Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
172b6e3ba2 | ||
|
|
e0b1561e16 |
No files matched your search
@@ -0,0 +1,4 @@
|
||||
[submodule "Peekaboo"]
|
||||
path = Peekaboo
|
||||
url = https://github.com/steipete/Peekaboo.git
|
||||
branch = main
|
||||
@@ -1,6 +1,6 @@
|
||||
# Repository Guidelines
|
||||
- Repo: https://github.com/clawdbot/clawdbot
|
||||
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
||||
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||
@@ -84,7 +84,6 @@
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
|
||||
+1
-68
@@ -2,41 +2,6 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.18-5
|
||||
|
||||
### Changes
|
||||
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||
- Agents: make inbound message envelopes configurable (timezone/timestamp/elapsed) and surface elapsed gaps. (#1150) — thanks @shiv19.
|
||||
|
||||
### Fixes
|
||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
||||
- Agents: retry compaction reset once, then surface a user-facing error on repeat failure. (#1187) — thanks @fayrose.
|
||||
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
||||
- macOS: use label colors for session preview text so previews render in menu subviews.
|
||||
|
||||
## 2026.1.18-4
|
||||
|
||||
### Changes
|
||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
|
||||
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
|
||||
- Swabble: use the tagged Commander Swift package release.
|
||||
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
||||
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
||||
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
||||
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
|
||||
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
|
||||
- Memory: add native Gemini embeddings provider for memory search. (#1151)
|
||||
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
||||
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
|
||||
|
||||
### Fixes
|
||||
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
||||
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
|
||||
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev.
|
||||
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
||||
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
||||
|
||||
## 2026.1.18-3
|
||||
|
||||
### Changes
|
||||
@@ -44,52 +9,20 @@ Docs: https://docs.clawd.bot
|
||||
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
||||
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
||||
- macOS: add approvals socket UI server + node exec lifecycle events.
|
||||
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
|
||||
- Nodes: add node daemon service install/status/start/stop/restart.
|
||||
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||
- Plugins: add typed lifecycle hooks + vector memory plugin. (#1149) — thanks @radek-paclt.
|
||||
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
||||
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
||||
- Agents: auto-inject local image references for vision models and avoid reloading history images. (#1098) — thanks @tyler6204.
|
||||
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
||||
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
|
||||
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
|
||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
|
||||
|
||||
### Fixes
|
||||
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
||||
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
||||
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
|
||||
|
||||
## 2026.1.18-2
|
||||
|
||||
### Fixes
|
||||
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
||||
|
||||
## 2026.1.18-1
|
||||
|
||||
### Changes
|
||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||
|
||||
### Fixes
|
||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||
- Discord: only emit slow listener warnings after 30s.
|
||||
|
||||
## 2026.1.17-6
|
||||
|
||||
### Changes
|
||||
|
||||
Submodule
+1
Submodule Peekaboo added at 5c195f5e46.
@@ -474,25 +474,25 @@ Core contributors:
|
||||
Thanks to all clawtributors:
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
||||
<a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
|
||||
<a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
|
||||
<a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
|
||||
<a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
|
||||
<a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a>
|
||||
<a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a>
|
||||
<a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a>
|
||||
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
|
||||
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
||||
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
|
||||
<a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
|
||||
<a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
|
||||
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
|
||||
<a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
|
||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
|
||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
|
||||
<a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
|
||||
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
|
||||
<a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
|
||||
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a>
|
||||
<a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a>
|
||||
<a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/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/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
|
||||
<a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a>
|
||||
<a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/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/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/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a>
|
||||
<a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/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/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a>
|
||||
<a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
|
||||
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/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/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/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a>
|
||||
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
|
||||
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a>
|
||||
<a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/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/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/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/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a>
|
||||
<a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a>
|
||||
<a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a>
|
||||
<a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/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/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a>
|
||||
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/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/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a>
|
||||
<a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a>
|
||||
<a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a>
|
||||
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a>
|
||||
<a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
</p>
|
||||
@@ -13,7 +13,7 @@ let package = Package(
|
||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
|
||||
.package(path: "../Peekaboo/Commander"),
|
||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||
],
|
||||
targets: [
|
||||
|
||||
+73
-28
@@ -1,24 +1,6 @@
|
||||
{
|
||||
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
|
||||
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "axorcist",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/AXorcist.git",
|
||||
"state" : {
|
||||
"revision" : "c75d06f7f93e264a9786edc2b78c04973061cb2f",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "commander",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Commander.git",
|
||||
"state" : {
|
||||
"revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce",
|
||||
"version" : "0.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -28,6 +10,15 @@
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "eventsource",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/mattt/eventsource.git",
|
||||
"state" : {
|
||||
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "menubarextraaccess",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -37,15 +28,6 @@
|
||||
"version" : "1.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "peekaboo",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Peekaboo.git",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sparkle",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -64,6 +46,33 @@
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-asn1.git",
|
||||
"state" : {
|
||||
"revision" : "810496cf121e525d660cd0ea89a758740476b85f",
|
||||
"version" : "1.5.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-async-algorithms",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-async-algorithms",
|
||||
"state" : {
|
||||
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -73,6 +82,24 @@
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-configuration",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-configuration",
|
||||
"state" : {
|
||||
"revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749",
|
||||
"version" : "1.0.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-crypto",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-crypto.git",
|
||||
"state" : {
|
||||
"revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095",
|
||||
"version" : "4.2.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-log",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -91,6 +118,24 @@
|
||||
"version" : "1.1.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-sdk",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/modelcontextprotocol/swift-sdk.git",
|
||||
"state" : {
|
||||
"revision" : "c0407a0b52677cb395d824cac2879b963075ba8c",
|
||||
"version" : "0.10.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-service-lifecycle",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swift-server/swift-service-lifecycle",
|
||||
"state" : {
|
||||
"revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348",
|
||||
"version" : "2.9.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-subprocess",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -20,9 +20,10 @@ let package = Package(
|
||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
||||
.package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"),
|
||||
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"),
|
||||
.package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"),
|
||||
.package(path: "../shared/ClawdbotKit"),
|
||||
.package(path: "../../Swabble"),
|
||||
.package(path: "../../Peekaboo/Core/PeekabooCore"),
|
||||
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@@ -60,8 +61,8 @@ let package = Package(
|
||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||
.product(name: "Logging", package: "swift-log"),
|
||||
.product(name: "Sparkle", package: "Sparkle"),
|
||||
.product(name: "PeekabooBridge", package: "Peekaboo"),
|
||||
.product(name: "PeekabooAutomationKit", package: "Peekaboo"),
|
||||
.product(name: "PeekabooBridge", package: "PeekabooCore"),
|
||||
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
||||
],
|
||||
exclude: [
|
||||
"Resources/Info.plist",
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Clawdbot macOS app (dev + signing)
|
||||
|
||||
## Quick dev run
|
||||
|
||||
```bash
|
||||
# from repo root
|
||||
scripts/restart-mac.sh
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
```bash
|
||||
scripts/restart-mac.sh --no-sign # fastest dev; ad-hoc signing (TCC permissions do not stick)
|
||||
scripts/restart-mac.sh --sign # force code signing (requires cert)
|
||||
```
|
||||
|
||||
## Packaging flow
|
||||
|
||||
```bash
|
||||
scripts/package-mac-app.sh
|
||||
```
|
||||
|
||||
Creates `dist/Clawdbot.app` and signs it via `scripts/codesign-mac-app.sh`.
|
||||
|
||||
## Signing behavior
|
||||
|
||||
Auto-selects identity (first match):
|
||||
1) Developer ID Application
|
||||
2) Apple Distribution
|
||||
3) Apple Development
|
||||
4) first available identity
|
||||
|
||||
If none found:
|
||||
- errors by default
|
||||
- set `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` to ad-hoc sign
|
||||
|
||||
## Team ID audit (Sparkle mismatch guard)
|
||||
|
||||
After signing, we read the app bundle Team ID and compare every Mach-O inside the app.
|
||||
If any embedded binary has a different Team ID, signing fails.
|
||||
|
||||
Skip the audit:
|
||||
```bash
|
||||
SKIP_TEAM_ID_CHECK=1 scripts/package-mac-app.sh
|
||||
```
|
||||
|
||||
## Library validation workaround (dev only)
|
||||
|
||||
If Sparkle Team ID mismatch blocks loading (common with Apple Development certs), opt in:
|
||||
|
||||
```bash
|
||||
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh
|
||||
```
|
||||
|
||||
This adds `com.apple.security.cs.disable-library-validation` to app entitlements.
|
||||
Use for local dev only; keep off for release builds.
|
||||
|
||||
## Useful env flags
|
||||
|
||||
- `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"`
|
||||
- `ALLOW_ADHOC_SIGNING=1` (ad-hoc, TCC permissions do not persist)
|
||||
- `CODESIGN_TIMESTAMP=off` (offline debug)
|
||||
- `DISABLE_LIBRARY_VALIDATION=1` (dev-only Sparkle workaround)
|
||||
- `SKIP_TEAM_ID_CHECK=1` (bypass audit)
|
||||
@@ -8,8 +8,6 @@ struct BridgeNodeInfo: Sendable {
|
||||
var displayName: String?
|
||||
var platform: String?
|
||||
var version: String?
|
||||
var coreVersion: String?
|
||||
var uiVersion: String?
|
||||
var deviceFamily: String?
|
||||
var modelIdentifier: String?
|
||||
var remoteAddress: String?
|
||||
@@ -149,8 +147,6 @@ actor BridgeConnectionHandler {
|
||||
displayName: hello.displayName,
|
||||
platform: hello.platform,
|
||||
version: hello.version,
|
||||
coreVersion: hello.coreVersion,
|
||||
uiVersion: hello.uiVersion,
|
||||
deviceFamily: hello.deviceFamily,
|
||||
modelIdentifier: hello.modelIdentifier,
|
||||
remoteAddress: self.remoteAddressString(),
|
||||
@@ -175,8 +171,6 @@ actor BridgeConnectionHandler {
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
version: req.version,
|
||||
coreVersion: req.coreVersion,
|
||||
uiVersion: req.uiVersion,
|
||||
deviceFamily: req.deviceFamily,
|
||||
modelIdentifier: req.modelIdentifier,
|
||||
caps: req.caps,
|
||||
@@ -192,8 +186,6 @@ actor BridgeConnectionHandler {
|
||||
displayName: enriched.displayName,
|
||||
platform: enriched.platform,
|
||||
version: enriched.version,
|
||||
coreVersion: enriched.coreVersion,
|
||||
uiVersion: enriched.uiVersion,
|
||||
deviceFamily: enriched.deviceFamily,
|
||||
modelIdentifier: enriched.modelIdentifier,
|
||||
remoteAddress: enriched.remoteAddress,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Security
|
||||
@@ -122,13 +121,6 @@ struct ExecApprovalsFile: Codable {
|
||||
var agents: [String: ExecApprovalsAgent]?
|
||||
}
|
||||
|
||||
struct ExecApprovalsSnapshot: Codable {
|
||||
var path: String
|
||||
var exists: Bool
|
||||
var hash: String
|
||||
var file: ExecApprovalsFile
|
||||
}
|
||||
|
||||
struct ExecApprovalsResolved {
|
||||
let url: URL
|
||||
let socketPath: String
|
||||
@@ -161,58 +153,6 @@ enum ExecApprovalsStore {
|
||||
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
|
||||
}
|
||||
|
||||
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return ExecApprovalsFile(
|
||||
version: 1,
|
||||
socket: ExecApprovalsSocketConfig(
|
||||
path: socketPath.isEmpty ? nil : socketPath,
|
||||
token: token.isEmpty ? nil : token),
|
||||
defaults: file.defaults,
|
||||
agents: file.agents)
|
||||
}
|
||||
|
||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||
let url = self.fileURL()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
return ExecApprovalsSnapshot(
|
||||
path: url.path,
|
||||
exists: false,
|
||||
hash: self.hashRaw(nil),
|
||||
file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]))
|
||||
}
|
||||
let raw = try? String(contentsOf: url, encoding: .utf8)
|
||||
let data = raw.flatMap { $0.data(using: .utf8) }
|
||||
let decoded: ExecApprovalsFile = {
|
||||
if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 {
|
||||
return file
|
||||
}
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
}()
|
||||
return ExecApprovalsSnapshot(
|
||||
path: url.path,
|
||||
exists: true,
|
||||
hash: self.hashRaw(raw),
|
||||
file: decoded)
|
||||
}
|
||||
|
||||
static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if socketPath.isEmpty {
|
||||
return ExecApprovalsFile(
|
||||
version: file.version,
|
||||
socket: nil,
|
||||
defaults: file.defaults,
|
||||
agents: file.agents)
|
||||
}
|
||||
return ExecApprovalsFile(
|
||||
version: file.version,
|
||||
socket: ExecApprovalsSocketConfig(path: socketPath, token: nil),
|
||||
defaults: file.defaults,
|
||||
agents: file.agents)
|
||||
}
|
||||
|
||||
static func loadFile() -> ExecApprovalsFile {
|
||||
let url = self.fileURL()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
@@ -222,7 +162,7 @@ enum ExecApprovalsStore {
|
||||
let data = try Data(contentsOf: url)
|
||||
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
||||
if decoded.version != 1 {
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
return ExecApprovalsFile(version: 1, socket: decoded.socket, defaults: decoded.defaults, agents: decoded.agents)
|
||||
}
|
||||
return decoded
|
||||
} catch {
|
||||
@@ -264,7 +204,7 @@ enum ExecApprovalsStore {
|
||||
}
|
||||
|
||||
static func resolve(agentId: String?) -> ExecApprovalsResolved {
|
||||
let file = self.ensureFile()
|
||||
var file = self.ensureFile()
|
||||
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||
let resolvedDefaults = ExecApprovalsResolvedDefaults(
|
||||
security: defaults.security ?? self.defaultSecurity,
|
||||
@@ -432,12 +372,6 @@ enum ExecApprovalsStore {
|
||||
return UUID().uuidString
|
||||
}
|
||||
|
||||
private static func hashRaw(_ raw: String?) -> String {
|
||||
let data = Data((raw ?? "").utf8)
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private static func expandPath(_ raw: String) -> String {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed == "~" {
|
||||
@@ -463,32 +397,11 @@ struct ExecCommandResolution: Sendable {
|
||||
let executableName: String
|
||||
let cwd: String?
|
||||
|
||||
static func resolve(
|
||||
command: [String],
|
||||
rawCommand: String?,
|
||||
cwd: String?,
|
||||
env: [String: String]?
|
||||
) -> ExecCommandResolution? {
|
||||
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
|
||||
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
|
||||
}
|
||||
return self.resolve(command: command, cwd: cwd, env: env)
|
||||
}
|
||||
|
||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||
}
|
||||
|
||||
private static func resolveExecutable(
|
||||
rawExecutable: String,
|
||||
cwd: String?,
|
||||
env: [String: String]?
|
||||
) -> ExecCommandResolution? {
|
||||
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
|
||||
let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw
|
||||
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
|
||||
let resolvedPath: String? = {
|
||||
if hasPathSeparator {
|
||||
@@ -506,20 +419,6 @@ struct ExecCommandResolution: Sendable {
|
||||
return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
|
||||
}
|
||||
|
||||
private static func parseFirstToken(_ command: String) -> String? {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard let first = trimmed.first else { return nil }
|
||||
if first == "\"" || first == "'" {
|
||||
let rest = trimmed.dropFirst()
|
||||
if let end = rest.firstIndex(of: first) {
|
||||
return String(rest[..<end])
|
||||
}
|
||||
return String(rest)
|
||||
}
|
||||
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
||||
}
|
||||
|
||||
private static func searchPaths(from env: [String: String]?) -> [String] {
|
||||
let raw = env?["PATH"]
|
||||
if let raw, !raw.isEmpty {
|
||||
@@ -540,12 +439,6 @@ enum ExecCommandFormatter {
|
||||
return "\"\(escaped)\""
|
||||
}.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func displayString(for argv: [String], rawCommand: String?) -> String {
|
||||
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
return self.displayString(for: argv)
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecAllowlistMatcher {
|
||||
@@ -629,7 +522,7 @@ struct ExecEventPayload: Codable, Sendable {
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.count <= maxChars { return trimmed }
|
||||
let suffix = trimmed.suffix(maxChars)
|
||||
return "... (truncated) \(suffix)"
|
||||
return "… (truncated) \(suffix)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import AppKit
|
||||
import ClawdbotKit
|
||||
import CryptoKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OSLog
|
||||
@@ -28,49 +27,6 @@ private struct ExecApprovalSocketDecision: Codable {
|
||||
var decision: ExecApprovalDecision
|
||||
}
|
||||
|
||||
fileprivate struct ExecHostSocketRequest: Codable {
|
||||
var type: String
|
||||
var id: String
|
||||
var nonce: String
|
||||
var ts: Int
|
||||
var hmac: String
|
||||
var requestJson: String
|
||||
}
|
||||
|
||||
fileprivate struct ExecHostRequest: Codable {
|
||||
var command: [String]
|
||||
var rawCommand: String?
|
||||
var cwd: String?
|
||||
var env: [String: String]?
|
||||
var timeoutMs: Int?
|
||||
var needsScreenRecording: Bool?
|
||||
var agentId: String?
|
||||
var sessionKey: String?
|
||||
}
|
||||
|
||||
fileprivate struct ExecHostRunResult: Codable {
|
||||
var exitCode: Int?
|
||||
var timedOut: Bool
|
||||
var success: Bool
|
||||
var stdout: String
|
||||
var stderr: String
|
||||
var error: String?
|
||||
}
|
||||
|
||||
fileprivate struct ExecHostError: Codable {
|
||||
var code: String
|
||||
var message: String
|
||||
var reason: String?
|
||||
}
|
||||
|
||||
fileprivate struct ExecHostResponse: Codable {
|
||||
var type: String
|
||||
var id: String
|
||||
var ok: Bool
|
||||
var payload: ExecHostRunResult?
|
||||
var error: ExecHostError?
|
||||
}
|
||||
|
||||
enum ExecApprovalsSocketClient {
|
||||
private struct TimeoutError: LocalizedError {
|
||||
var message: String
|
||||
@@ -190,9 +146,6 @@ final class ExecApprovalsPromptServer {
|
||||
token: approvals.token,
|
||||
onPrompt: { request in
|
||||
await ExecApprovalsPromptPresenter.prompt(request)
|
||||
},
|
||||
onExec: { request in
|
||||
await ExecHostExecutor.handle(request)
|
||||
})
|
||||
server.start()
|
||||
self.server = server
|
||||
@@ -204,10 +157,9 @@ final class ExecApprovalsPromptServer {
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecApprovalsPromptPresenter {
|
||||
private enum ExecApprovalsPromptPresenter {
|
||||
@MainActor
|
||||
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Allow this command?"
|
||||
@@ -253,182 +205,11 @@ enum ExecApprovalsPromptPresenter {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
fileprivate enum ExecHostExecutor {
|
||||
private static let blockedEnvKeys: Set<String> = [
|
||||
"PATH",
|
||||
"NODE_OPTIONS",
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
"PERL5LIB",
|
||||
"PERL5OPT",
|
||||
"RUBYOPT",
|
||||
]
|
||||
|
||||
private static let blockedEnvPrefixes: [String] = [
|
||||
"DYLD_",
|
||||
"LD_",
|
||||
]
|
||||
|
||||
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
||||
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard !command.isEmpty else {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
|
||||
}
|
||||
|
||||
let displayCommand = ExecCommandFormatter.displayString(
|
||||
for: command,
|
||||
rawCommand: request.rawCommand)
|
||||
let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil
|
||||
let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent)
|
||||
let security = approvals.agent.security
|
||||
let ask = approvals.agent.ask
|
||||
let autoAllowSkills = approvals.agent.autoAllowSkills
|
||||
let env = self.sanitizedEnv(request.env)
|
||||
let resolution = ExecCommandResolution.resolve(
|
||||
command: command,
|
||||
rawCommand: request.rawCommand,
|
||||
cwd: request.cwd,
|
||||
env: env)
|
||||
let allowlistMatch = security == .allowlist
|
||||
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
||||
: nil
|
||||
let skillAllow: Bool
|
||||
if autoAllowSkills, let name = resolution?.executableName {
|
||||
let bins = await SkillBinsCache.shared.currentBins()
|
||||
skillAllow = bins.contains(name)
|
||||
} else {
|
||||
skillAllow = false
|
||||
}
|
||||
|
||||
if security == .deny {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny", reason: "security=deny"))
|
||||
}
|
||||
|
||||
let requiresAsk: Bool = {
|
||||
if ask == .always { return true }
|
||||
if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true }
|
||||
return false
|
||||
}()
|
||||
|
||||
var approvedByAsk = false
|
||||
if requiresAsk {
|
||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: displayCommand,
|
||||
cwd: request.cwd,
|
||||
host: "node",
|
||||
security: security.rawValue,
|
||||
ask: ask.rawValue,
|
||||
agentId: trimmedAgent,
|
||||
resolvedPath: resolution?.resolvedPath))
|
||||
|
||||
switch decision {
|
||||
case .deny:
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: user denied", reason: "user-denied"))
|
||||
case .allowAlways:
|
||||
approvedByAsk = true
|
||||
if security == .allowlist {
|
||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||
if !pattern.isEmpty {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
||||
}
|
||||
}
|
||||
case .allowOnce:
|
||||
approvedByAsk = true
|
||||
}
|
||||
}
|
||||
|
||||
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss", reason: "allowlist-miss"))
|
||||
}
|
||||
|
||||
if let match = allowlistMatch {
|
||||
ExecApprovalsStore.recordAllowlistUse(
|
||||
agentId: trimmedAgent,
|
||||
pattern: match.pattern,
|
||||
command: displayCommand,
|
||||
resolvedPath: resolution?.resolvedPath)
|
||||
}
|
||||
|
||||
if request.needsScreenRecording == true {
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
if !authorized {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording", reason: "permission:screenRecording"))
|
||||
}
|
||||
}
|
||||
|
||||
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||
let result = await Task.detached { () -> ShellExecutor.ShellResult in
|
||||
await ShellExecutor.runDetailed(
|
||||
command: command,
|
||||
cwd: request.cwd,
|
||||
env: env,
|
||||
timeout: timeoutSec)
|
||||
}.value
|
||||
let payload = ExecHostRunResult(
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
error: result.errorMessage)
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: UUID().uuidString,
|
||||
ok: true,
|
||||
payload: payload,
|
||||
error: nil)
|
||||
}
|
||||
|
||||
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
|
||||
guard let overrides else { return nil }
|
||||
var merged = ProcessInfo.processInfo.environment
|
||||
for (rawKey, value) in overrides {
|
||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { continue }
|
||||
let upper = key.uppercased()
|
||||
if self.blockedEnvKeys.contains(upper) { continue }
|
||||
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
||||
merged[key] = value
|
||||
}
|
||||
return merged
|
||||
}
|
||||
}
|
||||
|
||||
private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
private final class ExecApprovalsSocketServer {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
|
||||
private let socketPath: String
|
||||
private let token: String
|
||||
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
|
||||
private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse
|
||||
private var socketFD: Int32 = -1
|
||||
private var acceptTask: Task<Void, Never>?
|
||||
private var isRunning = false
|
||||
@@ -436,13 +217,11 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
init(
|
||||
socketPath: String,
|
||||
token: String,
|
||||
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision,
|
||||
onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse)
|
||||
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision)
|
||||
{
|
||||
self.socketPath = socketPath
|
||||
self.token = token
|
||||
self.onPrompt = onPrompt
|
||||
self.onExec = onExec
|
||||
}
|
||||
|
||||
func start() {
|
||||
@@ -537,39 +316,26 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
private func handleClient(fd: Int32) async {
|
||||
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||
do {
|
||||
guard self.isAllowedPeer(fd: fd) else {
|
||||
try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny)
|
||||
return
|
||||
}
|
||||
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||
let data = line.data(using: .utf8)
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard
|
||||
let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let type = envelope["type"] as? String
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
if type == "request" {
|
||||
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
|
||||
guard request.token == self.token else {
|
||||
try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny)
|
||||
return
|
||||
}
|
||||
let decision = await self.onPrompt(request.request)
|
||||
try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision)
|
||||
return
|
||||
}
|
||||
|
||||
if type == "exec" {
|
||||
let request = try JSONDecoder().decode(ExecHostSocketRequest.self, from: data)
|
||||
let response = await self.handleExecRequest(request)
|
||||
try self.sendExecResponse(handle: handle, response: response)
|
||||
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
|
||||
guard request.type == "request", request.token == self.token else {
|
||||
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny)
|
||||
let data = try JSONEncoder().encode(response)
|
||||
var payload = data
|
||||
payload.append(0x0A)
|
||||
try handle.write(contentsOf: payload)
|
||||
return
|
||||
}
|
||||
let decision = await self.onPrompt(request.request)
|
||||
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: decision)
|
||||
let responseData = try JSONEncoder().encode(response)
|
||||
var payload = responseData
|
||||
payload.append(0x0A)
|
||||
try handle.write(contentsOf: payload)
|
||||
} catch {
|
||||
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
@@ -590,77 +356,4 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
|
||||
private func sendApprovalResponse(
|
||||
handle: FileHandle,
|
||||
id: String,
|
||||
decision: ExecApprovalDecision) throws
|
||||
{
|
||||
let response = ExecApprovalSocketDecision(type: "decision", id: id, decision: decision)
|
||||
let data = try JSONEncoder().encode(response)
|
||||
var payload = data
|
||||
payload.append(0x0A)
|
||||
try handle.write(contentsOf: payload)
|
||||
}
|
||||
|
||||
private func sendExecResponse(handle: FileHandle, response: ExecHostResponse) throws {
|
||||
let data = try JSONEncoder().encode(response)
|
||||
var payload = data
|
||||
payload.append(0x0A)
|
||||
try handle.write(contentsOf: payload)
|
||||
}
|
||||
|
||||
private func isAllowedPeer(fd: Int32) -> Bool {
|
||||
var uid = uid_t(0)
|
||||
var gid = gid_t(0)
|
||||
if getpeereid(fd, &uid, &gid) != 0 {
|
||||
return false
|
||||
}
|
||||
return uid == geteuid()
|
||||
}
|
||||
|
||||
private func handleExecRequest(_ request: ExecHostSocketRequest) async -> ExecHostResponse {
|
||||
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
if abs(nowMs - request.ts) > 10_000 {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: request.id,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl"))
|
||||
}
|
||||
let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson)
|
||||
if expected != request.hmac {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: request.id,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid auth", reason: "hmac"))
|
||||
}
|
||||
guard let requestData = request.requestJson.data(using: .utf8),
|
||||
let payload = try? JSONDecoder().decode(ExecHostRequest.self, from: requestData)
|
||||
else {
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: request.id,
|
||||
ok: false,
|
||||
payload: nil,
|
||||
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid payload", reason: "json"))
|
||||
}
|
||||
let response = await self.onExec(payload)
|
||||
return ExecHostResponse(
|
||||
type: "exec-res",
|
||||
id: request.id,
|
||||
ok: response.ok,
|
||||
payload: response.payload,
|
||||
error: response.error)
|
||||
}
|
||||
|
||||
private func hmacHex(nonce: String, ts: Int, requestJson: String) -> String {
|
||||
let key = SymmetricKey(data: Data(self.token.utf8))
|
||||
let message = "\(nonce):\(ts):\(requestJson)"
|
||||
let mac = HMAC<SHA256>.authenticationCode(for: Data(message.utf8), using: key)
|
||||
return mac.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -249,13 +249,6 @@ actor GatewayConnection {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
func cachedGatewayVersion() -> String? {
|
||||
guard let snapshot = self.lastSnapshot else { return nil }
|
||||
let raw = snapshot.server["version"]?.value as? String
|
||||
let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
|
||||
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
||||
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -16,10 +16,6 @@ enum GatewayLaunchAgentManager {
|
||||
|
||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||
_ = bundlePath
|
||||
guard !CommandResolver.connectionModeIsRemote() else {
|
||||
self.logger.info("launchd change skipped (remote mode)")
|
||||
return nil
|
||||
}
|
||||
if enabled, self.isLaunchAgentWriteDisabled() {
|
||||
self.logger.info("launchd enable skipped (disable marker set)")
|
||||
return nil
|
||||
@@ -116,9 +112,7 @@ extension GatewayLaunchAgentManager {
|
||||
{
|
||||
let command = CommandResolver.clawdbotCommand(
|
||||
subcommand: "daemon",
|
||||
extraArgs: self.withJsonFlag(args),
|
||||
// Launchd management must always run locally, even if remote mode is configured.
|
||||
configRoot: ["gateway": ["mode": "local"]])
|
||||
extraArgs: self.withJsonFlag(args))
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||
|
||||
@@ -114,9 +114,6 @@ final class GatewayProcessManager {
|
||||
self.lastFailureReason = nil
|
||||
self.status = .stopped
|
||||
self.logger.info("gateway stop requested")
|
||||
if CommandResolver.connectionModeIsRemote() {
|
||||
return
|
||||
}
|
||||
let bundlePath = Bundle.main.bundleURL.path
|
||||
Task {
|
||||
_ = await GatewayLaunchAgentManager.set(
|
||||
|
||||
@@ -14,7 +14,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
private weak var statusItem: NSStatusItem?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var nodesLoadTask: Task<Void, Never>?
|
||||
private var previewTasks: [Task<Void, Never>] = []
|
||||
private var isMenuOpen = false
|
||||
private var lastKnownMenuWidth: CGFloat?
|
||||
private var menuOpenWidth: CGFloat?
|
||||
@@ -88,7 +87,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
self.menuOpenWidth = nil
|
||||
self.loadTask?.cancel()
|
||||
self.nodesLoadTask?.cancel()
|
||||
self.cancelPreviewTasks()
|
||||
}
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
@@ -109,7 +107,6 @@ extension MenuSessionsInjector {
|
||||
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
||||
|
||||
private func inject(into menu: NSMenu) {
|
||||
self.cancelPreviewTasks()
|
||||
// Remove any previous injected items.
|
||||
for item in menu.items where item.tag == self.tag {
|
||||
menu.removeItem(item)
|
||||
@@ -283,7 +280,9 @@ extension MenuSessionsInjector {
|
||||
|
||||
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
||||
let rows = self.usageRows
|
||||
if rows.isEmpty {
|
||||
let errorText = self.cachedUsageErrorText
|
||||
|
||||
if rows.isEmpty, errorText == nil {
|
||||
return cursor
|
||||
}
|
||||
|
||||
@@ -307,6 +306,25 @@ extension MenuSessionsInjector {
|
||||
menu.insertItem(headerItem, at: cursor)
|
||||
cursor += 1
|
||||
|
||||
if let errorText = errorText?.nonEmpty, !rows.isEmpty {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(
|
||||
text: errorText,
|
||||
symbolName: "exclamationmark.triangle",
|
||||
width: width,
|
||||
maxLines: 2),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
if rows.isEmpty {
|
||||
menu.insertItem(
|
||||
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
|
||||
at: cursor)
|
||||
cursor += 1
|
||||
return cursor
|
||||
}
|
||||
|
||||
if let selectedProvider = self.selectedUsageProviderId,
|
||||
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
|
||||
rows.count > 1
|
||||
@@ -422,8 +440,6 @@ extension MenuSessionsInjector {
|
||||
displayName: "Gateway",
|
||||
platform: platform,
|
||||
version: nil,
|
||||
coreVersion: nil,
|
||||
uiVersion: nil,
|
||||
deviceFamily: nil,
|
||||
modelIdentifier: nil,
|
||||
remoteIp: host,
|
||||
@@ -457,46 +473,15 @@ extension MenuSessionsInjector {
|
||||
item.tag = self.tag
|
||||
item.isEnabled = false
|
||||
let view = AnyView(SessionMenuPreviewView(
|
||||
sessionKey: sessionKey,
|
||||
width: width,
|
||||
maxItems: 10,
|
||||
maxLines: maxLines,
|
||||
title: title,
|
||||
items: [],
|
||||
status: .loading))
|
||||
let hosting = NSHostingView(rootView: view)
|
||||
hosting.frame.size.width = max(1, width)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||
item.view = hosting
|
||||
|
||||
let task = Task { [weak hosting] in
|
||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: sessionKey, maxItems: 10)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard let hosting else { return }
|
||||
let nextView = AnyView(SessionMenuPreviewView(
|
||||
width: width,
|
||||
maxLines: maxLines,
|
||||
title: title,
|
||||
items: snapshot.items,
|
||||
status: snapshot.status))
|
||||
hosting.rootView = nextView
|
||||
hosting.invalidateIntrinsicContentSize()
|
||||
hosting.frame.size.width = max(1, width)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame.size.height = size.height
|
||||
}
|
||||
}
|
||||
self.previewTasks.append(task)
|
||||
title: title))
|
||||
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
||||
return item
|
||||
}
|
||||
|
||||
private func cancelPreviewTasks() {
|
||||
for task in self.previewTasks {
|
||||
task.cancel()
|
||||
}
|
||||
self.previewTasks.removeAll()
|
||||
}
|
||||
|
||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
|
||||
let view = AnyView(
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
@@ -574,11 +559,14 @@ extension MenuSessionsInjector {
|
||||
|
||||
do {
|
||||
self.cachedUsageSummary = try await UsageLoader.loadSummary()
|
||||
} catch {
|
||||
self.cachedUsageSummary = nil
|
||||
self.cachedUsageErrorText = nil
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
} catch {
|
||||
if self.cachedUsageSummary == nil {
|
||||
self.cachedUsageErrorText = self.compactUsageError(error)
|
||||
}
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
}
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
}
|
||||
|
||||
private func compactUsageError(_ error: Error) -> String {
|
||||
@@ -759,8 +747,8 @@ extension MenuSessionsInjector {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
||||
}
|
||||
|
||||
if let version = NodeMenuEntryFormatter.detailRightVersion(entry)?.nonEmpty {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Version", value: version))
|
||||
if let version = entry.version?.nonEmpty {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version)))
|
||||
}
|
||||
|
||||
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
|
||||
|
||||
@@ -95,8 +95,6 @@ actor MacNodeBridgePairingClient {
|
||||
displayName: hello.displayName,
|
||||
platform: hello.platform,
|
||||
version: hello.version,
|
||||
coreVersion: hello.coreVersion,
|
||||
uiVersion: hello.uiVersion,
|
||||
deviceFamily: hello.deviceFamily,
|
||||
modelIdentifier: hello.modelIdentifier,
|
||||
caps: hello.caps,
|
||||
|
||||
@@ -114,19 +114,12 @@ final class MacNodeModeCoordinator {
|
||||
let caps = self.currentCaps()
|
||||
let commands = self.currentCommands(caps: caps)
|
||||
let permissions = await self.currentPermissions()
|
||||
let uiVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
||||
let liveGatewayVersion = await GatewayConnection.shared.cachedGatewayVersion()
|
||||
let fallbackGatewayVersion = GatewayProcessManager.shared.environmentStatus.gatewayVersion
|
||||
let coreVersion = (liveGatewayVersion ?? fallbackGatewayVersion)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return BridgeHello(
|
||||
nodeId: Self.nodeId(),
|
||||
displayName: InstanceIdentity.displayName,
|
||||
token: token,
|
||||
platform: "macos",
|
||||
version: uiVersion,
|
||||
coreVersion: coreVersion?.isEmpty == false ? coreVersion : nil,
|
||||
uiVersion: uiVersion,
|
||||
version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: InstanceIdentity.modelIdentifier,
|
||||
caps: caps,
|
||||
@@ -165,8 +158,6 @@ final class MacNodeModeCoordinator {
|
||||
ClawdbotSystemCommand.notify.rawValue,
|
||||
ClawdbotSystemCommand.which.rawValue,
|
||||
ClawdbotSystemCommand.run.rawValue,
|
||||
ClawdbotSystemCommand.execApprovalsGet.rawValue,
|
||||
ClawdbotSystemCommand.execApprovalsSet.rawValue,
|
||||
]
|
||||
|
||||
let capsSet = Set(caps)
|
||||
|
||||
@@ -64,10 +64,6 @@ actor MacNodeRuntime {
|
||||
return try await self.handleSystemWhich(req)
|
||||
case ClawdbotSystemCommand.notify.rawValue:
|
||||
return try await self.handleSystemNotify(req)
|
||||
case ClawdbotSystemCommand.execApprovalsGet.rawValue:
|
||||
return try await self.handleSystemExecApprovalsGet(req)
|
||||
case ClawdbotSystemCommand.execApprovalsSet.rawValue:
|
||||
return try await self.handleSystemExecApprovalsSet(req)
|
||||
default:
|
||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
|
||||
}
|
||||
@@ -436,7 +432,6 @@ actor MacNodeRuntime {
|
||||
guard !command.isEmpty else {
|
||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||
}
|
||||
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: params.rawCommand)
|
||||
|
||||
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
||||
@@ -449,12 +444,7 @@ actor MacNodeRuntime {
|
||||
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: self.mainSessionKey
|
||||
let runId = UUID().uuidString
|
||||
let env = Self.sanitizedEnv(params.env)
|
||||
let resolution = ExecCommandResolution.resolve(
|
||||
command: command,
|
||||
rawCommand: params.rawCommand,
|
||||
cwd: params.cwd,
|
||||
env: env)
|
||||
let resolution = ExecCommandResolution.resolve(command: command, cwd: params.cwd, env: params.env)
|
||||
let allowlistMatch = security == .allowlist
|
||||
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
||||
: nil
|
||||
@@ -473,7 +463,7 @@ actor MacNodeRuntime {
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
reason: "security=deny"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
@@ -487,11 +477,12 @@ actor MacNodeRuntime {
|
||||
return false
|
||||
}()
|
||||
|
||||
var approvedByAsk = false
|
||||
if requiresAsk {
|
||||
let decision: ExecApprovalDecision? = await ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: displayCommand,
|
||||
let decision = await ExecApprovalsSocketClient.requestDecision(
|
||||
socketPath: approvals.socketPath,
|
||||
token: approvals.token,
|
||||
request: ExecApprovalPromptRequest(
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
cwd: params.cwd,
|
||||
host: "node",
|
||||
security: security.rawValue,
|
||||
@@ -507,40 +498,21 @@ actor MacNodeRuntime {
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
reason: "user-denied"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied")
|
||||
case nil:
|
||||
if askFallback == .full {
|
||||
approvedByAsk = true
|
||||
} else if askFallback == .allowlist {
|
||||
if allowlistMatch != nil || skillAllow {
|
||||
approvedByAsk = true
|
||||
} else {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "approval-required"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: approval required")
|
||||
}
|
||||
} else {
|
||||
if askFallback == .deny || (askFallback == .allowlist && allowlistMatch == nil && !skillAllow) {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
reason: "approval-required"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
@@ -548,7 +520,6 @@ actor MacNodeRuntime {
|
||||
message: "SYSTEM_RUN_DENIED: approval required")
|
||||
}
|
||||
case .allowAlways?:
|
||||
approvedByAsk = true
|
||||
if security == .allowlist {
|
||||
let pattern = resolution?.resolvedPath ??
|
||||
resolution?.rawExecutable ??
|
||||
@@ -559,33 +530,20 @@ actor MacNodeRuntime {
|
||||
}
|
||||
}
|
||||
case .allowOnce?:
|
||||
approvedByAsk = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "allowlist-miss"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: allowlist miss")
|
||||
}
|
||||
|
||||
if let match = allowlistMatch {
|
||||
ExecApprovalsStore.recordAllowlistUse(
|
||||
agentId: agentId,
|
||||
pattern: match.pattern,
|
||||
command: displayCommand,
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
resolvedPath: resolution?.resolvedPath)
|
||||
}
|
||||
|
||||
let env = Self.sanitizedEnv(params.env)
|
||||
|
||||
if params.needsScreenRecording == true {
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
@@ -596,7 +554,7 @@ actor MacNodeRuntime {
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
reason: "permission:screenRecording"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
@@ -612,23 +570,20 @@ actor MacNodeRuntime {
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand))
|
||||
command: ExecCommandFormatter.displayString(for: command)))
|
||||
let result = await ShellExecutor.runDetailed(
|
||||
command: command,
|
||||
cwd: params.cwd,
|
||||
env: env,
|
||||
timeout: timeoutSec)
|
||||
let combined = [result.stdout, result.stderr, result.errorMessage]
|
||||
.compactMap { $0 }
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
let combined = [result.stdout, result.stderr, result.errorMessage].filter { !$0.isEmpty }.joined(separator: "\n")
|
||||
await self.emitExecEvent(
|
||||
"exec.finished",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
command: ExecCommandFormatter.displayString(for: command),
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
@@ -680,72 +635,6 @@ actor MacNodeRuntime {
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||
let redacted = ExecApprovalsSnapshot(
|
||||
path: snapshot.path,
|
||||
exists: snapshot.exists,
|
||||
hash: snapshot.hash,
|
||||
file: ExecApprovalsStore.redactForSnapshot(snapshot.file))
|
||||
let payload = try Self.encodePayload(redacted)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private func handleSystemExecApprovalsSet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
struct SetParams: Decodable {
|
||||
var file: ExecApprovalsFile
|
||||
var baseHash: String?
|
||||
}
|
||||
|
||||
let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON)
|
||||
let current = ExecApprovalsStore.ensureFile()
|
||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||
if snapshot.exists {
|
||||
if snapshot.hash.isEmpty {
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: exec approvals base hash unavailable; reload and retry")
|
||||
}
|
||||
let baseHash = params.baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if baseHash.isEmpty {
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: exec approvals base hash required; reload and retry")
|
||||
}
|
||||
if baseHash != snapshot.hash {
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .invalidRequest,
|
||||
message: "INVALID_REQUEST: exec approvals changed; reload and retry")
|
||||
}
|
||||
}
|
||||
|
||||
var normalized = ExecApprovalsStore.normalizeIncoming(params.file)
|
||||
let socketPath = normalized.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let token = normalized.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let resolvedPath = (socketPath?.isEmpty == false)
|
||||
? socketPath!
|
||||
: current.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ??
|
||||
ExecApprovalsStore.socketPath()
|
||||
let resolvedToken = (token?.isEmpty == false)
|
||||
? token!
|
||||
: current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken)
|
||||
|
||||
ExecApprovalsStore.saveFile(normalized)
|
||||
let nextSnapshot = ExecApprovalsStore.readSnapshot()
|
||||
let redacted = ExecApprovalsSnapshot(
|
||||
path: nextSnapshot.path,
|
||||
exists: nextSnapshot.exists,
|
||||
hash: nextSnapshot.hash,
|
||||
file: ExecApprovalsStore.redactForSnapshot(nextSnapshot.file))
|
||||
let payload = try Self.encodePayload(redacted)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
|
||||
guard let sender = self.eventSender else { return }
|
||||
guard let data = try? JSONEncoder().encode(payload),
|
||||
|
||||
@@ -35,9 +35,8 @@ struct NodeMenuEntryFormatter {
|
||||
if let platform = self.platformText(entry) {
|
||||
parts.append("platform \(platform)")
|
||||
}
|
||||
let versionLabels = self.versionLabels(entry)
|
||||
if !versionLabels.isEmpty {
|
||||
parts.append(versionLabels.joined(separator: " · "))
|
||||
if let version = entry.version?.nonEmpty {
|
||||
parts.append("app \(self.compactVersion(version))")
|
||||
}
|
||||
parts.append("status \(self.roleText(entry))")
|
||||
return parts.joined(separator: " · ")
|
||||
@@ -61,9 +60,8 @@ struct NodeMenuEntryFormatter {
|
||||
}
|
||||
|
||||
static func detailRightVersion(_ entry: NodeInfo) -> String? {
|
||||
let labels = self.versionLabels(entry, compact: false)
|
||||
if labels.isEmpty { return nil }
|
||||
return labels.joined(separator: " · ")
|
||||
guard let version = entry.version?.nonEmpty else { return nil }
|
||||
return self.shortVersionLabel(version)
|
||||
}
|
||||
|
||||
static func platformText(_ entry: NodeInfo) -> String? {
|
||||
@@ -129,39 +127,6 @@ struct NodeMenuEntryFormatter {
|
||||
return compact
|
||||
}
|
||||
|
||||
private static func versionLabels(_ entry: NodeInfo, compact: Bool = true) -> [String] {
|
||||
let (core, ui) = self.resolveVersions(entry)
|
||||
var labels: [String] = []
|
||||
if let core {
|
||||
let label = compact ? self.compactVersion(core) : self.shortVersionLabel(core)
|
||||
labels.append("core \(label)")
|
||||
}
|
||||
if let ui {
|
||||
let label = compact ? self.compactVersion(ui) : self.shortVersionLabel(ui)
|
||||
labels.append("ui \(label)")
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
private static func resolveVersions(_ entry: NodeInfo) -> (core: String?, ui: String?) {
|
||||
let core = entry.coreVersion?.nonEmpty
|
||||
let ui = entry.uiVersion?.nonEmpty
|
||||
if core != nil || ui != nil {
|
||||
return (core, ui)
|
||||
}
|
||||
guard let legacy = entry.version?.nonEmpty else { return (nil, nil) }
|
||||
if self.isHeadlessPlatform(entry) {
|
||||
return (legacy, nil)
|
||||
}
|
||||
return (nil, legacy)
|
||||
}
|
||||
|
||||
private static func isHeadlessPlatform(_ entry: NodeInfo) -> Bool {
|
||||
let raw = entry.platform?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
||||
if raw == "darwin" || raw == "linux" || raw == "win32" || raw == "windows" { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
||||
if self.isGateway(entry) {
|
||||
return self.safeSystemSymbol(
|
||||
|
||||
@@ -7,8 +7,6 @@ struct NodeInfo: Identifiable, Codable {
|
||||
let displayName: String?
|
||||
let platform: String?
|
||||
let version: String?
|
||||
let coreVersion: String?
|
||||
let uiVersion: String?
|
||||
let deviceFamily: String?
|
||||
let modelIdentifier: String?
|
||||
let remoteIp: String?
|
||||
|
||||
@@ -3,13 +3,13 @@ import ClawdbotKit
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
struct SessionPreviewItem: Identifiable, Sendable {
|
||||
private struct SessionPreviewItem: Identifiable, Sendable {
|
||||
let id: String
|
||||
let role: PreviewRole
|
||||
let text: String
|
||||
}
|
||||
|
||||
enum PreviewRole: String, Sendable {
|
||||
private enum PreviewRole: String, Sendable {
|
||||
case user
|
||||
case assistant
|
||||
case tool
|
||||
@@ -27,7 +27,7 @@ enum PreviewRole: String, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
actor SessionPreviewCache {
|
||||
private actor SessionPreviewCache {
|
||||
static let shared = SessionPreviewCache()
|
||||
|
||||
private struct CacheEntry {
|
||||
@@ -52,33 +52,25 @@ actor SessionPreviewCache {
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension SessionPreviewCache {
|
||||
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
|
||||
}
|
||||
|
||||
func _testReset() {
|
||||
self.entries = [:]
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
struct SessionMenuPreviewSnapshot: Sendable {
|
||||
let items: [SessionPreviewItem]
|
||||
let status: SessionMenuPreviewView.LoadStatus
|
||||
}
|
||||
|
||||
struct SessionMenuPreviewView: View {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||
private static let previewTimeoutSeconds: Double = 4
|
||||
|
||||
let sessionKey: String
|
||||
let width: CGFloat
|
||||
let maxItems: Int
|
||||
let maxLines: Int
|
||||
let title: String
|
||||
let items: [SessionPreviewItem]
|
||||
let status: LoadStatus
|
||||
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
@State private var items: [SessionPreviewItem] = []
|
||||
@State private var status: LoadStatus = .loading
|
||||
|
||||
enum LoadStatus: Equatable {
|
||||
private struct PreviewTimeoutError: LocalizedError {
|
||||
var errorDescription: String? { "preview timeout" }
|
||||
}
|
||||
|
||||
private enum LoadStatus: Equatable {
|
||||
case loading
|
||||
case ready
|
||||
case empty
|
||||
@@ -86,17 +78,15 @@ struct SessionMenuPreviewView: View {
|
||||
}
|
||||
|
||||
private var primaryColor: Color {
|
||||
if self.isHighlighted {
|
||||
return Color(nsColor: .selectedMenuItemTextColor)
|
||||
}
|
||||
return Color(nsColor: .labelColor)
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
||||
}
|
||||
|
||||
private var secondaryColor: Color {
|
||||
if self.isHighlighted {
|
||||
return Color(nsColor: .selectedMenuItemTextColor).opacity(0.85)
|
||||
}
|
||||
return Color(nsColor: .secondaryLabelColor)
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
||||
}
|
||||
|
||||
private var previewLimit: Int {
|
||||
min(max(self.maxItems * 3, 20), 120)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -110,19 +100,21 @@ struct SessionMenuPreviewView: View {
|
||||
|
||||
switch self.status {
|
||||
case .loading:
|
||||
self.placeholder("Loading preview…")
|
||||
Text("Loading preview…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
case .empty:
|
||||
self.placeholder("No recent messages")
|
||||
Text("No recent messages")
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
case let .error(message):
|
||||
self.placeholder(message)
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
case .ready:
|
||||
if self.items.isEmpty {
|
||||
self.placeholder("No recent messages")
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.items) { item in
|
||||
self.previewRow(item)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.items) { item in
|
||||
self.previewRow(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +123,9 @@ struct SessionMenuPreviewView: View {
|
||||
.padding(.leading, 16)
|
||||
.padding(.trailing, 11)
|
||||
.frame(width: max(1, self.width), alignment: .leading)
|
||||
.task(id: self.sessionKey) {
|
||||
await self.loadPreview()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -162,66 +157,55 @@ struct SessionMenuPreviewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func placeholder(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.primaryColor)
|
||||
}
|
||||
private func loadPreview() async {
|
||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: self.sessionKey, maxAge: 12) {
|
||||
await MainActor.run {
|
||||
self.items = cached
|
||||
self.status = cached.isEmpty ? .empty : .ready
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
enum SessionMenuPreviewLoader {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||
private static let previewTimeoutSeconds: Double = 4
|
||||
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
||||
|
||||
private struct PreviewTimeoutError: LocalizedError {
|
||||
var errorDescription: String? { "preview timeout" }
|
||||
}
|
||||
|
||||
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
|
||||
return Self.snapshot(from: cached)
|
||||
await MainActor.run {
|
||||
self.status = .loading
|
||||
}
|
||||
|
||||
do {
|
||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
|
||||
let payload = try await AsyncTimeout.withTimeout(
|
||||
seconds: self.previewTimeoutSeconds,
|
||||
seconds: Self.previewTimeoutSeconds,
|
||||
onTimeout: { PreviewTimeoutError() },
|
||||
operation: {
|
||||
try await GatewayConnection.shared.chatHistory(
|
||||
sessionKey: sessionKey,
|
||||
limit: self.previewLimit(for: maxItems),
|
||||
sessionKey: self.sessionKey,
|
||||
limit: self.previewLimit,
|
||||
timeoutMs: timeoutMs)
|
||||
})
|
||||
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
||||
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
||||
return Self.snapshot(from: built)
|
||||
let built = Self.previewItems(from: payload, maxItems: self.maxItems)
|
||||
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
|
||||
await MainActor.run {
|
||||
self.items = built
|
||||
self.status = built.isEmpty ? .empty : .ready
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
||||
return
|
||||
} catch {
|
||||
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
|
||||
if let fallback {
|
||||
return Self.snapshot(from: fallback)
|
||||
let fallback = await SessionPreviewCache.shared.lastItems(for: self.sessionKey)
|
||||
await MainActor.run {
|
||||
if let fallback {
|
||||
self.items = fallback
|
||||
self.status = fallback.isEmpty ? .empty : .ready
|
||||
} else {
|
||||
self.status = .error("Preview unavailable")
|
||||
}
|
||||
}
|
||||
let errorDescription = String(describing: error)
|
||||
Self.logger.warning(
|
||||
"Session preview failed session=\(sessionKey, privacy: .public) " +
|
||||
"Session preview failed session=\(self.sessionKey, privacy: .public) " +
|
||||
"error=\(errorDescription, privacy: .public)")
|
||||
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
||||
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||
}
|
||||
|
||||
private static func previewLimit(for maxItems: Int) -> Int {
|
||||
min(max(maxItems * 3, 20), 120)
|
||||
}
|
||||
|
||||
private static func previewItems(
|
||||
from payload: ClawdbotChatHistoryPayload,
|
||||
maxItems: Int) -> [SessionPreviewItem]
|
||||
|
||||
@@ -13,16 +13,18 @@ struct SystemRunSettingsView: View {
|
||||
Text("Exec approvals")
|
||||
.font(.body)
|
||||
Spacer(minLength: 0)
|
||||
Picker("Agent", selection: Binding(
|
||||
get: { self.model.selectedAgentId },
|
||||
set: { self.model.selectAgent($0) }))
|
||||
{
|
||||
ForEach(self.model.agentPickerIds, id: \.self) { id in
|
||||
Text(self.model.label(for: id)).tag(id)
|
||||
if self.model.agentIds.count > 1 {
|
||||
Picker("Agent", selection: Binding(
|
||||
get: { self.model.selectedAgentId },
|
||||
set: { self.model.selectAgent($0) }))
|
||||
{
|
||||
ForEach(self.model.agentIds, id: \.self) { id in
|
||||
Text(id).tag(id)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 180, alignment: .trailing)
|
||||
}
|
||||
|
||||
Picker("", selection: self.$tab) {
|
||||
@@ -80,9 +82,7 @@ struct SystemRunSettingsView: View {
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Text(self.model.isDefaultsScope
|
||||
? "Defaults apply when an agent has no overrides. Ask controls prompt behavior; fallback is used when no companion UI is reachable."
|
||||
: "Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.")
|
||||
Text("Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -101,37 +101,31 @@ struct SystemRunSettingsView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if self.model.isDefaultsScope {
|
||||
Text("Allowlists are per-agent. Select an agent to edit its allowlist.")
|
||||
HStack(spacing: 8) {
|
||||
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Add") {
|
||||
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !pattern.isEmpty else { return }
|
||||
self.model.addEntry(pattern)
|
||||
self.newPattern = ""
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
if self.model.entries.isEmpty {
|
||||
Text("No allowlisted commands yet.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button("Add") {
|
||||
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !pattern.isEmpty else { return }
|
||||
self.model.addEntry(pattern)
|
||||
self.newPattern = ""
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
if self.model.entries.isEmpty {
|
||||
Text("No allowlisted commands yet.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
||||
ExecAllowlistRow(
|
||||
entry: Binding(
|
||||
get: { self.model.entries[index] },
|
||||
set: { self.model.updateEntry($0, at: index) }),
|
||||
onRemove: { self.model.removeEntry(at: index) })
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
||||
ExecAllowlistRow(
|
||||
entry: Binding(
|
||||
get: { self.model.entries[index] },
|
||||
set: { self.model.updateEntry($0, at: index) }),
|
||||
onRemove: { self.model.removeEntry(at: index) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,16 +177,8 @@ struct ExecAllowlistRow: View {
|
||||
Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
|
||||
Text("Last command: \(lastUsedCommand)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let lastResolvedPath = self.entry.lastResolvedPath, !lastResolvedPath.isEmpty {
|
||||
Text("Resolved path: \(lastResolvedPath)")
|
||||
} else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
|
||||
Text("Last used: \(lastUsedCommand)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -215,7 +201,6 @@ struct ExecAllowlistRow: View {
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ExecApprovalsSettingsModel {
|
||||
private static let defaultsScopeId = "__defaults__"
|
||||
var agentIds: [String] = []
|
||||
var selectedAgentId: String = "main"
|
||||
var defaultAgentId: String = "main"
|
||||
@@ -226,19 +211,6 @@ final class ExecApprovalsSettingsModel {
|
||||
var entries: [ExecAllowlistEntry] = []
|
||||
var skillBins: [String] = []
|
||||
|
||||
var agentPickerIds: [String] {
|
||||
[Self.defaultsScopeId] + self.agentIds
|
||||
}
|
||||
|
||||
var isDefaultsScope: Bool {
|
||||
self.selectedAgentId == Self.defaultsScopeId
|
||||
}
|
||||
|
||||
func label(for id: String) -> String {
|
||||
if id == Self.defaultsScopeId { return "Defaults" }
|
||||
return id
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
await self.refreshAgents()
|
||||
self.loadSettings(for: self.selectedAgentId)
|
||||
@@ -270,9 +242,6 @@ final class ExecApprovalsSettingsModel {
|
||||
}
|
||||
self.agentIds = ids
|
||||
self.defaultAgentId = defaultId ?? "main"
|
||||
if self.selectedAgentId == Self.defaultsScopeId {
|
||||
return
|
||||
}
|
||||
if !self.agentIds.contains(self.selectedAgentId) {
|
||||
self.selectedAgentId = self.defaultAgentId
|
||||
}
|
||||
@@ -285,15 +254,6 @@ final class ExecApprovalsSettingsModel {
|
||||
}
|
||||
|
||||
func loadSettings(for agentId: String) {
|
||||
if agentId == Self.defaultsScopeId {
|
||||
let defaults = ExecApprovalsStore.resolveDefaults()
|
||||
self.security = defaults.security
|
||||
self.ask = defaults.ask
|
||||
self.askFallback = defaults.askFallback
|
||||
self.autoAllowSkills = defaults.autoAllowSkills
|
||||
self.entries = []
|
||||
return
|
||||
}
|
||||
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
|
||||
self.security = resolved.agent.security
|
||||
self.ask = resolved.agent.ask
|
||||
@@ -305,61 +265,36 @@ final class ExecApprovalsSettingsModel {
|
||||
|
||||
func setSecurity(_ security: ExecSecurity) {
|
||||
self.security = security
|
||||
if self.isDefaultsScope {
|
||||
ExecApprovalsStore.updateDefaults { defaults in
|
||||
defaults.security = security
|
||||
}
|
||||
} else {
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.security = security
|
||||
}
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.security = security
|
||||
}
|
||||
self.syncQuickMode()
|
||||
}
|
||||
|
||||
func setAsk(_ ask: ExecAsk) {
|
||||
self.ask = ask
|
||||
if self.isDefaultsScope {
|
||||
ExecApprovalsStore.updateDefaults { defaults in
|
||||
defaults.ask = ask
|
||||
}
|
||||
} else {
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.ask = ask
|
||||
}
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.ask = ask
|
||||
}
|
||||
self.syncQuickMode()
|
||||
}
|
||||
|
||||
func setAskFallback(_ mode: ExecSecurity) {
|
||||
self.askFallback = mode
|
||||
if self.isDefaultsScope {
|
||||
ExecApprovalsStore.updateDefaults { defaults in
|
||||
defaults.askFallback = mode
|
||||
}
|
||||
} else {
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.askFallback = mode
|
||||
}
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.askFallback = mode
|
||||
}
|
||||
}
|
||||
|
||||
func setAutoAllowSkills(_ enabled: Bool) {
|
||||
self.autoAllowSkills = enabled
|
||||
if self.isDefaultsScope {
|
||||
ExecApprovalsStore.updateDefaults { defaults in
|
||||
defaults.autoAllowSkills = enabled
|
||||
}
|
||||
} else {
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.autoAllowSkills = enabled
|
||||
}
|
||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||
entry.autoAllowSkills = enabled
|
||||
}
|
||||
Task { await self.refreshSkillBins(force: enabled) }
|
||||
}
|
||||
|
||||
func addEntry(_ pattern: String) {
|
||||
guard !self.isDefaultsScope else { return }
|
||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
|
||||
@@ -367,14 +302,12 @@ final class ExecApprovalsSettingsModel {
|
||||
}
|
||||
|
||||
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
|
||||
guard !self.isDefaultsScope else { return }
|
||||
guard self.entries.indices.contains(index) else { return }
|
||||
self.entries[index] = entry
|
||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||
}
|
||||
|
||||
func removeEntry(at index: Int) {
|
||||
guard !self.isDefaultsScope else { return }
|
||||
guard self.entries.indices.contains(index) else { return }
|
||||
self.entries.remove(at: index)
|
||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||
@@ -390,10 +323,6 @@ final class ExecApprovalsSettingsModel {
|
||||
}
|
||||
|
||||
private func syncQuickMode() {
|
||||
if self.isDefaultsScope {
|
||||
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
||||
return
|
||||
}
|
||||
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
|
||||
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
||||
}
|
||||
|
||||
@@ -530,8 +530,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
||||
public let displayname: String?
|
||||
public let platform: String?
|
||||
public let version: String?
|
||||
public let coreversion: String?
|
||||
public let uiversion: String?
|
||||
public let devicefamily: String?
|
||||
public let modelidentifier: String?
|
||||
public let caps: [String]?
|
||||
@@ -544,8 +542,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
||||
displayname: String?,
|
||||
platform: String?,
|
||||
version: String?,
|
||||
coreversion: String?,
|
||||
uiversion: String?,
|
||||
devicefamily: String?,
|
||||
modelidentifier: String?,
|
||||
caps: [String]?,
|
||||
@@ -557,8 +553,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
||||
self.displayname = displayname
|
||||
self.platform = platform
|
||||
self.version = version
|
||||
self.coreversion = coreversion
|
||||
self.uiversion = uiversion
|
||||
self.devicefamily = devicefamily
|
||||
self.modelidentifier = modelidentifier
|
||||
self.caps = caps
|
||||
@@ -571,8 +565,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
||||
case displayname = "displayName"
|
||||
case platform
|
||||
case version
|
||||
case coreversion = "coreVersion"
|
||||
case uiversion = "uiVersion"
|
||||
case devicefamily = "deviceFamily"
|
||||
case modelidentifier = "modelIdentifier"
|
||||
case caps
|
||||
@@ -1640,85 +1632,6 @@ public struct LogsTailResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ExecApprovalsGetParams: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct ExecApprovalsSetParams: Codable, Sendable {
|
||||
public let file: [String: AnyCodable]
|
||||
public let basehash: String?
|
||||
|
||||
public init(
|
||||
file: [String: AnyCodable],
|
||||
basehash: String?
|
||||
) {
|
||||
self.file = file
|
||||
self.basehash = basehash
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case file
|
||||
case basehash = "baseHash"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ExecApprovalsNodeGetParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
|
||||
public init(
|
||||
nodeid: String
|
||||
) {
|
||||
self.nodeid = nodeid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ExecApprovalsNodeSetParams: Codable, Sendable {
|
||||
public let nodeid: String
|
||||
public let file: [String: AnyCodable]
|
||||
public let basehash: String?
|
||||
|
||||
public init(
|
||||
nodeid: String,
|
||||
file: [String: AnyCodable],
|
||||
basehash: String?
|
||||
) {
|
||||
self.nodeid = nodeid
|
||||
self.file = file
|
||||
self.basehash = basehash
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case nodeid = "nodeId"
|
||||
case file
|
||||
case basehash = "baseHash"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||
public let path: String
|
||||
public let exists: Bool
|
||||
public let hash: String
|
||||
public let file: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
path: String,
|
||||
exists: Bool,
|
||||
hash: String,
|
||||
file: [String: AnyCodable]
|
||||
) {
|
||||
self.path = path
|
||||
self.exists = exists
|
||||
self.hash = hash
|
||||
self.file = file
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case path
|
||||
case exists
|
||||
case hash
|
||||
case file
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatHistoryParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let limit: Int?
|
||||
|
||||
@@ -134,27 +134,4 @@ import Testing
|
||||
#expect(script.contains("CLI="))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func configRootLocalOverridesRemoteDefaults() async throws {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||
defaults.set("clawd@example.com:2222", forKey: remoteTargetKey)
|
||||
|
||||
let tmp = try makeTempDir()
|
||||
CommandResolver.setProjectRoot(tmp.path)
|
||||
|
||||
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
|
||||
try self.makeExec(at: clawdbotPath)
|
||||
|
||||
let cmd = CommandResolver.clawdbotCommand(
|
||||
subcommand: "daemon",
|
||||
defaults: defaults,
|
||||
configRoot: ["gateway": ["mode": "local"]])
|
||||
|
||||
#expect(cmd.first == clawdbotPath.path)
|
||||
#expect(cmd.count >= 2)
|
||||
if cmd.count >= 2 {
|
||||
#expect(cmd[1] == "daemon")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized)
|
||||
struct SessionMenuPreviewTests {
|
||||
@Test func loaderReturnsCachedItems() async {
|
||||
await SessionPreviewCache.shared._testReset()
|
||||
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
||||
await SessionPreviewCache.shared._testSet(items: items, for: "main")
|
||||
|
||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||
#expect(snapshot.status == .ready)
|
||||
#expect(snapshot.items.count == 1)
|
||||
#expect(snapshot.items.first?.text == "Hi")
|
||||
}
|
||||
|
||||
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
||||
await SessionPreviewCache.shared._testReset()
|
||||
await SessionPreviewCache.shared._testSet(items: [], for: "main")
|
||||
|
||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||
#expect(snapshot.status == .empty)
|
||||
#expect(snapshot.items.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -63,8 +63,6 @@ public struct BridgeHello: Codable, Sendable {
|
||||
public let token: String?
|
||||
public let platform: String?
|
||||
public let version: String?
|
||||
public let coreVersion: String?
|
||||
public let uiVersion: String?
|
||||
public let deviceFamily: String?
|
||||
public let modelIdentifier: String?
|
||||
public let caps: [String]?
|
||||
@@ -78,8 +76,6 @@ public struct BridgeHello: Codable, Sendable {
|
||||
token: String?,
|
||||
platform: String?,
|
||||
version: String?,
|
||||
coreVersion: String? = nil,
|
||||
uiVersion: String? = nil,
|
||||
deviceFamily: String? = nil,
|
||||
modelIdentifier: String? = nil,
|
||||
caps: [String]? = nil,
|
||||
@@ -92,8 +88,6 @@ public struct BridgeHello: Codable, Sendable {
|
||||
self.token = token
|
||||
self.platform = platform
|
||||
self.version = version
|
||||
self.coreVersion = coreVersion
|
||||
self.uiVersion = uiVersion
|
||||
self.deviceFamily = deviceFamily
|
||||
self.modelIdentifier = modelIdentifier
|
||||
self.caps = caps
|
||||
@@ -127,8 +121,6 @@ public struct BridgePairRequest: Codable, Sendable {
|
||||
public let displayName: String?
|
||||
public let platform: String?
|
||||
public let version: String?
|
||||
public let coreVersion: String?
|
||||
public let uiVersion: String?
|
||||
public let deviceFamily: String?
|
||||
public let modelIdentifier: String?
|
||||
public let caps: [String]?
|
||||
@@ -143,8 +135,6 @@ public struct BridgePairRequest: Codable, Sendable {
|
||||
displayName: String?,
|
||||
platform: String?,
|
||||
version: String?,
|
||||
coreVersion: String? = nil,
|
||||
uiVersion: String? = nil,
|
||||
deviceFamily: String? = nil,
|
||||
modelIdentifier: String? = nil,
|
||||
caps: [String]? = nil,
|
||||
@@ -158,8 +148,6 @@ public struct BridgePairRequest: Codable, Sendable {
|
||||
self.displayName = displayName
|
||||
self.platform = platform
|
||||
self.version = version
|
||||
self.coreVersion = coreVersion
|
||||
self.uiVersion = uiVersion
|
||||
self.deviceFamily = deviceFamily
|
||||
self.modelIdentifier = modelIdentifier
|
||||
self.caps = caps
|
||||
|
||||
@@ -4,8 +4,6 @@ public enum ClawdbotSystemCommand: String, Codable, Sendable {
|
||||
case run = "system.run"
|
||||
case which = "system.which"
|
||||
case notify = "system.notify"
|
||||
case execApprovalsGet = "system.execApprovals.get"
|
||||
case execApprovalsSet = "system.execApprovals.set"
|
||||
}
|
||||
|
||||
public enum ClawdbotNotificationPriority: String, Codable, Sendable {
|
||||
@@ -22,7 +20,6 @@ public enum ClawdbotNotificationDelivery: String, Codable, Sendable {
|
||||
|
||||
public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||
public var command: [String]
|
||||
public var rawCommand: String?
|
||||
public var cwd: String?
|
||||
public var env: [String: String]?
|
||||
public var timeoutMs: Int?
|
||||
@@ -32,7 +29,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||
|
||||
public init(
|
||||
command: [String],
|
||||
rawCommand: String? = nil,
|
||||
cwd: String? = nil,
|
||||
env: [String: String]? = nil,
|
||||
timeoutMs: Int? = nil,
|
||||
@@ -41,7 +37,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||
sessionKey: String? = nil)
|
||||
{
|
||||
self.command = command
|
||||
self.rawCommand = rawCommand
|
||||
self.cwd = cwd
|
||||
self.env = env
|
||||
self.timeoutMs = timeoutMs
|
||||
|
||||
-194
@@ -1,194 +0,0 @@
|
||||
# Clawdbot ACP Bridge
|
||||
|
||||
This document describes how the Clawdbot ACP (Agent Client Protocol) bridge works,
|
||||
how it maps ACP sessions to Gateway sessions, and how IDEs should invoke it.
|
||||
|
||||
## Overview
|
||||
|
||||
`clawdbot acp` exposes an ACP agent over stdio and forwards prompts to a running
|
||||
Clawdbot Gateway over WebSocket. It keeps ACP session ids mapped to Gateway
|
||||
session keys so IDEs can reconnect to the same agent transcript or reset it on
|
||||
request.
|
||||
|
||||
Key goals:
|
||||
|
||||
- Minimal ACP surface area (stdio, NDJSON).
|
||||
- Stable session mapping across reconnects.
|
||||
- Works with existing Gateway session store (list/resolve/reset).
|
||||
- Safe defaults (isolated ACP session keys by default).
|
||||
|
||||
## How can I use this
|
||||
|
||||
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
|
||||
drive a Clawdbot Gateway session.
|
||||
|
||||
Quick steps:
|
||||
|
||||
1. Run a Gateway (local or remote).
|
||||
2. Configure the Gateway target (`gateway.remote.url` + auth) or pass flags.
|
||||
3. Point the IDE to run `clawdbot acp` over stdio.
|
||||
|
||||
Example config:
|
||||
|
||||
```bash
|
||||
clawdbot config set gateway.remote.url wss://gateway-host:18789
|
||||
clawdbot config set gateway.remote.token <token>
|
||||
```
|
||||
|
||||
Example run:
|
||||
|
||||
```bash
|
||||
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
||||
```
|
||||
|
||||
## Selecting agents
|
||||
|
||||
ACP does not pick agents directly. It routes by the Gateway session key.
|
||||
|
||||
Use agent-scoped session keys to target a specific agent:
|
||||
|
||||
```bash
|
||||
clawdbot acp --session agent:main:main
|
||||
clawdbot acp --session agent:design:main
|
||||
clawdbot acp --session agent:qa:bug-123
|
||||
```
|
||||
|
||||
Each ACP session maps to a single Gateway session key. One agent can have many
|
||||
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
|
||||
the key or label.
|
||||
|
||||
## Zed editor setup
|
||||
|
||||
Add a custom ACP agent in `~/.config/zed/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"Clawdbot ACP": {
|
||||
"type": "custom",
|
||||
"command": "clawdbot",
|
||||
"args": ["acp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To target a specific Gateway or agent:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"Clawdbot ACP": {
|
||||
"type": "custom",
|
||||
"command": "clawdbot",
|
||||
"args": [
|
||||
"acp",
|
||||
"--url", "wss://gateway-host:18789",
|
||||
"--token", "<token>",
|
||||
"--session", "agent:design:main"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
|
||||
|
||||
## Execution Model
|
||||
|
||||
- ACP client spawns `clawdbot acp` and speaks ACP messages over stdio.
|
||||
- The bridge connects to the Gateway using existing auth config (or CLI flags).
|
||||
- ACP `prompt` translates to Gateway `chat.send`.
|
||||
- Gateway streaming events are translated back into ACP streaming events.
|
||||
- ACP `cancel` maps to Gateway `chat.abort` for the active run.
|
||||
|
||||
## Session Mapping
|
||||
|
||||
By default each ACP session is mapped to a dedicated Gateway session key:
|
||||
|
||||
- `acp:<uuid>` unless overridden.
|
||||
|
||||
You can override or reuse sessions in two ways:
|
||||
|
||||
1) CLI defaults
|
||||
|
||||
```bash
|
||||
clawdbot acp --session agent:main:main
|
||||
clawdbot acp --session-label "support inbox"
|
||||
clawdbot acp --reset-session
|
||||
```
|
||||
|
||||
2) ACP metadata per session
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"sessionKey": "agent:main:main",
|
||||
"sessionLabel": "support inbox",
|
||||
"resetSession": true,
|
||||
"requireExisting": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `sessionKey`: direct Gateway session key.
|
||||
- `sessionLabel`: resolve an existing session by label.
|
||||
- `resetSession`: mint a new transcript for the key before first use.
|
||||
- `requireExisting`: fail if the key/label does not exist.
|
||||
|
||||
### Session Listing
|
||||
|
||||
ACP `listSessions` maps to Gateway `sessions.list` and returns a filtered
|
||||
summary suitable for IDE session pickers. `_meta.limit` can cap the number of
|
||||
sessions returned.
|
||||
|
||||
## Prompt Translation
|
||||
|
||||
ACP prompt inputs are converted into a Gateway `chat.send`:
|
||||
|
||||
- `text` and `resource` blocks become prompt text.
|
||||
- `resource_link` with image mime types become attachments.
|
||||
- The working directory can be prefixed into the prompt (default on, can be
|
||||
disabled with `--no-prefix-cwd`).
|
||||
|
||||
Gateway streaming events are translated into ACP `message` and `tool_call`
|
||||
updates. Terminal Gateway states map to ACP `done` with stop reasons:
|
||||
|
||||
- `complete` -> `stop`
|
||||
- `aborted` -> `cancel`
|
||||
- `error` -> `error`
|
||||
|
||||
## Auth + Gateway Discovery
|
||||
|
||||
`clawdbot acp` resolves the Gateway URL and auth from CLI flags or config:
|
||||
|
||||
- `--url` / `--token` / `--password` take precedence.
|
||||
- Otherwise use configured `gateway.remote.*` settings.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- ACP sessions are stored in memory for the bridge process lifetime.
|
||||
- Gateway session state is persisted by the Gateway itself.
|
||||
- `--verbose` logs ACP/Gateway bridge events to stderr (never stdout).
|
||||
- ACP runs can be canceled and the active run id is tracked per session.
|
||||
|
||||
## Compatibility
|
||||
|
||||
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x).
|
||||
- Works with ACP clients that implement `initialize`, `newSession`,
|
||||
`loadSession`, `prompt`, `cancel`, and `listSessions`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: `src/acp/session.test.ts` covers run id lifecycle.
|
||||
- Full gate: `pnpm lint && pnpm build && pnpm test && pnpm docs:build`.
|
||||
|
||||
## Related Docs
|
||||
|
||||
- CLI usage: `docs/cli/acp.md`
|
||||
- Session model: `docs/concepts/session.md`
|
||||
- Session management internals: `docs/reference/session-management-compaction.md`
|
||||
+10
-45
@@ -1,13 +1,11 @@
|
||||
---
|
||||
summary: "Slack setup for socket or HTTP webhook mode"
|
||||
read_when: "Setting up Slack or debugging Slack socket/HTTP mode"
|
||||
summary: "Slack socket mode setup and Clawdbot config"
|
||||
read_when: "Setting up Slack or debugging Slack socket mode"
|
||||
---
|
||||
|
||||
# Slack
|
||||
# Slack (socket mode)
|
||||
|
||||
## Socket mode (default)
|
||||
|
||||
### Quick setup (beginner)
|
||||
## Quick setup (beginner)
|
||||
1) Create a Slack app and enable **Socket Mode**.
|
||||
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
|
||||
3) Set tokens for Clawdbot and start the gateway.
|
||||
@@ -25,7 +23,7 @@ Minimal config:
|
||||
}
|
||||
```
|
||||
|
||||
### Setup
|
||||
## Setup
|
||||
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
||||
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
||||
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||
@@ -45,7 +43,7 @@ Use the manifest below so scopes and events stay in sync.
|
||||
|
||||
Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||
|
||||
### Clawdbot config (minimal)
|
||||
## Clawdbot config (minimal)
|
||||
|
||||
Set tokens via env vars (recommended):
|
||||
- `SLACK_APP_TOKEN=xapp-...`
|
||||
@@ -65,7 +63,7 @@ Or via config:
|
||||
}
|
||||
```
|
||||
|
||||
### User token (optional)
|
||||
## User token (optional)
|
||||
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
|
||||
pins, reactions, emoji, member info). By default this stays read-only: reads
|
||||
prefer the user token when present, and writes still use the bot token unless
|
||||
@@ -104,51 +102,18 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
|
||||
}
|
||||
```
|
||||
|
||||
#### Token usage
|
||||
### Token usage
|
||||
- Read operations (history, reactions list, pins list, emoji list, member info,
|
||||
search) prefer the user token when configured, otherwise the bot token.
|
||||
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
|
||||
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
|
||||
no bot token is available, Clawdbot falls back to the user token.
|
||||
|
||||
### History context
|
||||
## History context
|
||||
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
||||
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||
|
||||
## HTTP mode (Events API)
|
||||
Use HTTP webhook mode when your Gateway is reachable by Slack over HTTPS (typical for server deployments).
|
||||
HTTP mode uses the Events API + Interactivity + Slash Commands with a shared request URL.
|
||||
|
||||
### Setup
|
||||
1) Create a Slack app and **disable Socket Mode** (optional if you only use HTTP).
|
||||
2) **Basic Information** → copy the **Signing Secret**.
|
||||
3) **OAuth & Permissions** → install the app and copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||
4) **Event Subscriptions** → enable events and set the **Request URL** to your gateway webhook path (default `/slack/events`).
|
||||
5) **Interactivity & Shortcuts** → enable and set the same **Request URL**.
|
||||
6) **Slash Commands** → set the same **Request URL** for your command(s).
|
||||
|
||||
Example request URL:
|
||||
`https://gateway-host/slack/events`
|
||||
|
||||
### Clawdbot config (minimal)
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "http",
|
||||
botToken: "xoxb-...",
|
||||
signingSecret: "your-signing-secret",
|
||||
webhookPath: "/slack/events"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Multi-account HTTP mode: set `channels.slack.accounts.<id>.mode = "http"` and provide a unique
|
||||
`webhookPath` per account so each Slack app can point to its own URL.
|
||||
|
||||
### Manifest (optional)
|
||||
## Manifest (optional)
|
||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
|
||||
user scopes if you plan to configure a user token.
|
||||
|
||||
|
||||
-166
@@ -1,166 +0,0 @@
|
||||
---
|
||||
summary: "Run the ACP bridge for IDE integrations"
|
||||
read_when:
|
||||
- Setting up ACP-based IDE integrations
|
||||
- Debugging ACP session routing to the Gateway
|
||||
---
|
||||
|
||||
# acp
|
||||
|
||||
Run the ACP (Agent Client Protocol) bridge that talks to a Clawdbot Gateway.
|
||||
|
||||
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
|
||||
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
clawdbot acp
|
||||
|
||||
# Remote Gateway
|
||||
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
||||
|
||||
# Attach to an existing session key
|
||||
clawdbot acp --session agent:main:main
|
||||
|
||||
# Attach by label (must already exist)
|
||||
clawdbot acp --session-label "support inbox"
|
||||
|
||||
# Reset the session key before the first prompt
|
||||
clawdbot acp --session agent:main:main --reset-session
|
||||
```
|
||||
|
||||
## ACP client (debug)
|
||||
|
||||
Use the built-in ACP client to sanity-check the bridge without an IDE.
|
||||
It spawns the ACP bridge and lets you type prompts interactively.
|
||||
|
||||
```bash
|
||||
clawdbot acp client
|
||||
|
||||
# Point the spawned bridge at a remote Gateway
|
||||
clawdbot acp client --server-args --url wss://gateway-host:18789 --token <token>
|
||||
|
||||
# Override the server command (default: clawdbot)
|
||||
clawdbot acp client --server "node" --server-args dist/entry.js acp --url ws://127.0.0.1:19001
|
||||
```
|
||||
|
||||
## How to use this
|
||||
|
||||
Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want
|
||||
it to drive a Clawdbot Gateway session.
|
||||
|
||||
1. Ensure the Gateway is running (local or remote).
|
||||
2. Configure the Gateway target (config or flags).
|
||||
3. Point your IDE to run `clawdbot acp` over stdio.
|
||||
|
||||
Example config (persisted):
|
||||
|
||||
```bash
|
||||
clawdbot config set gateway.remote.url wss://gateway-host:18789
|
||||
clawdbot config set gateway.remote.token <token>
|
||||
```
|
||||
|
||||
Example direct run (no config write):
|
||||
|
||||
```bash
|
||||
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
||||
```
|
||||
|
||||
## Selecting agents
|
||||
|
||||
ACP does not pick agents directly. It routes by the Gateway session key.
|
||||
|
||||
Use agent-scoped session keys to target a specific agent:
|
||||
|
||||
```bash
|
||||
clawdbot acp --session agent:main:main
|
||||
clawdbot acp --session agent:design:main
|
||||
clawdbot acp --session agent:qa:bug-123
|
||||
```
|
||||
|
||||
Each ACP session maps to a single Gateway session key. One agent can have many
|
||||
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
|
||||
the key or label.
|
||||
|
||||
## Zed editor setup
|
||||
|
||||
Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zed’s Settings UI):
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"Clawdbot ACP": {
|
||||
"type": "custom",
|
||||
"command": "clawdbot",
|
||||
"args": ["acp"],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To target a specific Gateway or agent:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"Clawdbot ACP": {
|
||||
"type": "custom",
|
||||
"command": "clawdbot",
|
||||
"args": [
|
||||
"acp",
|
||||
"--url", "wss://gateway-host:18789",
|
||||
"--token", "<token>",
|
||||
"--session", "agent:design:main"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
|
||||
|
||||
## Session mapping
|
||||
|
||||
By default, ACP sessions get an isolated Gateway session key with an `acp:` prefix.
|
||||
To reuse a known session, pass a session key or label:
|
||||
|
||||
- `--session <key>`: use a specific Gateway session key.
|
||||
- `--session-label <label>`: resolve an existing session by label.
|
||||
- `--reset-session`: mint a fresh session id for that key (same key, new transcript).
|
||||
|
||||
If your ACP client supports metadata, you can override per session:
|
||||
|
||||
```json
|
||||
{
|
||||
"_meta": {
|
||||
"sessionKey": "agent:main:main",
|
||||
"sessionLabel": "support inbox",
|
||||
"resetSession": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Learn more about session keys at [/concepts/session](/concepts/session).
|
||||
|
||||
## Options
|
||||
|
||||
- `--url <url>`: Gateway WebSocket URL (defaults to gateway.remote.url when configured).
|
||||
- `--token <token>`: Gateway auth token.
|
||||
- `--password <password>`: Gateway auth password.
|
||||
- `--session <key>`: default session key.
|
||||
- `--session-label <label>`: default session label to resolve.
|
||||
- `--require-existing`: fail if the session key/label does not exist.
|
||||
- `--reset-session`: reset the session key before first use.
|
||||
- `--no-prefix-cwd`: do not prefix prompts with the working directory.
|
||||
- `--verbose, -v`: verbose logging to stderr.
|
||||
|
||||
### `acp client` options
|
||||
|
||||
- `--cwd <dir>`: working directory for the ACP session.
|
||||
- `--server <command>`: ACP server command (default: `clawdbot`).
|
||||
- `--server-args <args...>`: extra arguments passed to the ACP server.
|
||||
- `--server-verbose`: enable verbose logging on the ACP server.
|
||||
- `--verbose, -v`: verbose client logging.
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot approvals` (exec approvals for gateway or node hosts)"
|
||||
read_when:
|
||||
- You want to edit exec approvals from the CLI
|
||||
- You need to manage allowlists on gateway or node hosts
|
||||
---
|
||||
|
||||
# `clawdbot approvals`
|
||||
|
||||
Manage exec approvals for the **gateway host** or a **node host**.
|
||||
By default, commands target the gateway. Use `--node` to edit a node’s approvals.
|
||||
|
||||
Related:
|
||||
- Exec approvals: [Exec approvals](/tools/exec-approvals)
|
||||
- Nodes: [Nodes](/nodes)
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
clawdbot approvals get
|
||||
clawdbot approvals get --node <id|name|ip>
|
||||
```
|
||||
|
||||
## Replace approvals from a file
|
||||
|
||||
```bash
|
||||
clawdbot approvals set --file ./exec-approvals.json
|
||||
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
|
||||
```
|
||||
|
||||
## Allowlist helpers
|
||||
|
||||
```bash
|
||||
clawdbot approvals allowlist add "~/Projects/**/bin/rg"
|
||||
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"
|
||||
|
||||
clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix).
|
||||
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
|
||||
- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`.
|
||||
@@ -15,7 +15,6 @@ the configure wizard (same as `clawdbot configure`).
|
||||
clawdbot config get browser.executablePath
|
||||
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
|
||||
clawdbot config set agents.defaults.heartbeat.every "2h"
|
||||
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
|
||||
clawdbot config unset tools.web.search.apiKey
|
||||
```
|
||||
|
||||
@@ -28,13 +27,6 @@ clawdbot config get agents.defaults.workspace
|
||||
clawdbot config get agents.list[0].id
|
||||
```
|
||||
|
||||
Use the agent list index to target a specific agent:
|
||||
|
||||
```bash
|
||||
clawdbot config get agents.list
|
||||
clawdbot config set agents.list[1].tools.exec.node "node-id-or-name"
|
||||
```
|
||||
|
||||
## Values
|
||||
|
||||
Values are parsed as JSON5 when possible; otherwise they are treated as strings.
|
||||
|
||||
@@ -9,9 +9,6 @@ read_when:
|
||||
|
||||
Manage the Gateway daemon (background service).
|
||||
|
||||
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains
|
||||
as a legacy alias for compatibility.
|
||||
|
||||
Related:
|
||||
- Gateway CLI: [Gateway](/cli/gateway)
|
||||
- macOS platform notes: [macOS](/platforms/macos)
|
||||
|
||||
+4
-19
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
# `clawdbot hooks`
|
||||
|
||||
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, and gateway startup).
|
||||
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
|
||||
|
||||
Related:
|
||||
- Hooks: [Hooks](/hooks)
|
||||
@@ -29,10 +29,9 @@ List all discovered hooks from workspace, managed, and bundled directories.
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Hooks (4/4 ready)
|
||||
Hooks (3/3 ready)
|
||||
|
||||
Ready:
|
||||
🚀 boot-md ✓ - Run BOOT.md on gateway startup
|
||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
||||
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
||||
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
|
||||
@@ -108,8 +107,8 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
|
||||
```
|
||||
Hooks Status
|
||||
|
||||
Total hooks: 4
|
||||
Ready: 4
|
||||
Total hooks: 2
|
||||
Ready: 2
|
||||
Not ready: 0
|
||||
```
|
||||
|
||||
@@ -274,17 +273,3 @@ clawdbot hooks enable soul-evil
|
||||
```
|
||||
|
||||
**See:** [SOUL Evil Hook](/hooks/soul-evil)
|
||||
|
||||
### boot-md
|
||||
|
||||
Runs `BOOT.md` when the gateway starts (after channels start).
|
||||
|
||||
**Events**: `gateway:startup`
|
||||
|
||||
**Enable**:
|
||||
|
||||
```bash
|
||||
clawdbot hooks enable boot-md
|
||||
```
|
||||
|
||||
**See:** [boot-md documentation](/hooks#boot-md)
|
||||
+16
-59
@@ -23,19 +23,15 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`message`](/cli/message)
|
||||
- [`agent`](/cli/agent)
|
||||
- [`agents`](/cli/agents)
|
||||
- [`acp`](/cli/acp)
|
||||
- [`status`](/cli/status)
|
||||
- [`health`](/cli/health)
|
||||
- [`sessions`](/cli/sessions)
|
||||
- [`gateway`](/cli/gateway)
|
||||
- [`daemon`](/cli/daemon)
|
||||
- [`service`](/cli/service)
|
||||
- [`logs`](/cli/logs)
|
||||
- [`models`](/cli/models)
|
||||
- [`memory`](/cli/memory)
|
||||
- [`nodes`](/cli/nodes)
|
||||
- [`node`](/cli/node)
|
||||
- [`approvals`](/cli/approvals)
|
||||
- [`sandbox`](/cli/sandbox)
|
||||
- [`tui`](/cli/tui)
|
||||
- [`browser`](/cli/browser)
|
||||
@@ -129,7 +125,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
list
|
||||
add
|
||||
delete
|
||||
acp
|
||||
status
|
||||
health
|
||||
sessions
|
||||
@@ -145,21 +140,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
start
|
||||
stop
|
||||
restart
|
||||
service
|
||||
gateway
|
||||
status
|
||||
install
|
||||
uninstall
|
||||
start
|
||||
stop
|
||||
restart
|
||||
node
|
||||
status
|
||||
install
|
||||
uninstall
|
||||
start
|
||||
stop
|
||||
restart
|
||||
logs
|
||||
models
|
||||
list
|
||||
@@ -188,19 +168,21 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
runs
|
||||
run
|
||||
nodes
|
||||
node
|
||||
start
|
||||
daemon
|
||||
status
|
||||
install
|
||||
uninstall
|
||||
start
|
||||
stop
|
||||
restart
|
||||
approvals
|
||||
get
|
||||
set
|
||||
allowlist add|remove
|
||||
status
|
||||
describe
|
||||
list
|
||||
pending
|
||||
approve
|
||||
reject
|
||||
rename
|
||||
invoke
|
||||
run
|
||||
notify
|
||||
camera list|snap|clip
|
||||
canvas snapshot|present|hide|navigate|eval
|
||||
canvas a2ui push|reset
|
||||
screen record
|
||||
location get
|
||||
browser
|
||||
status
|
||||
start
|
||||
@@ -524,11 +506,6 @@ Options:
|
||||
- `--force`
|
||||
- `--json`
|
||||
|
||||
### `acp`
|
||||
Run the ACP bridge that connects IDEs to the Gateway.
|
||||
|
||||
See [`acp`](/cli/acp) for full options and examples.
|
||||
|
||||
### `status`
|
||||
Show linked session health and recent recipients.
|
||||
|
||||
@@ -541,9 +518,6 @@ Options:
|
||||
- `--verbose`
|
||||
- `--debug` (alias for `--verbose`)
|
||||
|
||||
Notes:
|
||||
- Overview includes Gateway + Node service status when available.
|
||||
|
||||
### Usage tracking
|
||||
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
||||
|
||||
@@ -798,23 +772,6 @@ Subcommands:
|
||||
|
||||
All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
|
||||
|
||||
## Node host
|
||||
|
||||
`node` runs a **headless node host** or manages it as a background service. See
|
||||
[`clawdbot node`](/cli/node).
|
||||
|
||||
Subcommands:
|
||||
- `node start --host <gateway-host> --port 18790`
|
||||
- `node service status`
|
||||
- `node service install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
||||
- `node service uninstall`
|
||||
- `node service start`
|
||||
- `node service stop`
|
||||
- `node service restart`
|
||||
|
||||
Legacy alias:
|
||||
- `node daemon …` (same as `node service …`)
|
||||
|
||||
## Nodes
|
||||
|
||||
`nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).
|
||||
@@ -831,7 +788,7 @@ Subcommands:
|
||||
- `nodes reject <requestId>`
|
||||
- `nodes rename --node <id|name|ip> --name <displayName>`
|
||||
- `nodes invoke --node <id|name|ip> --command <command> [--params <json>] [--invoke-timeout <ms>] [--idempotency-key <key>]`
|
||||
- `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac node or headless node host)
|
||||
- `nodes run --node <id|name|ip> [--cwd <path>] [--env KEY=VAL] [--command-timeout <ms>] [--needs-screen-recording] [--invoke-timeout <ms>] <command...>` (mac only)
|
||||
- `nodes notify --node <id|name|ip> [--title <text>] [--body <text>] [--sound <name>] [--priority <passive|active|timeSensitive>] [--delivery <system|overlay|auto>] [--invoke-timeout <ms>]` (mac only)
|
||||
|
||||
Camera:
|
||||
|
||||
+3
-14
@@ -7,8 +7,8 @@ read_when:
|
||||
|
||||
# `clawdbot memory`
|
||||
|
||||
Manage semantic memory indexing and search.
|
||||
Provided by the active memory plugin (default: `memory-core`; set `plugins.slots.memory = "none"` to disable).
|
||||
Memory search tools (semantic memory status/index/search).
|
||||
Provided by the active memory plugin (default: `memory-core`; use `plugins.slots.memory = "none"` to disable).
|
||||
|
||||
Related:
|
||||
- Memory concept: [Memory](/concepts/memory)
|
||||
@@ -22,20 +22,9 @@ clawdbot memory status --deep
|
||||
clawdbot memory status --deep --index
|
||||
clawdbot memory status --deep --index --verbose
|
||||
clawdbot memory index
|
||||
clawdbot memory index --verbose
|
||||
clawdbot memory search "release checklist"
|
||||
clawdbot memory status --agent main
|
||||
clawdbot memory index --agent main --verbose
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
Common:
|
||||
|
||||
- `--agent <id>`: scope to a single agent (default: all configured agents).
|
||||
- `--verbose`: emit detailed logs during probes and indexing.
|
||||
|
||||
Notes:
|
||||
- `memory status --deep` probes vector + embedding availability.
|
||||
- `memory status --deep --index` runs a reindex if the store is dirty.
|
||||
- `memory index --verbose` prints per-phase details (provider, model, sources, batch activity).
|
||||
- `--verbose`: emit debug logs during memory probes and indexing.
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot node` (headless node host)"
|
||||
read_when:
|
||||
- Running the headless node host
|
||||
- Pairing a non-macOS node for system.run
|
||||
---
|
||||
|
||||
# `clawdbot node`
|
||||
|
||||
Run a **headless node host** that connects to the Gateway bridge and exposes
|
||||
`system.run` / `system.which` on this machine.
|
||||
|
||||
## Why use a node host?
|
||||
|
||||
Use a node host when you want agents to **run commands on other machines** in your
|
||||
network without installing a full macOS companion app there.
|
||||
|
||||
Common use cases:
|
||||
- Run commands on remote Linux/Windows boxes (build servers, lab machines, NAS).
|
||||
- Keep exec **sandboxed** on the gateway, but delegate approved runs to other hosts.
|
||||
- Provide a lightweight, headless execution target for automation or CI nodes.
|
||||
|
||||
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
||||
node host, so you can keep command access scoped and explicit.
|
||||
|
||||
## Start (foreground)
|
||||
|
||||
```bash
|
||||
clawdbot node start --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
||||
- `--port <port>`: Gateway bridge port (default: `18790`)
|
||||
- `--tls`: Use TLS for the bridge connection
|
||||
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
||||
- `--node-id <id>`: Override node id (clears pairing token)
|
||||
- `--display-name <name>`: Override the node display name
|
||||
|
||||
## Service (background)
|
||||
|
||||
Install a headless node host as a user service.
|
||||
|
||||
```bash
|
||||
clawdbot node service install --host <gateway-host> --port 18790
|
||||
# or
|
||||
clawdbot service node install --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
||||
- `--port <port>`: Gateway bridge port (default: `18790`)
|
||||
- `--tls`: Use TLS for the bridge connection
|
||||
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
||||
- `--node-id <id>`: Override node id (clears pairing token)
|
||||
- `--display-name <name>`: Override the node display name
|
||||
- `--runtime <runtime>`: Service runtime (`node` or `bun`)
|
||||
- `--force`: Reinstall/overwrite if already installed
|
||||
|
||||
Manage the service:
|
||||
|
||||
```bash
|
||||
clawdbot node status
|
||||
clawdbot service node status
|
||||
clawdbot node service status
|
||||
clawdbot node service start
|
||||
clawdbot node service stop
|
||||
clawdbot node service restart
|
||||
clawdbot node service uninstall
|
||||
```
|
||||
|
||||
Legacy alias:
|
||||
|
||||
```bash
|
||||
clawdbot node daemon status
|
||||
```
|
||||
|
||||
## Pairing
|
||||
|
||||
The first connection creates a pending node pair request on the Gateway.
|
||||
Approve it via:
|
||||
|
||||
```bash
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
```
|
||||
|
||||
The node host stores its node id + token in `~/.clawdbot/node.json`.
|
||||
|
||||
## Exec approvals
|
||||
|
||||
`system.run` is gated by local exec approvals:
|
||||
|
||||
- `~/.clawdbot/exec-approvals.json`
|
||||
- [Exec approvals](/tools/exec-approvals)
|
||||
- `clawdbot approvals --node <id|name|ip>` (edit from the Gateway)
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot service` (manage gateway + node services)"
|
||||
read_when:
|
||||
- You want to manage Gateway or node services cross-platform
|
||||
- You want a single surface for start/stop/install/uninstall
|
||||
---
|
||||
|
||||
# `clawdbot service`
|
||||
|
||||
Manage the **Gateway** service and **node host** services.
|
||||
|
||||
Related:
|
||||
- Gateway daemon (legacy alias): [Daemon](/cli/daemon)
|
||||
- Node host: [Node](/cli/node)
|
||||
|
||||
## Gateway service
|
||||
|
||||
```bash
|
||||
clawdbot service gateway status
|
||||
clawdbot service gateway install --port 18789
|
||||
clawdbot service gateway start
|
||||
clawdbot service gateway stop
|
||||
clawdbot service gateway restart
|
||||
clawdbot service gateway uninstall
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `service gateway status` supports `--json` and `--deep` for system checks.
|
||||
- `service gateway install` supports `--runtime node|bun` and `--token`.
|
||||
|
||||
## Node host service
|
||||
|
||||
```bash
|
||||
clawdbot service node status
|
||||
clawdbot service node install --host <gateway-host> --port 18790
|
||||
clawdbot service node start
|
||||
clawdbot service node stop
|
||||
clawdbot service node restart
|
||||
clawdbot service node uninstall
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `service node install` supports `--runtime node|bun`, `--node-id`, `--display-name`,
|
||||
and TLS options (`--tls`, `--tls-fingerprint`).
|
||||
|
||||
## Aliases
|
||||
|
||||
- `clawdbot daemon …` → `clawdbot service gateway …`
|
||||
- `clawdbot node service …` → `clawdbot service node …`
|
||||
- `clawdbot node status` → `clawdbot service node status`
|
||||
- `clawdbot node daemon …` → `clawdbot service node …` (legacy)
|
||||
@@ -19,5 +19,4 @@ clawdbot status --usage
|
||||
Notes:
|
||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||
- Output includes per-agent session stores when multiple agents are configured.
|
||||
- Overview includes Gateway + Node service install/runtime status when available.
|
||||
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
||||
@@ -5,19 +5,13 @@ read_when:
|
||||
---
|
||||
# Agent Loop (Clawdbot)
|
||||
|
||||
An agentic loop is the full “real” run of an agent: intake → context assembly → model inference →
|
||||
tool execution → streaming replies → persistence. It’s the authoritative path that turns a message
|
||||
into actions and a final reply, while keeping session state consistent.
|
||||
|
||||
In Clawdbot, a loop is a single, serialized run per session that emits lifecycle and stream events
|
||||
as the model thinks, calls tools, and streams output. This doc explains how that authentic loop is
|
||||
wired end-to-end.
|
||||
Short, exact flow of one agent run.
|
||||
|
||||
## Entry points
|
||||
- Gateway RPC: `agent` and `agent.wait`.
|
||||
- CLI: `agent` command.
|
||||
|
||||
## How it works (high-level)
|
||||
## High-level flow
|
||||
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
|
||||
2) `agentCommand` runs the agent:
|
||||
- resolves model + thinking/verbose defaults
|
||||
@@ -25,9 +19,8 @@ wired end-to-end.
|
||||
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
|
||||
- emits **lifecycle end/error** if the embedded loop does not emit one
|
||||
3) `runEmbeddedPiAgent`:
|
||||
- serializes runs via per-session + global queues
|
||||
- resolves model + auth profile and builds the pi session
|
||||
- subscribes to pi events and streams assistant/tool deltas
|
||||
- builds `AgentSession` and subscribes to pi events
|
||||
- streams assistant deltas + tool events
|
||||
- enforces timeout -> aborts run if exceeded
|
||||
- returns payloads + usage metadata
|
||||
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream:
|
||||
@@ -38,73 +31,6 @@ wired end-to-end.
|
||||
- waits for **lifecycle end/error** for `runId`
|
||||
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
|
||||
|
||||
## Queueing + concurrency
|
||||
- Runs are serialized per session key (session lane) and optionally through a global lane.
|
||||
- This prevents tool/session races and keeps session history consistent.
|
||||
- Messaging channels can choose queue modes (collect/steer/followup) that feed this lane system.
|
||||
See [Command Queue](/concepts/queue).
|
||||
|
||||
## Session + workspace preparation
|
||||
- Workspace is resolved and created; sandboxed runs may redirect to a sandbox workspace root.
|
||||
- Skills are loaded (or reused from a snapshot) and injected into env and prompt.
|
||||
- Bootstrap/context files are resolved and injected into the system prompt report.
|
||||
- A session write lock is acquired; `SessionManager` is opened and prepared before streaming.
|
||||
|
||||
## Prompt assembly + system prompt
|
||||
- System prompt is built from Clawdbot’s base prompt, skills prompt, bootstrap context, and per-run overrides.
|
||||
- Model-specific limits and compaction reserve tokens are enforced.
|
||||
- See [System prompt](/concepts/system-prompt) for what the model sees.
|
||||
|
||||
## Hook points (where you can intercept)
|
||||
Clawdbot has two hook systems:
|
||||
- **Internal hooks** (Gateway hooks): event-driven scripts for commands and lifecycle events.
|
||||
- **Plugin hooks**: extension points inside the agent/tool lifecycle and gateway pipeline.
|
||||
|
||||
### Internal hooks (Gateway hooks)
|
||||
- **`agent:bootstrap`**: runs while building bootstrap files before the system prompt is finalized.
|
||||
Use this to add/remove bootstrap context files.
|
||||
- **Command hooks**: `/new`, `/reset`, `/stop`, and other command events (see Hooks doc).
|
||||
|
||||
See [Hooks](/hooks) for setup and examples.
|
||||
|
||||
### Plugin hooks (agent + gateway lifecycle)
|
||||
These run inside the agent loop or gateway pipeline:
|
||||
- **`before_agent_start`**: inject context or override system prompt before the run starts.
|
||||
- **`agent_end`**: inspect the final message list and run metadata after completion.
|
||||
- **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles.
|
||||
- **`before_tool_call` / `after_tool_call`**: intercept tool params/results.
|
||||
- **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks.
|
||||
- **`session_start` / `session_end`**: session lifecycle boundaries.
|
||||
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
|
||||
|
||||
See [Plugins](/plugin#plugin-hooks) for the hook API and registration details.
|
||||
|
||||
## Streaming + partial replies
|
||||
- Assistant deltas are streamed from pi-agent-core and emitted as `assistant` events.
|
||||
- Block streaming can emit partial replies either on `text_end` or `message_end`.
|
||||
- Reasoning streaming can be emitted as a separate stream or as block replies.
|
||||
- See [Streaming](/concepts/streaming) for chunking and block reply behavior.
|
||||
|
||||
## Tool execution + messaging tools
|
||||
- Tool start/update/end events are emitted on the `tool` stream.
|
||||
- Tool results are sanitized for size and image payloads before logging/emitting.
|
||||
- Messaging tool sends are tracked to suppress duplicate assistant confirmations.
|
||||
|
||||
## Reply shaping + suppression
|
||||
- Final payloads are assembled from:
|
||||
- assistant text (and optional reasoning)
|
||||
- inline tool summaries (when verbose + allowed)
|
||||
- assistant error text when the model errors
|
||||
- `NO_REPLY` is treated as a silent token and filtered from outgoing payloads.
|
||||
- Messaging tool duplicates are removed from the final payload list.
|
||||
- If no renderable payloads remain and a tool errored, a fallback tool error reply is emitted
|
||||
(unless a messaging tool already sent a user-visible reply).
|
||||
|
||||
## Compaction + retries
|
||||
- Auto-compaction emits `compaction` stream events and can trigger a retry.
|
||||
- On retry, in-memory buffers and tool summaries are reset to avoid duplicate output.
|
||||
- See [Compaction](/concepts/compaction) for the compaction pipeline.
|
||||
|
||||
## Event streams (today)
|
||||
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
|
||||
- `assistant`: streamed deltas from pi-agent-core
|
||||
|
||||
@@ -86,10 +86,6 @@ These are the standard files Clawdbot expects inside the workspace:
|
||||
- Optional tiny checklist for heartbeat runs.
|
||||
- Keep it short to avoid token burn.
|
||||
|
||||
- `BOOT.md`
|
||||
- Optional startup checklist executed on gateway restart when internal hooks are enabled.
|
||||
- Keep it short; use the message tool for outbound sends.
|
||||
|
||||
- `BOOTSTRAP.md`
|
||||
- One-time first-run ritual.
|
||||
- Only created for a brand-new workspace.
|
||||
|
||||
+13
-46
@@ -79,46 +79,18 @@ semantic queries can find related notes even when wording differs.
|
||||
Defaults:
|
||||
- Enabled by default.
|
||||
- Watches memory files for changes (debounced).
|
||||
- Uses remote embeddings by default. If `memorySearch.provider` is not set, Clawdbot auto-selects:
|
||||
1. `local` if a `memorySearch.local.modelPath` is configured and the file exists.
|
||||
2. `openai` if an OpenAI key can be resolved.
|
||||
3. `gemini` if a Gemini key can be resolved.
|
||||
4. Otherwise memory search stays disabled until configured.
|
||||
- Uses remote embeddings (OpenAI) unless configured for local.
|
||||
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
||||
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
|
||||
|
||||
Remote embeddings **require** an API key for the embedding provider. Clawdbot
|
||||
resolves keys from auth profiles, `models.providers.*.apiKey`, or environment
|
||||
variables. Codex OAuth only covers chat/completions and does **not** satisfy
|
||||
embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or
|
||||
`models.providers.google.apiKey`. When using a custom OpenAI-compatible endpoint,
|
||||
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
||||
Remote embeddings **require** an API key for the embedding provider. By default
|
||||
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
|
||||
OAuth only covers chat/completions and does **not** satisfy embeddings for
|
||||
memory search. When using a custom OpenAI-compatible endpoint, set
|
||||
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
||||
|
||||
### Gemini embeddings (native)
|
||||
|
||||
Set the provider to `gemini` to use the Gemini embeddings API directly:
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
provider: "gemini",
|
||||
model: "gemini-embedding-001",
|
||||
remote: {
|
||||
apiKey: "YOUR_GEMINI_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `remote.baseUrl` is optional (defaults to the Gemini API base URL).
|
||||
- `remote.headers` lets you add extra headers if needed.
|
||||
- Default model: `gemini-embedding-001`.
|
||||
|
||||
If you want to use a **custom OpenAI-compatible endpoint** (OpenRouter, vLLM, or a proxy),
|
||||
you can use the `remote` configuration with the OpenAI provider:
|
||||
If you want to use a **custom OpenAI-compatible endpoint** (like Gemini, OpenRouter, or a proxy),
|
||||
you can use the `remote` configuration:
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
@@ -127,8 +99,8 @@ agents: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
remote: {
|
||||
baseUrl: "https://api.example.com/v1/",
|
||||
apiKey: "YOUR_OPENAI_COMPAT_API_KEY",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
apiKey: "YOUR_GEMINI_API_KEY",
|
||||
headers: { "X-Custom-Header": "value" }
|
||||
}
|
||||
}
|
||||
@@ -139,16 +111,11 @@ agents: {
|
||||
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
||||
`memorySearch.fallback = "none"`.
|
||||
|
||||
Fallbacks:
|
||||
- `memorySearch.fallback` can be `openai`, `gemini`, `local`, or `none`.
|
||||
- The fallback provider is only used when the primary embedding provider fails.
|
||||
|
||||
Batch indexing (OpenAI + Gemini):
|
||||
- Enabled by default for OpenAI and Gemini embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
|
||||
Batch indexing (OpenAI only):
|
||||
- Enabled by default for OpenAI embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
|
||||
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
|
||||
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
|
||||
- Batch mode applies when `memorySearch.provider = "openai"` or `"gemini"` and uses the corresponding API key.
|
||||
- Gemini batch jobs use the async embeddings batch endpoint and require Gemini Batch API availability.
|
||||
- Batch mode currently applies only when `memorySearch.provider = "openai"` and uses your OpenAI API key.
|
||||
|
||||
Why OpenAI batch is fast + cheap:
|
||||
- For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously.
|
||||
|
||||
@@ -59,11 +59,6 @@ It does **not** rotate on every request. The pinned profile is reused until:
|
||||
Manual selection via `/model …@<profileId>` sets a **user override** for that session
|
||||
and is not auto‑rotated until a new session starts.
|
||||
|
||||
Auto‑pinned profiles (selected by the session router) are treated as a **preference**:
|
||||
they are tried first, but Clawdbot may rotate to another profile on rate limits/timeouts.
|
||||
User‑pinned profiles stay locked to that profile; if it fails and model fallbacks
|
||||
are configured, Clawdbot moves to the next model instead of switching profiles.
|
||||
|
||||
### Why OAuth can “look lost”
|
||||
|
||||
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
|
||||
|
||||
@@ -18,7 +18,6 @@ The prompt is intentionally compact and uses fixed sections:
|
||||
- **Skills** (when available): tells the model how to load skill instructions on demand.
|
||||
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
|
||||
- **Workspace**: working directory (`agents.defaults.workspace`).
|
||||
- **Documentation**: local path to Clawdbot docs (repo or npm package) and when to read them.
|
||||
- **Workspace Files (injected)**: indicates bootstrap files are included below.
|
||||
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
|
||||
- **Current Date & Time**: user-local time, timezone, and time format.
|
||||
@@ -99,12 +98,3 @@ Skills section is omitted.
|
||||
```
|
||||
|
||||
This keeps the base prompt small while still enabling targeted skill usage.
|
||||
|
||||
## Documentation
|
||||
|
||||
When available, the system prompt includes a **Documentation** section that points to the
|
||||
local Clawdbot docs directory (either `docs/` in the repo workspace or the bundled npm
|
||||
package docs) and also notes the public mirror, source repo, community Discord, and
|
||||
ClawdHub (https://clawdhub.com) for skills discovery. The prompt instructs the model to consult local docs first
|
||||
for Clawdbot behavior, commands, configuration, or architecture, and to run
|
||||
`clawdbot status` itself when possible (asking the user only when it lacks access).
|
||||
@@ -9,7 +9,7 @@ read_when:
|
||||
|
||||
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
||||
|
||||
## Message envelopes (UTC by default)
|
||||
## Message envelopes (UTC)
|
||||
|
||||
Inbound messages are wrapped in an envelope like:
|
||||
|
||||
@@ -17,46 +17,7 @@ Inbound messages are wrapped in an envelope like:
|
||||
[Provider ... 2026-01-05T21:26Z] message text
|
||||
```
|
||||
|
||||
The timestamp in the envelope is **UTC by default**, with minutes precision.
|
||||
|
||||
You can override this with:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone
|
||||
envelopeTimestamp: "on", // "on" | "off"
|
||||
envelopeElapsed: "on" // "on" | "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
|
||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
|
||||
|
||||
### Examples
|
||||
|
||||
**UTC (default):**
|
||||
|
||||
```
|
||||
[Signal Alice +1555 2026-01-18T05:19Z] hello
|
||||
```
|
||||
|
||||
**Fixed timezone:**
|
||||
|
||||
```
|
||||
[Signal Alice +1555 2026-01-18 06:19 GMT+1] hello
|
||||
```
|
||||
|
||||
**Elapsed time:**
|
||||
|
||||
```
|
||||
[Signal Alice +1555 +2m 2026-01-18T05:19Z] follow-up
|
||||
```
|
||||
The timestamp in the envelope is **always UTC**, with minutes precision.
|
||||
|
||||
## Tool payloads (raw provider data + normalized fields)
|
||||
|
||||
|
||||
+4
-44
@@ -7,10 +7,10 @@ read_when:
|
||||
|
||||
# Date & Time
|
||||
|
||||
Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
||||
Provider timestamps are preserved so tools keep their native semantics.
|
||||
Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
||||
We avoid rewriting provider timestamps so tools keep their native semantics.
|
||||
|
||||
## Message envelopes (UTC by default)
|
||||
## Message envelopes (UTC)
|
||||
|
||||
Inbound messages are wrapped with a UTC timestamp (minute precision):
|
||||
|
||||
@@ -18,47 +18,7 @@ Inbound messages are wrapped with a UTC timestamp (minute precision):
|
||||
[Provider ... 2026-01-05T21:26Z] message text
|
||||
```
|
||||
|
||||
This envelope timestamp is **UTC by default**, regardless of the host timezone.
|
||||
|
||||
You can override this behavior:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone
|
||||
envelopeTimestamp: "on", // "on" | "off"
|
||||
envelopeElapsed: "on" // "on" | "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `envelopeTimezone: "local"` uses the host timezone.
|
||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
|
||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
|
||||
|
||||
### Examples
|
||||
|
||||
**UTC (default):**
|
||||
|
||||
```
|
||||
[WhatsApp +1555 2026-01-18T05:19Z] hello
|
||||
```
|
||||
|
||||
**User timezone:**
|
||||
|
||||
```
|
||||
[WhatsApp +1555 2026-01-18 00:19 CST] hello
|
||||
```
|
||||
|
||||
**Elapsed time enabled:**
|
||||
|
||||
```
|
||||
[WhatsApp +1555 +30s 2026-01-18T05:19Z] follow-up
|
||||
```
|
||||
This envelope timestamp is **always UTC**, regardless of the host timezone.
|
||||
|
||||
## System prompt: Current Date & Time
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
summary: Node + tsx "__name is not a function" crash notes and workarounds
|
||||
read_when:
|
||||
- Debugging Node-only dev scripts or watch mode failures
|
||||
- Investigating tsx/esbuild loader crashes in Clawdbot
|
||||
---
|
||||
|
||||
# Node + tsx "__name is not a function" crash
|
||||
|
||||
## Summary
|
||||
Running Clawdbot via Node with `tsx` fails at startup with:
|
||||
|
||||
```
|
||||
[clawdbot] Failed to start CLI: TypeError: __name is not a function
|
||||
at createSubsystemLogger (.../src/logging/subsystem.ts:203:25)
|
||||
at .../src/agents/auth-profiles/constants.ts:25:20
|
||||
```
|
||||
|
||||
This began after switching dev scripts from Bun to `tsx` (commit `2871657e`, 2026-01-06). The same runtime path worked with Bun.
|
||||
|
||||
## Environment
|
||||
- Node: v25.x (observed on v25.3.0)
|
||||
- tsx: 4.21.0
|
||||
- OS: macOS (repro also likely on other platforms that run Node 25)
|
||||
|
||||
## Repro (Node-only)
|
||||
```bash
|
||||
# in repo root
|
||||
node --version
|
||||
pnpm install
|
||||
node --import tsx src/entry.ts status
|
||||
```
|
||||
|
||||
## Minimal repro in repo
|
||||
```bash
|
||||
node --import tsx scripts/repro/tsx-name-repro.ts
|
||||
```
|
||||
|
||||
## Node version check
|
||||
- Node 25.3.0: fails
|
||||
- Node 22.22.0 (Homebrew `node@22`): fails
|
||||
- Node 24: not installed here yet; needs verification
|
||||
|
||||
## Notes / hypothesis
|
||||
- `tsx` uses esbuild to transform TS/ESM. esbuild’s `keepNames` emits a `__name` helper and wraps function definitions with `__name(...)`.
|
||||
- The crash indicates `__name` exists but is not a function at runtime, which implies the helper is missing or overwritten for this module in the Node 25 loader path.
|
||||
- Similar `__name` helper issues have been reported in other esbuild consumers when the helper is missing or rewritten.
|
||||
|
||||
## Regression history
|
||||
- `2871657e` (2026-01-06): scripts changed from Bun to tsx to make Bun optional.
|
||||
- Before that (Bun path), `pnpm clawdbot status` and `gateway:watch` worked.
|
||||
|
||||
## Workarounds
|
||||
- Use Bun for dev scripts (current temporary revert).
|
||||
- Use Node + tsc watch, then run compiled output:
|
||||
```bash
|
||||
pnpm exec tsc --watch --preserveWatchOutput
|
||||
node --watch dist/entry.js status
|
||||
```
|
||||
- Confirmed locally: `pnpm exec tsc -p tsconfig.json` + `node dist/entry.js status` works on Node 25.
|
||||
- Disable esbuild keepNames in the TS loader if possible (prevents `__name` helper insertion); tsx does not currently expose this.
|
||||
- Test Node LTS (22/24) with `tsx` to see if the issue is Node 25–specific.
|
||||
|
||||
## References
|
||||
- https://opennext.js.org/cloudflare/howtos/keep_names
|
||||
- https://esbuild.github.io/api/#keep-names
|
||||
- https://github.com/evanw/esbuild/issues/1031
|
||||
|
||||
## Next steps
|
||||
- Repro on Node 22/24 to confirm Node 25 regression.
|
||||
- Test `tsx` nightly or pin to earlier version if a known regression exists.
|
||||
- If reproduces on Node LTS, file a minimal repro upstream with the `__name` stack trace.
|
||||
@@ -77,7 +77,6 @@ What this does:
|
||||
- Seeds the workspace files if missing:
|
||||
`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`.
|
||||
- Default identity: **C3‑PO** (protocol droid).
|
||||
- Skips channel providers in dev mode (`CLAWDBOT_SKIP_CHANNELS=1`).
|
||||
|
||||
Reset flow (fresh start):
|
||||
|
||||
|
||||
@@ -657,10 +657,6 @@
|
||||
"source": "/templates/AGENTS",
|
||||
"destination": "/reference/templates/AGENTS"
|
||||
},
|
||||
{
|
||||
"source": "/templates/BOOT",
|
||||
"destination": "/reference/templates/BOOT"
|
||||
},
|
||||
{
|
||||
"source": "/templates/BOOTSTRAP",
|
||||
"destination": "/reference/templates/BOOTSTRAP"
|
||||
@@ -826,10 +822,8 @@
|
||||
"cli/models",
|
||||
"cli/logs",
|
||||
"cli/nodes",
|
||||
"cli/approvals",
|
||||
"cli/gateway",
|
||||
"cli/daemon",
|
||||
"cli/service",
|
||||
"cli/tui",
|
||||
"cli/voicecall",
|
||||
"cli/wake",
|
||||
@@ -1057,7 +1051,6 @@
|
||||
"reference/RELEASING",
|
||||
"reference/AGENTS.default",
|
||||
"reference/templates/AGENTS",
|
||||
"reference/templates/BOOT",
|
||||
"reference/templates/BOOTSTRAP",
|
||||
"reference/templates/HEARTBEAT",
|
||||
"reference/templates/IDENTITY",
|
||||
|
||||
@@ -46,8 +46,8 @@ When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
|
||||
## Frames
|
||||
|
||||
Client → Gateway:
|
||||
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake, skills.bins)
|
||||
- `event`: node signals (voice transcript, agent request, chat subscribe, exec lifecycle)
|
||||
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake)
|
||||
- `event`: node signals (voice transcript, agent request, chat subscribe)
|
||||
|
||||
Gateway → Client:
|
||||
- `invoke` / `invoke-res`: node commands (`canvas.*`, `camera.*`, `screen.record`,
|
||||
@@ -57,18 +57,6 @@ Gateway → Client:
|
||||
|
||||
Exact allowlist is enforced in `src/gateway/server-bridge.ts`.
|
||||
|
||||
## Exec lifecycle events
|
||||
|
||||
Nodes can emit `exec.started`, `exec.finished`, or `exec.denied` events to surface
|
||||
system.run activity. These are mapped to system events in the gateway.
|
||||
|
||||
Payload fields (all optional unless noted):
|
||||
- `sessionKey` (required): agent session to receive the system event.
|
||||
- `runId`: unique exec id for grouping.
|
||||
- `command`: raw or formatted command string.
|
||||
- `exitCode`, `timedOut`, `success`, `output`: completion details (finished only).
|
||||
- `reason`: denial reason (denied only).
|
||||
|
||||
## Tailnet usage
|
||||
|
||||
- Bind the bridge to a tailnet IP: `bridge.bind: "tailnet"` in
|
||||
|
||||
@@ -261,9 +261,10 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
||||
ackMaxChars: 300
|
||||
},
|
||||
memorySearch: {
|
||||
provider: "gemini",
|
||||
model: "gemini-embedding-001",
|
||||
provider: "openai",
|
||||
model: "text-embedding-004",
|
||||
remote: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
apiKey: "${GEMINI_API_KEY}"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -65,8 +65,8 @@ stronger isolation between agents, run them under separate OS users or separate
|
||||
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
|
||||
|
||||
- Requires node pairing (approval + token).
|
||||
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
|
||||
- If you don’t want remote execution, set security to **deny** and remove node pairing for that Mac.
|
||||
- Controlled on the Mac via **Settings → "Node Run Commands"**: "Always Ask" (default), "Always Allow", or "Never".
|
||||
- If you don’t want remote execution, set the policy to "Never" and remove node pairing for that Mac.
|
||||
|
||||
## Dynamic skills (watcher / remote nodes)
|
||||
|
||||
|
||||
+2
-30
@@ -37,11 +37,10 @@ The hooks system allows you to:
|
||||
|
||||
### Bundled Hooks
|
||||
|
||||
Clawdbot ships with four bundled hooks that are automatically discovered:
|
||||
Clawdbot ships with three bundled hooks that are automatically discovered:
|
||||
|
||||
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
|
||||
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
|
||||
- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled)
|
||||
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
|
||||
|
||||
List available hooks:
|
||||
@@ -196,7 +195,7 @@ Each event includes:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: 'command' | 'session' | 'agent' | 'gateway',
|
||||
type: 'command' | 'session' | 'agent',
|
||||
action: string, // e.g., 'new', 'reset', 'stop'
|
||||
sessionKey: string, // Session identifier
|
||||
timestamp: Date, // When the event occurred
|
||||
@@ -229,12 +228,6 @@ Triggered when agent commands are issued:
|
||||
|
||||
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
|
||||
|
||||
### Gateway Events
|
||||
|
||||
Triggered when the gateway starts:
|
||||
|
||||
- **`gateway:startup`**: After channels start and hooks are loaded
|
||||
|
||||
### Future Events
|
||||
|
||||
Planned event types:
|
||||
@@ -549,26 +542,6 @@ clawdbot hooks enable soul-evil
|
||||
}
|
||||
```
|
||||
|
||||
### boot-md
|
||||
|
||||
Runs `BOOT.md` when the gateway starts (after channels start).
|
||||
Internal hooks must be enabled for this to run.
|
||||
|
||||
**Events**: `gateway:startup`
|
||||
|
||||
**Requirements**: `workspace.dir` must be configured
|
||||
|
||||
**What it does**:
|
||||
1. Reads `BOOT.md` from your workspace
|
||||
2. Runs the instructions via the agent runner
|
||||
3. Sends any requested outbound messages via the message tool
|
||||
|
||||
**Enable**:
|
||||
|
||||
```bash
|
||||
clawdbot hooks enable boot-md
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Keep Handlers Fast
|
||||
@@ -641,7 +614,6 @@ The gateway logs hook loading at startup:
|
||||
```
|
||||
Registered hook: session-memory -> command:new
|
||||
Registered hook: command-logger -> command
|
||||
Registered hook: boot-md -> gateway:startup
|
||||
```
|
||||
|
||||
### Check Discovery
|
||||
|
||||
@@ -12,7 +12,6 @@ Clawdbot ships two installer scripts (served from `clawd.bot`):
|
||||
|
||||
- `https://clawd.bot/install.sh` — “recommended” installer (global npm install by default; can also install from a GitHub checkout)
|
||||
- `https://clawd.bot/install-cli.sh` — non-root-friendly CLI installer (installs into a prefix with its own Node)
|
||||
- `https://clawd.bot/install.ps1` — Windows PowerShell installer (npm by default; optional git install)
|
||||
|
||||
To see the current flags/behavior, run:
|
||||
|
||||
@@ -20,12 +19,6 @@ To see the current flags/behavior, run:
|
||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --help
|
||||
```
|
||||
|
||||
Windows (PowerShell) help:
|
||||
|
||||
```powershell
|
||||
& ([scriptblock]::Create((iwr -useb https://clawd.bot/install.ps1))) -?
|
||||
```
|
||||
|
||||
If the installer completes but `clawdbot` is not found in a new terminal, it’s usually a Node/npm PATH issue. See: [Install](/install#nodejs--npm-path-sanity).
|
||||
|
||||
## install.sh (recommended)
|
||||
@@ -80,37 +73,3 @@ Help:
|
||||
```bash
|
||||
curl -fsSL https://clawd.bot/install-cli.sh | bash -s -- --help
|
||||
```
|
||||
|
||||
## install.ps1 (Windows PowerShell)
|
||||
|
||||
What it does (high level):
|
||||
|
||||
- Ensure Node.js **22+** (winget/Chocolatey/Scoop or manual).
|
||||
- Choose install method:
|
||||
- `npm` (default): `npm install -g clawdbot@latest`
|
||||
- `git`: clone/build a source checkout and install a wrapper script
|
||||
- Runs `clawdbot doctor --non-interactive` on upgrades and git installs (best effort).
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
iwr -useb https://clawd.bot/install.ps1 | iex
|
||||
```
|
||||
|
||||
```powershell
|
||||
iwr -useb https://clawd.bot/install.ps1 | iex -InstallMethod git
|
||||
```
|
||||
|
||||
```powershell
|
||||
iwr -useb https://clawd.bot/install.ps1 | iex -InstallMethod git -GitDir "C:\\clawdbot"
|
||||
```
|
||||
|
||||
Environment variables:
|
||||
|
||||
- `CLAWDBOT_INSTALL_METHOD=git|npm`
|
||||
- `CLAWDBOT_GIT_DIR=...`
|
||||
|
||||
Git requirement:
|
||||
|
||||
If you choose `-InstallMethod git` and Git is missing, the installer will print the
|
||||
Git for Windows link (`https://git-scm.com/download/win`) and exit.
|
||||
+3
-50
@@ -147,10 +147,9 @@ Notes:
|
||||
- The permission prompt must be accepted on the Android device before the capability is advertised.
|
||||
- Wi-Fi-only devices without telephony will not advertise `sms.send`.
|
||||
|
||||
## System commands (node host / mac node)
|
||||
## System commands (mac node)
|
||||
|
||||
The macOS node exposes `system.run`, `system.notify`, and `system.execApprovals.get/set`.
|
||||
The headless node host exposes `system.run`, `system.which`, and `system.execApprovals.get/set`.
|
||||
The macOS node exposes `system.run` and `system.notify`.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -164,58 +163,12 @@ Notes:
|
||||
- `system.notify` respects notification permission state on the macOS app.
|
||||
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
|
||||
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
|
||||
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
|
||||
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
|
||||
- On headless node host, `system.run` is gated by exec approvals (`~/.clawdbot/exec-approvals.json`).
|
||||
|
||||
## Exec node binding
|
||||
|
||||
When multiple nodes are available, you can bind exec to a specific node.
|
||||
This sets the default node for `exec host=node` (and can be overridden per agent).
|
||||
|
||||
Global default:
|
||||
|
||||
```bash
|
||||
clawdbot config set tools.exec.node "node-id-or-name"
|
||||
```
|
||||
|
||||
Per-agent override:
|
||||
|
||||
```bash
|
||||
clawdbot config get agents.list
|
||||
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
|
||||
```
|
||||
|
||||
Unset to allow any node:
|
||||
|
||||
```bash
|
||||
clawdbot config unset tools.exec.node
|
||||
clawdbot config unset agents.list[0].tools.exec.node
|
||||
```
|
||||
- `system.run` is gated by the macOS app policy (Settings → "Node Run Commands"): "Always Ask" prompts per command, "Always Allow" runs without prompts, and "Never" disables the tool. Denied prompts return `SYSTEM_RUN_DENIED`; disabled returns `SYSTEM_RUN_DISABLED`.
|
||||
|
||||
## Permissions map
|
||||
|
||||
Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted).
|
||||
|
||||
## Headless node host (cross-platform)
|
||||
|
||||
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
|
||||
bridge and exposes `system.run` / `system.which`. This is useful on Linux/Windows
|
||||
or for running a minimal node alongside a server.
|
||||
|
||||
Start it:
|
||||
|
||||
```bash
|
||||
clawdbot node start --host <gateway-host> --port 18790
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Pairing is still required (the Gateway will show a node approval prompt).
|
||||
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
|
||||
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
|
||||
(see [Exec approvals](/tools/exec-approvals)).
|
||||
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
|
||||
|
||||
## Mac node mode
|
||||
|
||||
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
|
||||
|
||||
@@ -104,29 +104,6 @@ Rules:
|
||||
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
||||
**active reply model** when its provider supports the capability.
|
||||
|
||||
### Auto-enable audio (when keys exist)
|
||||
If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
|
||||
audio provider keys configured, Clawdbot will **auto-enable audio transcription**
|
||||
even when you haven’t listed models explicitly.
|
||||
|
||||
Providers checked (in order):
|
||||
1) OpenAI
|
||||
2) Groq
|
||||
3) Deepgram
|
||||
|
||||
To disable this behavior, set:
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
audio: {
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Capabilities (optional)
|
||||
If you set `capabilities`, the entry only runs for those media types. For shared
|
||||
lists, Clawdbot can infer defaults:
|
||||
|
||||
@@ -9,9 +9,8 @@ read_when:
|
||||
# Gateway on macOS (external launchd)
|
||||
|
||||
Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app
|
||||
expects an **external** `clawdbot` CLI install, does not spawn the Gateway as a
|
||||
child process, and manages a per‑user launchd service to keep the Gateway
|
||||
running (or attaches to an existing local Gateway if one is already running).
|
||||
expects an **external** `clawdbot` CLI install and manages a per‑user launchd
|
||||
service to keep the Gateway running.
|
||||
|
||||
## Install the CLI (required for local mode)
|
||||
|
||||
@@ -39,8 +38,6 @@ Manager:
|
||||
Behavior:
|
||||
- “Clawdbot Active” enables/disables the LaunchAgent.
|
||||
- App quit does **not** stop the gateway (launchd keeps it alive).
|
||||
- If a Gateway is already running on the configured port, the app attaches to
|
||||
it instead of starting a new one.
|
||||
|
||||
Logging:
|
||||
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
|
||||
|
||||
@@ -5,11 +5,9 @@ read_when:
|
||||
---
|
||||
# Gateway lifecycle on macOS
|
||||
|
||||
The macOS app **manages the Gateway via launchd** by default and does not spawn
|
||||
the Gateway as a child process. It first tries to attach to an already‑running
|
||||
Gateway on the configured port; if none is reachable, it enables the launchd
|
||||
service via the external `clawdbot` CLI (no embedded runtime). This gives you
|
||||
reliable auto‑start at login and restart on crashes.
|
||||
The macOS app **manages the Gateway via launchd** by default. The launchd job
|
||||
uses the external `clawdbot` CLI (no embedded runtime). This gives you reliable
|
||||
auto‑start at login and restart on crashes.
|
||||
|
||||
Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
|
||||
If you need tighter coupling to the UI, run the Gateway manually in a terminal.
|
||||
|
||||
@@ -14,7 +14,15 @@ Before building the app, ensure you have the following installed:
|
||||
1. **Xcode 26.2+**: Required for Swift development.
|
||||
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
|
||||
|
||||
## 1. Install Dependencies
|
||||
## 1. Initialize Submodules
|
||||
|
||||
Clawdbot depends on several submodules (like `Peekaboo`). You must initialize these recursively:
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
## 2. Install Dependencies
|
||||
|
||||
Install the project-wide dependencies:
|
||||
|
||||
@@ -22,7 +30,7 @@ Install the project-wide dependencies:
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## 2. Build and Package the App
|
||||
## 3. Build and Package the App
|
||||
|
||||
To build the macOS app and package it into `dist/Clawdbot.app`, run:
|
||||
|
||||
@@ -32,12 +40,9 @@ To build the macOS app and package it into `dist/Clawdbot.app`, run:
|
||||
|
||||
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
|
||||
|
||||
For dev run modes, signing flags, and Team ID troubleshooting, see the macOS app README:
|
||||
https://github.com/clawdbot/clawdbot/blob/main/apps/macos/README.md
|
||||
|
||||
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
||||
|
||||
## 3. Install the CLI
|
||||
## 4. Install the CLI
|
||||
|
||||
The macOS app expects a global `clawdbot` CLI install to manage background tasks.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
summary: "PeekabooBridge integration for macOS UI automation"
|
||||
read_when:
|
||||
- Hosting PeekabooBridge in Clawdbot.app
|
||||
- Integrating Peekaboo via Swift Package Manager
|
||||
- Integrating Peekaboo as a submodule
|
||||
- Changing PeekabooBridge protocol/paths
|
||||
---
|
||||
# Peekaboo Bridge (macOS UI automation)
|
||||
|
||||
@@ -14,7 +14,6 @@ This app is usually built from [`scripts/package-mac-app.sh`](https://github.com
|
||||
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
|
||||
- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build.
|
||||
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
|
||||
- runs a Team ID audit after signing and fails if any Mach-O inside the app bundle is signed by a different Team ID. Set `SKIP_TEAM_ID_CHECK=1` to bypass.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -24,7 +23,6 @@ scripts/package-mac-app.sh # auto-selects identity; errors if none
|
||||
SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert
|
||||
ALLOW_ADHOC_SIGNING=1 scripts/package-mac-app.sh # ad-hoc (permissions will not stick)
|
||||
SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same caveat)
|
||||
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh # dev-only Sparkle Team ID mismatch workaround
|
||||
```
|
||||
|
||||
### Ad-hoc Signing Note
|
||||
|
||||
@@ -7,8 +7,6 @@ read_when:
|
||||
|
||||
**Current model:** there is **no local control socket** and no `clawdbot-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge.
|
||||
|
||||
**Planned model:** add a local Unix socket between the **node service** and the **macOS app**. The app owns `system.run` (UI/TCC context); the node service forwards exec requests over IPC.
|
||||
|
||||
## Goals
|
||||
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
||||
- A small surface for automation: Gateway + node commands, plus PeekabooBridge for UI automation.
|
||||
@@ -19,19 +17,6 @@ read_when:
|
||||
- The app runs the Gateway (local mode) and connects to it as a node.
|
||||
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
||||
|
||||
### Node service + app IPC (planned)
|
||||
- A headless node service connects to the Gateway bridge.
|
||||
- `system.run` requests are forwarded to the macOS app over a local Unix socket.
|
||||
- The app performs the exec in UI context, prompts if needed, and returns output.
|
||||
|
||||
Diagram (SCI):
|
||||
```
|
||||
Agent -> Gateway -> Bridge -> Node Service (TS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + TCC + system.run)
|
||||
```
|
||||
|
||||
### PeekabooBridge (UI automation)
|
||||
- UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol.
|
||||
- Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution.
|
||||
@@ -40,7 +25,6 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
|
||||
|
||||
### Mach/XPC
|
||||
- Not required for automation; `node.invoke` + PeekabooBridge cover current needs.
|
||||
- Planned IPC keeps Unix sockets (no XPC helper).
|
||||
|
||||
## Operational flows
|
||||
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh`
|
||||
@@ -54,4 +38,3 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
|
||||
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
|
||||
- All communication remains local-only; no network sockets are exposed.
|
||||
- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
|
||||
- Planned IPC hardening: socket mode `0600`, token, peer-UID checks, HMAC challenge/response, short TTL.
|
||||
+18
-37
@@ -7,8 +7,8 @@ read_when:
|
||||
# Clawdbot macOS Companion (menu bar + gateway broker)
|
||||
|
||||
The macOS app is the **menu‑bar companion** for Clawdbot. It owns permissions,
|
||||
manages/attaches to the Gateway locally (launchd or manual), and exposes macOS
|
||||
capabilities to the agent as a node.
|
||||
manages the Gateway locally, and exposes macOS capabilities to the agent as a
|
||||
node.
|
||||
|
||||
## What it does
|
||||
|
||||
@@ -20,17 +20,13 @@ capabilities to the agent as a node.
|
||||
- Optionally hosts **PeekabooBridge** for UI automation.
|
||||
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime).
|
||||
|
||||
Planned:
|
||||
- Run a headless **node service** locally (launchd).
|
||||
- Keep `system.run` in the app (UI/TCC context), with the node service forwarding via IPC.
|
||||
|
||||
## Local vs remote mode
|
||||
|
||||
- **Local** (default): the app attaches to a running local Gateway if present;
|
||||
otherwise it enables the launchd service via `clawdbot daemon`.
|
||||
- **Local** (default): the app ensures a local Gateway is running via launchd.
|
||||
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
||||
a local process.
|
||||
The app does not spawn the Gateway as a child process.
|
||||
- **Attach‑only** (debug): the app connects to an already‑running local Gateway
|
||||
and never spawns its own.
|
||||
|
||||
## Launchd control
|
||||
|
||||
@@ -58,44 +54,29 @@ The macOS app presents itself as a node. Common commands:
|
||||
|
||||
The node reports a `permissions` map so agents can decide what’s allowed.
|
||||
|
||||
Planned split:
|
||||
- Node service advertises the node surface to the Gateway.
|
||||
- macOS app performs `system.run` in UI context over IPC.
|
||||
## Node run policy + allowlist
|
||||
|
||||
Diagram (SCI):
|
||||
```
|
||||
Gateway -> Bridge -> Node Service (TS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + TCC + system.run)
|
||||
```
|
||||
`system.run` is controlled by the macOS app **Node Run Commands** policy:
|
||||
|
||||
## Exec approvals (system.run)
|
||||
- `Always Ask`: prompt per command (default).
|
||||
- `Always Allow`: run without prompts.
|
||||
- `Never`: disable `system.run` (tool not advertised).
|
||||
|
||||
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
|
||||
Security + ask + allowlist are stored locally on the Mac in:
|
||||
The policy + allowlist live on the Mac in:
|
||||
|
||||
```
|
||||
~/.clawdbot/exec-approvals.json
|
||||
~/.clawdbot/macos-node.json
|
||||
```
|
||||
|
||||
Example:
|
||||
Schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"defaults": {
|
||||
"security": "deny",
|
||||
"ask": "on-miss"
|
||||
},
|
||||
"agents": {
|
||||
"main": {
|
||||
"security": "allowlist",
|
||||
"ask": "on-miss",
|
||||
"allowlist": [
|
||||
{ "pattern": "/opt/homebrew/bin/rg" }
|
||||
]
|
||||
}
|
||||
"systemRun": {
|
||||
"policy": "ask",
|
||||
"allowlist": [
|
||||
"[\"/bin/echo\",\"hello\"]"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
+1
-2
@@ -37,7 +37,6 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
|
||||
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
||||
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
|
||||
- Memory (LanceDB) — bundled long-term memory plugin (auto-recall/capture; set `plugins.slots.memory = "memory-lancedb"`)
|
||||
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||
@@ -46,7 +45,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
||||
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
|
||||
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
||||
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
|
||||
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
|
||||
|
||||
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
||||
register:
|
||||
|
||||
@@ -6,27 +6,6 @@ read_when:
|
||||
---
|
||||
# Github Copilot
|
||||
|
||||
## What is GitHub Copilot?
|
||||
|
||||
GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot
|
||||
models for your GitHub account and plan. Clawdbot can use Copilot as a model
|
||||
provider in two different ways.
|
||||
|
||||
## Two ways to use Copilot in Clawdbot
|
||||
|
||||
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
||||
|
||||
Use the native device-login flow to obtain a GitHub token, then exchange it for
|
||||
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
|
||||
because it does not require VS Code.
|
||||
|
||||
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
||||
|
||||
Use the **Copilot Proxy** VS Code extension as a local bridge. Clawdbot talks to
|
||||
the proxy’s `/v1` endpoint and uses the model list you configure there. Choose
|
||||
this when you already run Copilot Proxy in VS Code or need to route through it.
|
||||
You must enable the plugin and keep the VS Code extension running.
|
||||
|
||||
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
|
||||
the GitHub device flow, saves an auth profile, and updates your config to use that
|
||||
profile.
|
||||
|
||||
+11
-25
@@ -29,9 +29,6 @@ read_when:
|
||||
- **Runner:** headless system service; UI app hosts a Unix socket for approvals.
|
||||
- **Node identity:** use existing `nodeId`.
|
||||
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
||||
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
|
||||
- **macOS exec host:** run `system.run` inside the macOS app; node service forwards requests over local IPC.
|
||||
- **No XPC helper:** stick to Unix socket + token + peer checks.
|
||||
|
||||
## Key concepts
|
||||
### Host
|
||||
@@ -141,30 +138,19 @@ Notes:
|
||||
|
||||
## UI integration (macOS app)
|
||||
### IPC
|
||||
- Unix socket at `~/.clawdbot/exec-approvals.sock` (0600).
|
||||
- Token stored in `exec-approvals.json` (0600).
|
||||
- Peer checks: same-UID only.
|
||||
- Challenge/response: nonce + HMAC(token, request-hash) to prevent replay.
|
||||
- Short TTL (e.g., 10s) + max payload + rate limit.
|
||||
- Unix socket at `~/.clawdbot/exec-approvals.sock`.
|
||||
- Runner connects and sends an approval request; UI responds with a decision.
|
||||
- Token stored in `exec-approvals.json`.
|
||||
|
||||
### Ask flow (macOS app exec host)
|
||||
1) Node service receives `system.run` from gateway.
|
||||
2) Node service connects to the local socket and sends the prompt/exec request.
|
||||
3) App validates peer + token + HMAC + TTL, then shows dialog if needed.
|
||||
4) App executes the command in UI context and returns output.
|
||||
5) Node service returns output to gateway.
|
||||
### Ask flow
|
||||
1) Runner receives `system.run` from gateway.
|
||||
2) If ask required, runner connects to the socket and sends a prompt request.
|
||||
3) UI shows dialog; returns decision.
|
||||
4) Runner enforces decision and proceeds.
|
||||
|
||||
If UI missing:
|
||||
- Apply `askFallback` (`deny|allowlist|full`).
|
||||
|
||||
### Diagram (SCI)
|
||||
```
|
||||
Agent -> Gateway -> Bridge -> Node Service (TS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + TCC + system.run)
|
||||
```
|
||||
|
||||
## Node identity + binding
|
||||
- Use existing `nodeId` from Bridge pairing.
|
||||
- Binding model:
|
||||
@@ -182,9 +168,9 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
|
||||
- Stored in the gateway in-memory queue (`enqueueSystemEvent`).
|
||||
|
||||
### Event text
|
||||
- `Exec started (node=<id>, id=<runId>)`
|
||||
- `Exec finished (node=<id>, id=<runId>, code=<code>)` + optional output tail
|
||||
- `Exec denied (node=<id>, id=<runId>, <reason>)`
|
||||
- `Exec started (host=node, node=<id>, id=<runId>)`
|
||||
- `Exec finished (exit=<code>, tail=<...>)`
|
||||
- `Exec denied (policy=<...>, reason=<...>)`
|
||||
|
||||
### Transport
|
||||
Option A (recommended):
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
---
|
||||
summary: "Audit what can spend money, which keys are used, and how to view usage"
|
||||
read_when:
|
||||
- You want to understand which features may call paid APIs
|
||||
- You need to audit keys, costs, and usage visibility
|
||||
- You’re explaining /status or /usage cost reporting
|
||||
---
|
||||
# API usage & costs
|
||||
|
||||
This doc lists **features that can invoke API keys** and where their costs show up. It focuses on
|
||||
Clawdbot features that can generate provider usage or paid API calls.
|
||||
|
||||
## Where costs show up (chat + CLI)
|
||||
|
||||
**Per-session cost snapshot**
|
||||
- `/status` shows the current session model, context usage, and last response tokens.
|
||||
- If the model uses **API-key auth**, `/status` also shows **estimated cost** for the last reply.
|
||||
|
||||
**Per-message cost footer**
|
||||
- `/usage full` appends a usage footer to every reply, including **estimated cost** (API-key only).
|
||||
- `/usage tokens` shows tokens only; OAuth flows hide dollar cost.
|
||||
|
||||
**CLI usage windows (provider quotas)**
|
||||
- `clawdbot status --usage` and `clawdbot channels list` show provider **usage windows**
|
||||
(quota snapshots, not per-message costs).
|
||||
|
||||
See [Token use & costs](/token-use) for details and examples.
|
||||
|
||||
## How keys are discovered
|
||||
|
||||
Clawdbot can pick up credentials from:
|
||||
- **Auth profiles** (per-agent, stored in `auth-profiles.json`).
|
||||
- **Environment variables** (e.g. `OPENAI_API_KEY`, `BRAVE_API_KEY`, `FIRECRAWL_API_KEY`).
|
||||
- **Config** (`models.providers.*.apiKey`, `tools.web.search.*`, `tools.web.fetch.firecrawl.*`,
|
||||
`memorySearch.*`, `talk.apiKey`).
|
||||
- **Skills** (`skills.entries.<name>.apiKey`) which may export keys to the skill process env.
|
||||
|
||||
## Features that can spend keys
|
||||
|
||||
### 1) Core model responses (chat + tools)
|
||||
Every reply or tool call uses the **current model provider** (OpenAI, Anthropic, etc). This is the
|
||||
primary source of usage and cost.
|
||||
|
||||
See [Models](/providers/models) for pricing config and [Token use & costs](/token-use) for display.
|
||||
|
||||
### 2) Media understanding (audio/image/video)
|
||||
Inbound media can be summarized/transcribed before the reply runs. This uses model/provider APIs.
|
||||
|
||||
- Audio: OpenAI / Groq / Deepgram (now **auto-enabled** when keys exist).
|
||||
- Image: OpenAI / Anthropic / Google.
|
||||
- Video: Google.
|
||||
|
||||
See [Media understanding](/nodes/media-understanding).
|
||||
|
||||
### 3) Memory embeddings + semantic search
|
||||
Semantic memory search uses **embedding APIs** when configured for remote providers:
|
||||
- `memorySearch.provider = "openai"` → OpenAI embeddings
|
||||
- `memorySearch.provider = "gemini"` → Gemini embeddings
|
||||
- Optional fallback to OpenAI if local embeddings fail
|
||||
|
||||
You can keep it local with `memorySearch.provider = "local"` (no API usage).
|
||||
|
||||
See [Memory](/concepts/memory).
|
||||
|
||||
### 4) Web search tool (Brave / Perplexity via OpenRouter)
|
||||
`web_search` uses API keys and may incur usage charges:
|
||||
|
||||
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||
- **Perplexity** (via OpenRouter): `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||
|
||||
**Brave free tier (generous):**
|
||||
- **2,000 requests/month**
|
||||
- **1 request/second**
|
||||
- **Credit card required** for verification (no charge unless you upgrade)
|
||||
|
||||
See [Web tools](/tools/web).
|
||||
|
||||
### 5) Web fetch tool (Firecrawl)
|
||||
`web_fetch` can call **Firecrawl** when an API key is present:
|
||||
- `FIRECRAWL_API_KEY` or `tools.web.fetch.firecrawl.apiKey`
|
||||
|
||||
If Firecrawl isn’t configured, the tool falls back to direct fetch + readability (no paid API).
|
||||
|
||||
See [Web tools](/tools/web).
|
||||
|
||||
### 6) Provider usage snapshots (status/health)
|
||||
Some status commands call **provider usage endpoints** to display quota windows or auth health.
|
||||
These are typically low-volume calls but still hit provider APIs:
|
||||
- `clawdbot status --usage`
|
||||
- `clawdbot models status --json`
|
||||
|
||||
See [Models CLI](/cli/models).
|
||||
|
||||
### 7) Compaction safeguard summarization
|
||||
The compaction safeguard can summarize session history using the **current model**, which
|
||||
invokes provider APIs when it runs.
|
||||
|
||||
See [Session management + compaction](/reference/session-management-compaction).
|
||||
|
||||
### 8) Model scan / probe
|
||||
`clawdbot models scan` can probe OpenRouter models and uses `OPENROUTER_API_KEY` when
|
||||
probing is enabled.
|
||||
|
||||
See [Models CLI](/cli/models).
|
||||
|
||||
### 9) Talk (speech)
|
||||
Talk mode can invoke **ElevenLabs** when configured:
|
||||
- `ELEVENLABS_API_KEY` or `talk.apiKey`
|
||||
|
||||
See [Talk mode](/nodes/talk).
|
||||
|
||||
### 10) Skills (third-party APIs)
|
||||
Skills can store `apiKey` in `skills.entries.<name>.apiKey`. If a skill uses that key for external
|
||||
APIs, it can incur costs according to the skill’s provider.
|
||||
|
||||
See [Skills](/tools/skills).
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
summary: "Workspace template for BOOT.md"
|
||||
read_when:
|
||||
- Adding a BOOT.md checklist
|
||||
---
|
||||
# BOOT.md
|
||||
|
||||
Add short, explicit instructions for what Clawdbot should do on startup (enable `hooks.internal.enabled`).
|
||||
If the task sends a message, use the message tool and then reply with NO_REPLY.
|
||||
+17
-21
@@ -501,23 +501,14 @@ is writable (read-only sandboxes skip it). See [Memory](/concepts/memory).
|
||||
|
||||
### Does semantic memory search require an OpenAI API key?
|
||||
|
||||
Only if you use **OpenAI embeddings**. Codex OAuth covers chat/completions and
|
||||
does **not** grant embeddings access, so **signing in with Codex (OAuth or the
|
||||
Codex CLI login)** does not help for semantic memory search. OpenAI embeddings
|
||||
still need a real API key (`OPENAI_API_KEY` or `models.providers.openai.apiKey`).
|
||||
|
||||
If you don’t set a provider explicitly, Clawdbot auto-selects a provider when it
|
||||
can resolve an API key (auth profiles, `models.providers.*.apiKey`, or env vars).
|
||||
It prefers OpenAI if an OpenAI key resolves, otherwise Gemini if a Gemini key
|
||||
resolves. If neither key is available, memory search stays disabled until you
|
||||
configure it. If you have a local model path configured and present, Clawdbot
|
||||
prefers `local`.
|
||||
|
||||
If you’d rather stay local, set `memorySearch.provider = "local"` (and optionally
|
||||
`memorySearch.fallback = "none"`). If you want Gemini embeddings, set
|
||||
`memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or
|
||||
`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, or local** embedding
|
||||
models — see [Memory](/concepts/memory) for the setup details.
|
||||
Only if you use **remote embeddings** (OpenAI). Codex OAuth covers
|
||||
chat/completions and does **not** grant embeddings access, so **signing in with
|
||||
Codex (OAuth or the Codex CLI login)** does not help for semantic memory search.
|
||||
Remote memory search still needs a real OpenAI API key (`OPENAI_API_KEY` or
|
||||
`models.providers.openai.apiKey`). If you’d rather stay local, set
|
||||
`memorySearch.provider = "local"` (and optionally `memorySearch.fallback =
|
||||
"none"`). We support **remote or local embedding models** — see [Memory](/concepts/memory)
|
||||
for the setup details.
|
||||
|
||||
## Where things live on disk
|
||||
|
||||
@@ -889,14 +880,19 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce
|
||||
|
||||
### Do sessions reset automatically if I never send `/new`?
|
||||
|
||||
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next**
|
||||
message starts a fresh session id for that chat key. This does not delete
|
||||
transcripts — it just starts a new session.
|
||||
Yes. By default sessions reset daily at **4:00 AM local time** on the gateway host.
|
||||
You can also add an idle window; when both daily and idle resets are configured,
|
||||
whichever expires first starts a new session id on the next message. This does
|
||||
not delete transcripts — it just starts a new session.
|
||||
|
||||
```json5
|
||||
{
|
||||
session: {
|
||||
idleMinutes: 240
|
||||
reset: {
|
||||
mode: "daily",
|
||||
atHour: 4,
|
||||
idleMinutes: 240
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -8,7 +8,7 @@ read_when:
|
||||
|
||||
# Exec approvals
|
||||
|
||||
Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run
|
||||
Exec approvals are the **companion app guardrail** for letting a sandboxed agent run
|
||||
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
|
||||
commands are allowed only when policy + allowlist + (optional) user approval all agree.
|
||||
Exec approvals are **in addition** to tool policy and elevated gating.
|
||||
@@ -20,15 +20,11 @@ resolved by the **ask fallback** (default: deny).
|
||||
|
||||
Exec approvals are enforced locally on the execution host:
|
||||
- **gateway host** → `clawdbot` process on the gateway machine
|
||||
- **node host** → node runner (macOS companion app or headless node host)
|
||||
|
||||
Planned macOS split:
|
||||
- **node service** forwards `system.run` to the **macOS app** over local IPC.
|
||||
- **macOS app** enforces approvals + executes the command in UI context.
|
||||
- **node host** → node runner (macOS companion app or headless node)
|
||||
|
||||
## Settings and storage
|
||||
|
||||
Approvals live in a local JSON file on the execution host:
|
||||
Approvals live in a local JSON file:
|
||||
|
||||
`~/.clawdbot/exec-approvals.json`
|
||||
|
||||
@@ -101,22 +97,8 @@ Each allowlist entry tracks:
|
||||
## Auto-allow skill CLIs
|
||||
|
||||
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
|
||||
are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
|
||||
gateway for the skill bin list. Disable this if you want strict manual allowlists.
|
||||
|
||||
## Control UI editing
|
||||
|
||||
Use the **Control UI → Nodes → Exec approvals** card to edit defaults, per‑agent
|
||||
overrides, and allowlists. Pick a scope (Defaults or an agent), tweak the policy,
|
||||
add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata
|
||||
per pattern so you can keep the list tidy.
|
||||
|
||||
The target selector chooses **Gateway** (local approvals) or a **Node**. Nodes
|
||||
must advertise `system.execApprovals.get/set` (macOS app or headless node host).
|
||||
If a node does not advertise exec approvals yet, edit its local
|
||||
`~/.clawdbot/exec-approvals.json` directly.
|
||||
|
||||
CLI: `clawdbot approvals` supports gateway or node editing (see [Approvals CLI](/cli/approvals)).
|
||||
are treated as allowlisted (node hosts only). Disable this if you want strict
|
||||
manual allowlists.
|
||||
|
||||
## Approval flow
|
||||
|
||||
@@ -132,19 +114,6 @@ Actions:
|
||||
- **Always allow** → add to allowlist + run
|
||||
- **Deny** → block
|
||||
|
||||
### macOS IPC flow (planned)
|
||||
```
|
||||
Gateway -> Bridge -> Node Service (TS)
|
||||
| IPC (UDS + token + HMAC + TTL)
|
||||
v
|
||||
Mac App (UI + approvals + system.run)
|
||||
```
|
||||
|
||||
Security notes:
|
||||
- Unix socket mode `0600`, token stored in `exec-approvals.json`.
|
||||
- Same-UID peer check.
|
||||
- Challenge/response (nonce + HMAC token + request hash) + short TTL.
|
||||
|
||||
## System events
|
||||
|
||||
Exec lifecycle is surfaced as system messages:
|
||||
|
||||
+2
-11
@@ -30,7 +30,7 @@ Notes:
|
||||
- `host` defaults to `sandbox`.
|
||||
- `elevated` is ignored when sandboxing is off (exec already runs on the host).
|
||||
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
|
||||
- `node` requires a paired node (companion app or headless node host).
|
||||
- `node` requires a paired node (macOS companion app).
|
||||
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
|
||||
|
||||
## Config
|
||||
@@ -41,15 +41,6 @@ Notes:
|
||||
- `tools.exec.ask` (default: `on-miss`)
|
||||
- `tools.exec.node` (default: unset)
|
||||
|
||||
Per-agent node binding (use the agent list index in config):
|
||||
|
||||
```bash
|
||||
clawdbot config get agents.list
|
||||
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
|
||||
```
|
||||
|
||||
Control UI: the Nodes tab includes a small “Exec node binding” panel for the same settings.
|
||||
|
||||
## Session overrides (`/exec`)
|
||||
|
||||
Use `/exec` to set **per-session** defaults for `host`, `security`, `ask`, and `node`.
|
||||
@@ -60,7 +51,7 @@ Example:
|
||||
/exec host=gateway security=allowlist ask=on-miss node=mac-1
|
||||
```
|
||||
|
||||
## Exec approvals (companion app / node host)
|
||||
## Exec approvals (macOS app)
|
||||
|
||||
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
|
||||
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
||||
|
||||
@@ -181,7 +181,6 @@ Notes:
|
||||
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
|
||||
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
|
||||
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
|
||||
- `host=node` can target a macOS companion app or a headless node host (`clawdbot node start`).
|
||||
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
|
||||
|
||||
### `process`
|
||||
|
||||
@@ -187,7 +187,7 @@ Skills can also refresh mid-session when the skills watcher is enabled or when a
|
||||
|
||||
## Remote macOS nodes (Linux gateway)
|
||||
|
||||
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Exec approvals security not set to `deny`), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`).
|
||||
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Node Run Commands policy not set to "Never"), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`).
|
||||
|
||||
This relies on the node reporting its command support and on a bin probe via `system.run`. If the macOS node goes offline later, the skills remain visible; invocations may fail until the node reconnects.
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
||||
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
||||
- Skills: status, enable/disable, install, API key updates (`skills.*`)
|
||||
- Nodes: list + caps (`node.list`)
|
||||
- Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
|
||||
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
|
||||
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
||||
- Config writes include a base-hash guard to prevent clobbering concurrent edits
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { enqueueSystemEvent, formatAgentEnvelope, type ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
@@ -836,20 +836,10 @@ async function processMessage(
|
||||
const fromLabel = message.isGroup
|
||||
? `group:${peerId}`
|
||||
: message.senderName || `user:${message.senderId}`;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
const body = formatAgentEnvelope({
|
||||
channel: "BlueBubbles",
|
||||
from: fromLabel,
|
||||
timestamp: message.timestamp,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
let chatGuidForActions = chatGuid;
|
||||
@@ -1068,7 +1058,7 @@ async function processReaction(
|
||||
const senderLabel = reaction.senderName || reaction.senderId;
|
||||
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
||||
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${reaction.messageId}`;
|
||||
core.system.enqueueSystemEvent(text, {
|
||||
enqueueSystemEvent(text, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
||||
});
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { discordPlugin } from "./src/channel.js";
|
||||
import { setDiscordRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
description: "Discord channel plugin",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setDiscordRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: discordPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "@clawdbot/discord",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Discord channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setDiscordRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getDiscordRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Discord runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { imessagePlugin } from "./src/channel.js";
|
||||
import { setIMessageRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "imessage",
|
||||
name: "iMessage",
|
||||
description: "iMessage channel plugin",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setIMessageRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: imessagePlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "@clawdbot/imessage",
|
||||
"version": "2026.1.17-1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot iMessage channel plugin",
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setIMessageRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getIMessageRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("iMessage runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
@@ -1,21 +1,10 @@
|
||||
import os from "node:os";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
import { matrixPlugin } from "./channel.js";
|
||||
import { setMatrixRuntime } from "./runtime.js";
|
||||
|
||||
describe("matrix directory", () => {
|
||||
beforeEach(() => {
|
||||
setMatrixRuntime({
|
||||
state: {
|
||||
resolveStateDir: () => os.tmpdir(),
|
||||
},
|
||||
} as PluginRuntime);
|
||||
});
|
||||
|
||||
it("lists peers and groups from config", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
RoomTopicEventContent,
|
||||
} from "matrix-js-sdk/lib/@types/state_events.js";
|
||||
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import { loadConfig } from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getActiveMatrixClient } from "./active-client.js";
|
||||
import {
|
||||
@@ -74,14 +74,12 @@ async function resolveActionClient(opts: MatrixActionClientOpts = {}): Promise<M
|
||||
const shouldShareClient = Boolean(process.env.CLAWDBOT_GATEWAY_PORT);
|
||||
if (shouldShareClient) {
|
||||
const client = await resolveSharedMatrixClient({
|
||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
cfg: loadConfig() as CoreConfig,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
return { client, stopOnDone: false };
|
||||
}
|
||||
const auth = await resolveMatrixAuth({
|
||||
cfg: getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
});
|
||||
const auth = await resolveMatrixAuth({ cfg: loadConfig() as CoreConfig });
|
||||
const client = await createMatrixClient({
|
||||
homeserver: auth.homeserver,
|
||||
userId: auth.userId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk";
|
||||
|
||||
import { loadConfig } from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
|
||||
export type MatrixResolvedConfig = {
|
||||
homeserver: string;
|
||||
@@ -46,7 +46,7 @@ function clean(value?: string): string {
|
||||
}
|
||||
|
||||
export function resolveMatrixConfig(
|
||||
cfg: CoreConfig = getMatrixRuntime().config.loadConfig() as CoreConfig,
|
||||
cfg: CoreConfig = loadConfig() as CoreConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): MatrixResolvedConfig {
|
||||
const matrix = cfg.channels?.matrix ?? {};
|
||||
@@ -75,7 +75,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
cfg?: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<MatrixAuth> {
|
||||
const cfg = params?.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
|
||||
const cfg = params?.cfg ?? (loadConfig() as CoreConfig);
|
||||
const env = params?.env ?? process.env;
|
||||
const resolved = resolveMatrixConfig(cfg, env);
|
||||
if (!resolved.homeserver) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import { resolveStateDir } from "clawdbot/plugin-sdk";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
@@ -16,11 +16,9 @@ const CREDENTIALS_FILENAME = "credentials.json";
|
||||
|
||||
export function resolveMatrixCredentialsDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
stateDir?: string,
|
||||
stateDir: string = resolveStateDir(env, os.homedir),
|
||||
): string {
|
||||
const resolvedStateDir =
|
||||
stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
return path.join(resolvedStateDir, "credentials", "matrix");
|
||||
return path.join(stateDir, "credentials", "matrix");
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsPath(env: NodeJS.ProcessEnv = process.env): string {
|
||||
|
||||
@@ -3,8 +3,7 @@ import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import { runCommandWithTimeout, type RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
|
||||
const MATRIX_SDK_PACKAGE = "matrix-js-sdk";
|
||||
|
||||
@@ -41,7 +40,7 @@ export async function ensureMatrixSdkInstalled(params: {
|
||||
? ["pnpm", "install"]
|
||||
: ["npm", "install", "--omit=dev", "--silent"];
|
||||
params.runtime.log?.(`matrix: installing dependencies via ${command[0]} (${root})…`);
|
||||
const result = await getMatrixRuntime().system.runCommandWithTimeout(command, {
|
||||
const result = await runCommandWithTimeout(command, {
|
||||
cwd: root,
|
||||
timeoutMs: 300_000,
|
||||
env: { COREPACK_ENABLE_DOWNLOAD_PROMPT: "0" },
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { MatrixClient, MatrixEvent, RoomMember } from "matrix-js-sdk";
|
||||
import { RoomMemberEvent } from "matrix-js-sdk";
|
||||
|
||||
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import { danger, logVerbose, type RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import type { CoreConfig } from "../../types.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
export function registerMatrixAutoJoin(params: {
|
||||
client: MatrixClient;
|
||||
@@ -11,11 +10,6 @@ export function registerMatrixAutoJoin(params: {
|
||||
runtime: RuntimeEnv;
|
||||
}) {
|
||||
const { client, cfg, runtime } = params;
|
||||
const core = getMatrixRuntime();
|
||||
const logVerbose = (message: string) => {
|
||||
if (!core.logging.shouldLogVerbose()) return;
|
||||
runtime.log?.(message);
|
||||
};
|
||||
const autoJoin = cfg.channels?.matrix?.autoJoin ?? "always";
|
||||
const autoJoinAllowlist = cfg.channels?.matrix?.autoJoinAllowlist ?? [];
|
||||
|
||||
@@ -42,7 +36,7 @@ export function registerMatrixAutoJoin(params: {
|
||||
await client.joinRoom(roomId);
|
||||
logVerbose(`matrix: joined room ${roomId}`);
|
||||
} catch (err) {
|
||||
runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`);
|
||||
runtime.error?.(danger(`matrix: failed to join room ${roomId}: ${String(err)}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3,9 +3,34 @@ import { EventType, RelationType, RoomEvent } from "matrix-js-sdk";
|
||||
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
||||
|
||||
import {
|
||||
buildMentionRegexes,
|
||||
chunkMarkdownText,
|
||||
createReplyDispatcherWithTyping,
|
||||
danger,
|
||||
dispatchReplyFromConfig,
|
||||
enqueueSystemEvent,
|
||||
finalizeInboundContext,
|
||||
formatAgentEnvelope,
|
||||
formatAllowlistMatchMeta,
|
||||
getChildLogger,
|
||||
hasControlCommand,
|
||||
loadConfig,
|
||||
logVerbose,
|
||||
mergeAllowlist,
|
||||
matchesMentionPatterns,
|
||||
readChannelAllowFromStore,
|
||||
recordSessionMetaFromInbound,
|
||||
resolveAgentRoute,
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
resolveEffectiveMessagesConfig,
|
||||
resolveHumanDelayConfig,
|
||||
resolveStorePath,
|
||||
resolveTextChunkLimit,
|
||||
shouldHandleTextCommands,
|
||||
shouldLogVerbose,
|
||||
summarizeMapping,
|
||||
updateLastRoute,
|
||||
upsertChannelPairingRequest,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
@@ -36,7 +61,6 @@ import { deliverMatrixReplies } from "./replies.js";
|
||||
import { resolveMatrixRoomConfig } from "./rooms.js";
|
||||
import { resolveMatrixThreadRootId, resolveMatrixThreadTarget } from "./threads.js";
|
||||
import { resolveMatrixTargets } from "../../resolve-targets.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
export type MonitorMatrixOpts = {
|
||||
runtime?: RuntimeEnv;
|
||||
@@ -52,8 +76,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
if (isBunRuntime()) {
|
||||
throw new Error("Matrix provider requires Node (bun runtime not supported)");
|
||||
}
|
||||
const core = getMatrixRuntime();
|
||||
let cfg = core.config.loadConfig() as CoreConfig;
|
||||
let cfg = loadConfig() as CoreConfig;
|
||||
if (cfg.channels?.matrix?.enabled === false) return;
|
||||
|
||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||
@@ -184,13 +207,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
setActiveMatrixClient(client);
|
||||
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg);
|
||||
const logger = core.logging.getChildLogger({ module: "matrix-auto-reply" });
|
||||
const logVerboseMessage = (message: string) => {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
logger.debug(message);
|
||||
}
|
||||
};
|
||||
const mentionRegexes = buildMentionRegexes(cfg);
|
||||
const logger = getChildLogger({ module: "matrix-auto-reply" });
|
||||
const allowlistOnly = cfg.channels?.matrix?.allowlistOnly === true;
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicyRaw = cfg.channels?.matrix?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
@@ -201,7 +219,8 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
const dmEnabled = dmConfig?.enabled ?? true;
|
||||
const dmPolicyRaw = dmConfig?.policy ?? "pairing";
|
||||
const dmPolicy = allowlistOnly && dmPolicyRaw !== "disabled" ? "allowlist" : dmPolicyRaw;
|
||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||
const allowFrom = dmConfig?.allowFrom ?? [];
|
||||
const textLimit = resolveTextChunkLimit(cfg, "matrix");
|
||||
const mediaMaxMb = opts.mediaMaxMb ?? cfg.channels?.matrix?.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
||||
const mediaMaxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
|
||||
const startupMs = Date.now();
|
||||
@@ -287,22 +306,22 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
}`;
|
||||
|
||||
if (roomConfigInfo.config && !roomConfigInfo.allowed) {
|
||||
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
||||
logVerbose(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (!roomConfigInfo.allowlistConfigured) {
|
||||
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
||||
logVerbose(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
|
||||
return;
|
||||
}
|
||||
if (!roomConfigInfo.config) {
|
||||
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
||||
logVerbose(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const senderName = room.getMember(senderId)?.name ?? senderId;
|
||||
const storeAllowFrom = await core.channel.pairing.readAllowFromStore("matrix").catch(() => []);
|
||||
const storeAllowFrom = await readChannelAllowFromStore("matrix").catch(() => []);
|
||||
const effectiveAllowFrom = normalizeAllowListLower([...allowFrom, ...storeAllowFrom]);
|
||||
|
||||
if (isDirectMessage) {
|
||||
@@ -316,13 +335,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
const allowMatchMeta = formatAllowlistMatchMeta(allowMatch);
|
||||
if (!allowMatch.allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
const { code, created } = await upsertChannelPairingRequest({
|
||||
channel: "matrix",
|
||||
id: senderId,
|
||||
meta: { name: senderName },
|
||||
});
|
||||
if (created) {
|
||||
logVerboseMessage(
|
||||
logVerbose(
|
||||
`matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (${allowMatchMeta})`,
|
||||
);
|
||||
try {
|
||||
@@ -339,12 +358,12 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
{ client },
|
||||
);
|
||||
} catch (err) {
|
||||
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
logVerbose(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dmPolicy !== "pairing") {
|
||||
logVerboseMessage(
|
||||
logVerbose(
|
||||
`matrix: blocked dm sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
|
||||
);
|
||||
}
|
||||
@@ -360,7 +379,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
userName: senderName,
|
||||
});
|
||||
if (!userMatch.allowed) {
|
||||
logVerboseMessage(
|
||||
logVerbose(
|
||||
`matrix: blocked sender ${senderId} (room users allowlist, ${roomMatchMeta}, ${formatAllowlistMatchMeta(
|
||||
userMatch,
|
||||
)})`,
|
||||
@@ -369,7 +388,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
}
|
||||
}
|
||||
if (isRoom) {
|
||||
logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
||||
logVerbose(`matrix: allow room ${roomId} (${roomMatchMeta})`);
|
||||
}
|
||||
|
||||
const rawBody = content.body.trim();
|
||||
@@ -397,7 +416,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
maxBytes: mediaMaxBytes,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerboseMessage(`matrix: media download failed: ${String(err)}`);
|
||||
logVerbose(`matrix: media download failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,7 +429,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
text: bodyText,
|
||||
mentionRegexes,
|
||||
});
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
const allowTextCommands = shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "matrix",
|
||||
});
|
||||
@@ -420,19 +439,14 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
userId: senderId,
|
||||
userName: senderName,
|
||||
});
|
||||
const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
],
|
||||
});
|
||||
if (
|
||||
isRoom &&
|
||||
allowTextCommands &&
|
||||
core.channel.text.hasControlCommand(bodyText, cfg) &&
|
||||
!commandAuthorized
|
||||
) {
|
||||
logVerboseMessage(`matrix: drop control command from unauthorized sender ${senderId}`);
|
||||
if (isRoom && allowTextCommands && hasControlCommand(bodyText, cfg) && !commandAuthorized) {
|
||||
logVerbose(`matrix: drop control command from unauthorized sender ${senderId}`);
|
||||
return;
|
||||
}
|
||||
const shouldRequireMention = isRoom
|
||||
@@ -451,7 +465,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
!wasMentioned &&
|
||||
!hasExplicitMention &&
|
||||
commandAuthorized &&
|
||||
core.channel.text.hasControlCommand(bodyText);
|
||||
hasControlCommand(bodyText);
|
||||
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
|
||||
logger.info({ roomId, reason: "no-mention" }, "skipping room message");
|
||||
return;
|
||||
@@ -466,35 +480,26 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
isThreadRoot: event.isThreadRoot,
|
||||
});
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : "channel",
|
||||
id: isDirectMessage ? senderId : roomId,
|
||||
},
|
||||
});
|
||||
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
const body = formatAgentEnvelope({
|
||||
channel: "Matrix",
|
||||
from: envelopeFrom,
|
||||
timestamp: event.getTs() ?? undefined,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: textWithId,
|
||||
});
|
||||
|
||||
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "matrix",
|
||||
peer: {
|
||||
kind: isDirectMessage ? "dm" : "channel",
|
||||
id: isDirectMessage ? senderId : roomId,
|
||||
},
|
||||
});
|
||||
|
||||
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
|
||||
const ctxPayload = finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: bodyText,
|
||||
CommandBody: bodyText,
|
||||
@@ -526,10 +531,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
OriginatingTo: `room:${roomId}`,
|
||||
});
|
||||
|
||||
void core.channel.session.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
const storePath = resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
void recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
}).catch((err) => {
|
||||
logger.warn(
|
||||
{ error: String(err), storePath, sessionKey: ctxPayload.SessionKey ?? route.sessionKey },
|
||||
@@ -538,7 +546,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
|
||||
if (isDirectMessage) {
|
||||
await core.channel.session.updateLastRoute({
|
||||
await updateLastRoute({
|
||||
storePath,
|
||||
sessionKey: route.mainSessionKey,
|
||||
channel: "matrix",
|
||||
@@ -548,8 +556,10 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
}
|
||||
|
||||
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||
logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
||||
if (shouldLogVerbose()) {
|
||||
const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
|
||||
logVerbose(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
|
||||
}
|
||||
|
||||
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
|
||||
const ackScope = cfg.messages?.ackReactionScope ?? "group-mentions";
|
||||
@@ -567,20 +577,20 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
};
|
||||
if (shouldAckReaction() && messageId) {
|
||||
reactMatrixMessage(roomId, messageId, ackReaction, client).catch((err) => {
|
||||
logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
|
||||
logVerbose(`matrix react failed for room ${roomId}: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
const replyTarget = ctxPayload.To;
|
||||
if (!replyTarget) {
|
||||
runtime.error?.("matrix: missing reply target");
|
||||
runtime.error?.(danger("matrix: missing reply target"));
|
||||
return;
|
||||
}
|
||||
|
||||
let didSendReply = false;
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({
|
||||
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
deliver: async (payload) => {
|
||||
await deliverMatrixReplies({
|
||||
replies: [payload],
|
||||
@@ -594,13 +604,13 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
didSendReply = true;
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
|
||||
runtime.error?.(danger(`matrix ${info.kind} reply failed: ${String(err)}`));
|
||||
},
|
||||
onReplyStart: () => sendTypingMatrix(roomId, true, undefined, client).catch(() => {}),
|
||||
onIdle: () => sendTypingMatrix(roomId, false, undefined, client).catch(() => {}),
|
||||
});
|
||||
|
||||
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
||||
const { queuedFinal, counts } = await dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
@@ -612,19 +622,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
markDispatchIdle();
|
||||
if (!queuedFinal) return;
|
||||
didSendReply = true;
|
||||
const finalCount = counts.final;
|
||||
logVerboseMessage(
|
||||
`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
|
||||
);
|
||||
if (shouldLogVerbose()) {
|
||||
const finalCount = counts.final;
|
||||
logVerbose(`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`);
|
||||
}
|
||||
if (didSendReply) {
|
||||
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
|
||||
core.system.enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, {
|
||||
enqueueSystemEvent(`Matrix message from ${senderName}: ${preview}`, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `matrix:message:${roomId}:${messageId || "unknown"}`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(`matrix handler failed: ${String(err)}`);
|
||||
runtime.error?.(danger(`matrix handler failed: ${String(err)}`));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { saveMediaBuffer } from "clawdbot/plugin-sdk";
|
||||
|
||||
async function fetchMatrixMediaBuffer(params: {
|
||||
client: MatrixClient;
|
||||
@@ -49,12 +49,7 @@ export async function downloadMatrixMedia(params: {
|
||||
});
|
||||
if (!fetched) return null;
|
||||
const headerType = fetched.headerType ?? params.contentType ?? undefined;
|
||||
const saved = await getMatrixRuntime().channel.media.saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
headerType,
|
||||
"inbound",
|
||||
params.maxBytes,
|
||||
);
|
||||
const saved = await saveMediaBuffer(fetched.buffer, headerType, "inbound", params.maxBytes);
|
||||
return {
|
||||
path: saved.path,
|
||||
contentType: saved.contentType,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RoomMessageEventContent } from "matrix-js-sdk/lib/@types/events.js";
|
||||
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import { matchesMentionPatterns } from "clawdbot/plugin-sdk";
|
||||
|
||||
export function resolveMentions(params: {
|
||||
content: RoomMessageEventContent;
|
||||
@@ -17,9 +17,6 @@ export function resolveMentions(params: {
|
||||
const wasMentioned =
|
||||
Boolean(mentions?.room) ||
|
||||
(params.userId ? mentionedUsers.has(params.userId) : false) ||
|
||||
getMatrixRuntime().channel.mentions.matchesMentionPatterns(
|
||||
params.text ?? "",
|
||||
params.mentionRegexes,
|
||||
);
|
||||
matchesMentionPatterns(params.text ?? "", params.mentionRegexes);
|
||||
return { wasMentioned, hasExplicitMention: Boolean(mentions) };
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
chunkMarkdownText,
|
||||
danger,
|
||||
logVerbose,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { sendMessageMatrix } from "../send.js";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
|
||||
export async function deliverMatrixReplies(params: {
|
||||
replies: ReplyPayload[];
|
||||
@@ -13,12 +18,6 @@ export async function deliverMatrixReplies(params: {
|
||||
replyToMode: "off" | "first" | "all";
|
||||
threadId?: string;
|
||||
}): Promise<void> {
|
||||
const core = getMatrixRuntime();
|
||||
const logVerbose = (message: string) => {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
params.runtime.log?.(message);
|
||||
}
|
||||
};
|
||||
const chunkLimit = Math.min(params.textLimit, 4000);
|
||||
let hasReplied = false;
|
||||
for (const reply of params.replies) {
|
||||
@@ -28,7 +27,7 @@ export async function deliverMatrixReplies(params: {
|
||||
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
|
||||
continue;
|
||||
}
|
||||
params.runtime.error?.("matrix reply missing text/media");
|
||||
params.runtime.error?.(danger("matrix reply missing text/media"));
|
||||
continue;
|
||||
}
|
||||
const replyToIdRaw = reply.replyToId?.trim();
|
||||
@@ -43,7 +42,7 @@ export async function deliverMatrixReplies(params: {
|
||||
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) {
|
||||
for (const chunk of chunkMarkdownText(reply.text ?? "", chunkLimit)) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) continue;
|
||||
await sendMessageMatrix(params.roomId, trimmed, {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import { setMatrixRuntime } from "../runtime.js";
|
||||
|
||||
vi.mock("matrix-js-sdk", () => ({
|
||||
EventType: {
|
||||
Direct: "m.direct",
|
||||
@@ -21,33 +18,21 @@ vi.mock("matrix-js-sdk", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const loadWebMediaMock = vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("media"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
});
|
||||
const getImageMetadataMock = vi.fn().mockResolvedValue(null);
|
||||
const resizeToJpegMock = vi.fn();
|
||||
|
||||
const runtimeStub = {
|
||||
config: {
|
||||
loadConfig: () => ({}),
|
||||
},
|
||||
media: {
|
||||
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
||||
mediaKindFromMime: () => "image",
|
||||
isVoiceCompatibleAudio: () => false,
|
||||
getImageMetadata: (...args: unknown[]) => getImageMetadataMock(...args),
|
||||
resizeToJpeg: (...args: unknown[]) => resizeToJpegMock(...args),
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
resolveTextChunkLimit: () => 4000,
|
||||
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
vi.mock("clawdbot/plugin-sdk", () => ({
|
||||
loadConfig: () => ({}),
|
||||
resolveTextChunkLimit: () => 4000,
|
||||
chunkMarkdownText: (text: string) => (text ? [text] : []),
|
||||
loadWebMedia: vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("media"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
}),
|
||||
mediaKindFromMime: () => "image",
|
||||
isVoiceCompatibleAudio: () => false,
|
||||
getImageMetadata: vi.fn().mockResolvedValue(null),
|
||||
resizeToJpeg: vi.fn(),
|
||||
}));
|
||||
|
||||
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
|
||||
|
||||
@@ -65,13 +50,11 @@ const makeClient = () => {
|
||||
|
||||
describe("sendMessageMatrix media", () => {
|
||||
beforeAll(async () => {
|
||||
setMatrixRuntime(runtimeStub);
|
||||
({ sendMessageMatrix } = await import("./send.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setMatrixRuntime(runtimeStub);
|
||||
});
|
||||
|
||||
it("uploads media with url payloads", async () => {
|
||||
|
||||
@@ -5,8 +5,17 @@ import type {
|
||||
ReactionEventContent,
|
||||
} from "matrix-js-sdk/lib/@types/events.js";
|
||||
|
||||
import type { PollInput } from "clawdbot/plugin-sdk";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import {
|
||||
chunkMarkdownText,
|
||||
getImageMetadata,
|
||||
isVoiceCompatibleAudio,
|
||||
loadConfig,
|
||||
loadWebMedia,
|
||||
mediaKindFromMime,
|
||||
type PollInput,
|
||||
resolveTextChunkLimit,
|
||||
resizeToJpeg,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { getActiveMatrixClient } from "./active-client.js";
|
||||
import {
|
||||
createMatrixClient,
|
||||
@@ -20,7 +29,6 @@ import { buildPollStartContent, M_POLL_START } from "./poll-types.js";
|
||||
import type { CoreConfig } from "../types.js";
|
||||
|
||||
const MATRIX_TEXT_LIMIT = 4000;
|
||||
const getCore = () => getMatrixRuntime();
|
||||
|
||||
type MatrixDirectAccountData = AccountDataEvents[EventType.Direct];
|
||||
|
||||
@@ -57,7 +65,7 @@ function ensureNodeRuntime() {
|
||||
}
|
||||
|
||||
function resolveMediaMaxBytes(): number | undefined {
|
||||
const cfg = getCore().config.loadConfig() as CoreConfig;
|
||||
const cfg = loadConfig() as CoreConfig;
|
||||
if (typeof cfg.channels?.matrix?.mediaMaxMb === "number") {
|
||||
return cfg.channels.matrix.mediaMaxMb * 1024 * 1024;
|
||||
}
|
||||
@@ -216,7 +224,7 @@ function resolveMatrixMsgType(
|
||||
contentType?: string,
|
||||
fileName?: string,
|
||||
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
|
||||
const kind = getCore().media.mediaKindFromMime(contentType ?? "");
|
||||
const kind = mediaKindFromMime(contentType ?? "");
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return MsgType.Image;
|
||||
@@ -235,7 +243,7 @@ function resolveMatrixVoiceDecision(opts: {
|
||||
fileName?: string;
|
||||
}): { useVoice: boolean } {
|
||||
if (!opts.wantsVoice) return { useVoice: false };
|
||||
if (getCore().media.isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
|
||||
if (isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
|
||||
return { useVoice: true };
|
||||
}
|
||||
return { useVoice: false };
|
||||
@@ -248,19 +256,19 @@ async function prepareImageInfo(params: {
|
||||
buffer: Buffer;
|
||||
client: MatrixClient;
|
||||
}): Promise<MatrixImageInfo | undefined> {
|
||||
const meta = await getCore().media.getImageMetadata(params.buffer).catch(() => null);
|
||||
const meta = await getImageMetadata(params.buffer).catch(() => null);
|
||||
if (!meta) return undefined;
|
||||
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
|
||||
const maxDim = Math.max(meta.width, meta.height);
|
||||
if (maxDim > THUMBNAIL_MAX_SIDE) {
|
||||
try {
|
||||
const thumbBuffer = await getCore().media.resizeToJpeg({
|
||||
const thumbBuffer = await resizeToJpeg({
|
||||
buffer: params.buffer,
|
||||
maxSide: THUMBNAIL_MAX_SIDE,
|
||||
quality: THUMBNAIL_QUALITY,
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
const thumbMeta = await getCore().media.getImageMetadata(thumbBuffer).catch(() => null);
|
||||
const thumbMeta = await getImageMetadata(thumbBuffer).catch(() => null);
|
||||
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
|
||||
type: "image/jpeg",
|
||||
name: "thumbnail.jpg",
|
||||
@@ -344,10 +352,10 @@ export async function sendMessageMatrix(
|
||||
});
|
||||
try {
|
||||
const roomId = await resolveMatrixRoomId(client, to);
|
||||
const cfg = getCore().config.loadConfig();
|
||||
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
|
||||
const cfg = loadConfig();
|
||||
const textLimit = resolveTextChunkLimit(cfg, "matrix");
|
||||
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
|
||||
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
|
||||
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
|
||||
const threadId = normalizeThreadId(opts.threadId);
|
||||
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
|
||||
const sendContent = (content: RoomMessageEventContent) =>
|
||||
@@ -356,7 +364,7 @@ export async function sendMessageMatrix(
|
||||
let lastMessageId = "";
|
||||
if (opts.mediaUrl) {
|
||||
const maxBytes = resolveMediaMaxBytes();
|
||||
const media = await getCore().media.loadWebMedia(opts.mediaUrl, maxBytes);
|
||||
const media = await loadWebMedia(opts.mediaUrl, maxBytes);
|
||||
const contentUri = await uploadFile(client, media.buffer, {
|
||||
contentType: media.contentType,
|
||||
filename: media.fileName,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
createMemoryGetTool,
|
||||
createMemorySearchTool,
|
||||
registerMemoryCli,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
const memoryCorePlugin = {
|
||||
id: "memory-core",
|
||||
name: "Memory (Core)",
|
||||
@@ -8,11 +14,11 @@ const memoryCorePlugin = {
|
||||
register(api: ClawdbotPluginApi) {
|
||||
api.registerTool(
|
||||
(ctx) => {
|
||||
const memorySearchTool = api.runtime.tools.createMemorySearchTool({
|
||||
const memorySearchTool = createMemorySearchTool({
|
||||
config: ctx.config,
|
||||
agentSessionKey: ctx.sessionKey,
|
||||
});
|
||||
const memoryGetTool = api.runtime.tools.createMemoryGetTool({
|
||||
const memoryGetTool = createMemoryGetTool({
|
||||
config: ctx.config,
|
||||
agentSessionKey: ctx.sessionKey,
|
||||
});
|
||||
@@ -24,7 +30,7 @@ const memoryCorePlugin = {
|
||||
|
||||
api.registerCli(
|
||||
({ program }) => {
|
||||
api.runtime.tools.registerMemoryCli(program);
|
||||
registerMemoryCli(program);
|
||||
},
|
||||
{ commands: ["memory"] },
|
||||
);
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
export type MemoryConfig = {
|
||||
embedding: {
|
||||
provider: "openai";
|
||||
model?: string;
|
||||
apiKey: string;
|
||||
};
|
||||
dbPath?: string;
|
||||
autoCapture?: boolean;
|
||||
autoRecall?: boolean;
|
||||
};
|
||||
|
||||
export const MEMORY_CATEGORIES = ["preference", "fact", "decision", "entity", "other"] as const;
|
||||
export type MemoryCategory = (typeof MEMORY_CATEGORIES)[number];
|
||||
|
||||
const DEFAULT_MODEL = "text-embedding-3-small";
|
||||
const DEFAULT_DB_PATH = join(homedir(), ".clawdbot", "memory", "lancedb");
|
||||
|
||||
const EMBEDDING_DIMENSIONS: Record<string, number> = {
|
||||
"text-embedding-3-small": 1536,
|
||||
"text-embedding-3-large": 3072,
|
||||
};
|
||||
|
||||
export function vectorDimsForModel(model: string): number {
|
||||
const dims = EMBEDDING_DIMENSIONS[model];
|
||||
if (!dims) {
|
||||
throw new Error(`Unsupported embedding model: ${model}`);
|
||||
}
|
||||
return dims;
|
||||
}
|
||||
|
||||
function resolveEnvVars(value: string): string {
|
||||
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
|
||||
const envValue = process.env[envVar];
|
||||
if (!envValue) {
|
||||
throw new Error(`Environment variable ${envVar} is not set`);
|
||||
}
|
||||
return envValue;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEmbeddingModel(embedding: Record<string, unknown>): string {
|
||||
const model = typeof embedding.model === "string" ? embedding.model : DEFAULT_MODEL;
|
||||
vectorDimsForModel(model);
|
||||
return model;
|
||||
}
|
||||
|
||||
export const memoryConfigSchema = {
|
||||
parse(value: unknown): MemoryConfig {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("memory config required");
|
||||
}
|
||||
const cfg = value as Record<string, unknown>;
|
||||
|
||||
const embedding = cfg.embedding as Record<string, unknown> | undefined;
|
||||
if (!embedding || typeof embedding.apiKey !== "string") {
|
||||
throw new Error("embedding.apiKey is required");
|
||||
}
|
||||
|
||||
const model = resolveEmbeddingModel(embedding);
|
||||
|
||||
return {
|
||||
embedding: {
|
||||
provider: "openai",
|
||||
model,
|
||||
apiKey: resolveEnvVars(embedding.apiKey),
|
||||
},
|
||||
dbPath: typeof cfg.dbPath === "string" ? cfg.dbPath : DEFAULT_DB_PATH,
|
||||
autoCapture: cfg.autoCapture !== false,
|
||||
autoRecall: cfg.autoRecall !== false,
|
||||
};
|
||||
},
|
||||
uiHints: {
|
||||
"embedding.apiKey": {
|
||||
label: "OpenAI API Key",
|
||||
sensitive: true,
|
||||
placeholder: "sk-proj-...",
|
||||
help: "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})",
|
||||
},
|
||||
"embedding.model": {
|
||||
label: "Embedding Model",
|
||||
placeholder: DEFAULT_MODEL,
|
||||
help: "OpenAI embedding model to use",
|
||||
},
|
||||
dbPath: {
|
||||
label: "Database Path",
|
||||
placeholder: "~/.clawdbot/memory/lancedb",
|
||||
advanced: true,
|
||||
},
|
||||
autoCapture: {
|
||||
label: "Auto-Capture",
|
||||
help: "Automatically capture important information from conversations",
|
||||
},
|
||||
autoRecall: {
|
||||
label: "Auto-Recall",
|
||||
help: "Automatically inject relevant memories into context",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -9,7 +9,6 @@
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
@@ -37,8 +36,8 @@ describeWithKey("memory plugin e2e", () => {
|
||||
// Dynamic import to avoid loading LanceDB when not testing
|
||||
const { default: memoryPlugin } = await import("./index.js");
|
||||
|
||||
expect(memoryPlugin.id).toBe("memory-lancedb");
|
||||
expect(memoryPlugin.name).toBe("Memory (LanceDB)");
|
||||
expect(memoryPlugin.id).toBe("memory");
|
||||
expect(memoryPlugin.name).toBe("Memory (Vector)");
|
||||
expect(memoryPlugin.kind).toBe("memory");
|
||||
expect(memoryPlugin.configSchema).toBeDefined();
|
||||
expect(memoryPlugin.register).toBeInstanceOf(Function);
|
||||
@@ -185,8 +184,8 @@ describeWithKey("memory plugin live tests", () => {
|
||||
const logs: string[] = [];
|
||||
|
||||
const mockApi = {
|
||||
id: "memory-lancedb",
|
||||
name: "Memory (LanceDB)",
|
||||
id: "memory",
|
||||
name: "Memory (Vector)",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig: {
|
||||
|
||||
+131
-31
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Clawdbot Memory (LanceDB) Plugin
|
||||
* Clawdbot Memory Plugin
|
||||
*
|
||||
* Long-term memory with vector search for AI conversations.
|
||||
* Uses LanceDB for storage and OpenAI for embeddings.
|
||||
@@ -10,20 +10,28 @@ import { Type } from "@sinclair/typebox";
|
||||
import * as lancedb from "@lancedb/lancedb";
|
||||
import OpenAI from "openai";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { stringEnum } from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
MEMORY_CATEGORIES,
|
||||
type MemoryCategory,
|
||||
memoryConfigSchema,
|
||||
vectorDimsForModel,
|
||||
} from "./config.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
type MemoryConfig = {
|
||||
embedding: {
|
||||
provider: "openai";
|
||||
model?: string;
|
||||
apiKey: string;
|
||||
};
|
||||
dbPath?: string;
|
||||
autoCapture?: boolean;
|
||||
autoRecall?: boolean;
|
||||
};
|
||||
|
||||
const MEMORY_CATEGORIES = ["preference", "fact", "decision", "entity", "other"] as const;
|
||||
type MemoryCategory = (typeof MEMORY_CATEGORIES)[number];
|
||||
|
||||
type MemoryEntry = {
|
||||
id: string;
|
||||
text: string;
|
||||
@@ -38,12 +46,106 @@ type MemorySearchResult = {
|
||||
score: number;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Config Schema
|
||||
// ============================================================================
|
||||
|
||||
const memoryConfigSchema = {
|
||||
parse(value: unknown): MemoryConfig {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("memory config required");
|
||||
}
|
||||
const cfg = value as Record<string, unknown>;
|
||||
|
||||
// Embedding config is required
|
||||
const embedding = cfg.embedding as Record<string, unknown> | undefined;
|
||||
if (!embedding || typeof embedding.apiKey !== "string") {
|
||||
throw new Error("embedding.apiKey is required");
|
||||
}
|
||||
|
||||
const model =
|
||||
typeof embedding.model === "string" ? embedding.model : "text-embedding-3-small";
|
||||
ensureSupportedEmbeddingModel(model);
|
||||
|
||||
return {
|
||||
embedding: {
|
||||
provider: "openai",
|
||||
model,
|
||||
apiKey: resolveEnvVars(embedding.apiKey),
|
||||
},
|
||||
dbPath:
|
||||
typeof cfg.dbPath === "string"
|
||||
? cfg.dbPath
|
||||
: join(homedir(), ".clawdbot", "memory", "lancedb"),
|
||||
autoCapture: cfg.autoCapture !== false,
|
||||
autoRecall: cfg.autoRecall !== false,
|
||||
};
|
||||
},
|
||||
uiHints: {
|
||||
"embedding.apiKey": {
|
||||
label: "OpenAI API Key",
|
||||
sensitive: true,
|
||||
placeholder: "sk-proj-...",
|
||||
help: "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})",
|
||||
},
|
||||
"embedding.model": {
|
||||
label: "Embedding Model",
|
||||
placeholder: "text-embedding-3-small",
|
||||
help: "OpenAI embedding model to use",
|
||||
},
|
||||
dbPath: {
|
||||
label: "Database Path",
|
||||
placeholder: "~/.clawdbot/memory/lancedb",
|
||||
advanced: true,
|
||||
},
|
||||
autoCapture: {
|
||||
label: "Auto-Capture",
|
||||
help: "Automatically capture important information from conversations",
|
||||
},
|
||||
autoRecall: {
|
||||
label: "Auto-Recall",
|
||||
help: "Automatically inject relevant memories into context",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const EMBEDDING_DIMENSIONS: Record<string, number> = {
|
||||
"text-embedding-3-small": 1536,
|
||||
"text-embedding-3-large": 3072,
|
||||
};
|
||||
|
||||
function ensureSupportedEmbeddingModel(model: string): void {
|
||||
if (!EMBEDDING_DIMENSIONS[model]) {
|
||||
throw new Error(`Unsupported embedding model: ${model}`);
|
||||
}
|
||||
}
|
||||
|
||||
function stringEnum<T extends readonly string[]>(
|
||||
values: T,
|
||||
options: { description?: string } = {},
|
||||
) {
|
||||
return Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: [...values],
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEnvVars(value: string): string {
|
||||
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
|
||||
const envValue = process.env[envVar];
|
||||
if (!envValue) {
|
||||
throw new Error(`Environment variable ${envVar} is not set`);
|
||||
}
|
||||
return envValue;
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LanceDB Provider
|
||||
// ============================================================================
|
||||
|
||||
const TABLE_NAME = "memories";
|
||||
|
||||
class MemoryDB {
|
||||
private db: lancedb.Connection | null = null;
|
||||
private table: lancedb.Table | null = null;
|
||||
@@ -214,22 +316,20 @@ function detectCategory(text: string): MemoryCategory {
|
||||
// ============================================================================
|
||||
|
||||
const memoryPlugin = {
|
||||
id: "memory-lancedb",
|
||||
name: "Memory (LanceDB)",
|
||||
description: "LanceDB-backed long-term memory with auto-recall/capture",
|
||||
id: "memory",
|
||||
name: "Memory (Vector)",
|
||||
description: "Long-term memory with vector search and seamless auto-recall/capture",
|
||||
kind: "memory" as const,
|
||||
configSchema: memoryConfigSchema,
|
||||
|
||||
register(api: ClawdbotPluginApi) {
|
||||
const cfg = memoryConfigSchema.parse(api.pluginConfig);
|
||||
const resolvedDbPath = api.resolvePath(cfg.dbPath!);
|
||||
const vectorDim = vectorDimsForModel(cfg.embedding.model ?? "text-embedding-3-small");
|
||||
const db = new MemoryDB(resolvedDbPath, vectorDim);
|
||||
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!);
|
||||
const resolvedDbPath = api.resolvePath(cfg.dbPath!);
|
||||
const vectorDim = EMBEDDING_DIMENSIONS[cfg.embedding.model ?? "text-embedding-3-small"];
|
||||
const db = new MemoryDB(resolvedDbPath, vectorDim);
|
||||
const embeddings = new Embeddings(cfg.embedding.apiKey, cfg.embedding.model!);
|
||||
|
||||
api.logger.info(
|
||||
`memory-lancedb: plugin registered (db: ${resolvedDbPath}, lazy init)`,
|
||||
);
|
||||
api.logger.info(`memory: plugin registered (db: ${resolvedDbPath}, lazy init)`);
|
||||
|
||||
// ========================================================================
|
||||
// Tools
|
||||
@@ -379,9 +479,9 @@ const memoryPlugin = {
|
||||
};
|
||||
}
|
||||
|
||||
const list = results
|
||||
.map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`)
|
||||
.join("\n");
|
||||
const list = results
|
||||
.map((r) => `- [${r.entry.id.slice(0, 8)}] ${r.entry.text.slice(0, 60)}...`)
|
||||
.join("\n");
|
||||
|
||||
// Strip vector data for serialization
|
||||
const sanitizedCandidates = results.map((r) => ({
|
||||
@@ -419,7 +519,7 @@ const memoryPlugin = {
|
||||
({ program }) => {
|
||||
const memory = program
|
||||
.command("ltm")
|
||||
.description("LanceDB memory plugin commands");
|
||||
.description("Long-term memory plugin commands");
|
||||
|
||||
memory
|
||||
.command("list")
|
||||
@@ -479,14 +579,14 @@ const memoryPlugin = {
|
||||
.join("\n");
|
||||
|
||||
api.logger.info?.(
|
||||
`memory-lancedb: injecting ${results.length} memories into context`,
|
||||
`memory: injecting ${results.length} memories into context`,
|
||||
);
|
||||
|
||||
return {
|
||||
prependContext: `<relevant-memories>\nThe following memories may be relevant to this conversation:\n${memoryContext}\n</relevant-memories>`,
|
||||
};
|
||||
} catch (err) {
|
||||
api.logger.warn(`memory-lancedb: recall failed: ${String(err)}`);
|
||||
api.logger.warn(`memory: recall failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -561,10 +661,10 @@ const memoryPlugin = {
|
||||
}
|
||||
|
||||
if (stored > 0) {
|
||||
api.logger.info(`memory-lancedb: auto-captured ${stored} memories`);
|
||||
api.logger.info(`memory: auto-captured ${stored} memories`);
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn(`memory-lancedb: capture failed: ${String(err)}`);
|
||||
api.logger.warn(`memory: capture failed: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -574,14 +674,14 @@ const memoryPlugin = {
|
||||
// ========================================================================
|
||||
|
||||
api.registerService({
|
||||
id: "memory-lancedb",
|
||||
id: "memory",
|
||||
start: () => {
|
||||
api.logger.info(
|
||||
`memory-lancedb: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
|
||||
`memory: initialized (db: ${resolvedDbPath}, model: ${cfg.embedding.model})`,
|
||||
);
|
||||
},
|
||||
stop: () => {
|
||||
api.logger.info("memory-lancedb: stopped");
|
||||
api.logger.info("memory: stopped");
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
{
|
||||
"name": "@clawdbot/memory-lancedb",
|
||||
"name": "@clawdbot/memory",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||
"description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture",
|
||||
"dependencies": {
|
||||
"@lancedb/lancedb": "^0.15.0",
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"openai": "^4.104.0"
|
||||
"@lancedb/lancedb": "^0.15.0",
|
||||
"openai": "^4.77.0"
|
||||
},
|
||||
"clawdbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { msteamsPlugin } from "./src/channel.js";
|
||||
import { setMSTeamsRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "msteams",
|
||||
name: "Microsoft Teams",
|
||||
description: "Microsoft Teams channel plugin (Bot Framework)",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
setMSTeamsRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: msteamsPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
Loaded 100 of 479 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user