Compare commits
368 Commits
patch-2
...
improve-an
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4429b75813 | ||
|
|
ff7d4b00b3 | ||
|
|
a5d0cb90b1 | ||
|
|
c639b386da | ||
|
|
97531f174f | ||
|
|
ef125d5ba7 | ||
|
|
86950d3474 | ||
|
|
3a9582bc41 | ||
|
|
d198474415 | ||
|
|
ace8a1b44e | ||
|
|
a7be3a9649 | ||
|
|
0d543dd1ff | ||
|
|
404c373153 | ||
|
|
024691e4e7 | ||
|
|
a86d7a2f35 | ||
|
|
e7e34c442e | ||
|
|
9b9e8d4ae8 | ||
|
|
bf925e5758 | ||
|
|
c0c9df4ab7 | ||
|
|
6aa90f8b18 | ||
|
|
9af1c8a886 | ||
|
|
ed5ece4120 | ||
|
|
85d1835476 | ||
|
|
e85d2dff97 | ||
|
|
fac66d4dda | ||
|
|
2e99369113 | ||
|
|
835f9ee575 | ||
|
|
a136c6aa89 | ||
|
|
b621d4550b | ||
|
|
c290217305 | ||
|
|
769b286cf2 | ||
|
|
690bb192e6 | ||
|
|
601a052216 | ||
|
|
bf3021d266 | ||
|
|
145b2e5f52 | ||
|
|
4b73dc95c4 | ||
|
|
e17cb408a5 | ||
|
|
3cf92152c3 | ||
|
|
c50cde2170 | ||
|
|
7e0bebd669 | ||
|
|
7c49326191 | ||
|
|
744d1329cb | ||
|
|
42e6ff4611 | ||
|
|
5f21bf735a | ||
|
|
ee380e9ab9 | ||
|
|
ab340c82fb | ||
|
|
57dd0505a3 | ||
|
|
d6f9f1c79a | ||
|
|
a08a772ffc | ||
|
|
2622b1936b | ||
|
|
c0457e0cc4 | ||
|
|
ee2f0a175a | ||
|
|
0e94f0c018 | ||
|
|
576485b0c9 | ||
|
|
60efe8ed7b | ||
|
|
332a20d9cc | ||
|
|
f16b0cf80d | ||
|
|
a4ee933022 | ||
|
|
cf7437cb4c | ||
|
|
081123c0e4 | ||
|
|
5fe3c36471 | ||
|
|
e06158c645 | ||
|
|
19a8547ecd | ||
|
|
32ae4566c6 | ||
|
|
be6a3d4caf | ||
|
|
1db0384090 | ||
|
|
d024dceef7 | ||
|
|
5ec499e14c | ||
|
|
0b350d78d5 | ||
|
|
96ee027371 | ||
|
|
ffcf3263c1 | ||
|
|
cef6b16d14 | ||
|
|
d06d440086 | ||
|
|
415fc9092e | ||
|
|
0be9d773cb | ||
|
|
ecb45660e9 | ||
|
|
f6fefd7f5f | ||
|
|
4206b9684b | ||
|
|
a4aad1c76a | ||
|
|
9464774133 | ||
|
|
be7191879a | ||
|
|
7252938339 | ||
|
|
810394f43b | ||
|
|
835162fb62 | ||
|
|
82883095fe | ||
|
|
49d8ad3049 | ||
|
|
1721d04405 | ||
|
|
633e0d9382 | ||
|
|
e156320c51 | ||
|
|
f06ce98312 | ||
|
|
b546b2a48d | ||
|
|
c11b016d22 | ||
|
|
3686bde783 | ||
|
|
9c06689569 | ||
|
|
891a2cc64a | ||
|
|
01211937fc | ||
|
|
4726580c7e | ||
|
|
e9a08dc507 | ||
|
|
f3698e360b | ||
|
|
c69947dff8 | ||
|
|
173bce34b0 | ||
|
|
6a27e385b1 | ||
|
|
5f0d9c3eb9 | ||
|
|
0e31c8153c | ||
|
|
9c0773c469 | ||
|
|
f5533baf61 | ||
|
|
60bc436e99 | ||
|
|
741b984a68 | ||
|
|
858a5f48d8 | ||
|
|
20c26eb303 | ||
|
|
f3ef609839 | ||
|
|
234fe5b5cd | ||
|
|
65710932ff | ||
|
|
e944f21ec0 | ||
|
|
ee6e534ccb | ||
|
|
11b07f4a29 | ||
|
|
676d41d415 | ||
|
|
a3a4996adb | ||
|
|
b015c7e5ad | ||
|
|
4de3c3a028 | ||
|
|
b739a3897f | ||
|
|
c5e19f5c67 | ||
|
|
9241e21114 | ||
|
|
65bed815a8 | ||
|
|
d776cfb4e1 | ||
|
|
c6e7e1821b | ||
|
|
f76ab69612 | ||
|
|
889db137b8 | ||
|
|
9db682750d | ||
|
|
9809b47d45 | ||
|
|
68d79e56c2 | ||
|
|
d3862ae30a | ||
|
|
e49a2952d9 | ||
|
|
8b57f519c3 | ||
|
|
ddcc05f5f4 | ||
|
|
8c0e290db1 | ||
|
|
7bfc77db25 | ||
|
|
8d74578ceb | ||
|
|
f7123ec30a | ||
|
|
ad4f4388f4 | ||
|
|
2a86504723 | ||
|
|
de3b68740a | ||
|
|
efaa73f543 | ||
|
|
1589c73697 | ||
|
|
359d2af8a8 | ||
|
|
fa897e5dfe | ||
|
|
7fa8ae56cb | ||
|
|
ec27c813cc | ||
|
|
3b24fe639a | ||
|
|
e5cca6e432 | ||
|
|
ae0b4c4990 | ||
|
|
49bd2d96fa | ||
|
|
ca350fc66c | ||
|
|
30338ce1a7 | ||
|
|
faa94f0168 | ||
|
|
f5c84768ff | ||
|
|
df752d4706 | ||
|
|
c9c9516206 | ||
|
|
d3b15c6afa | ||
|
|
f86b24c511 | ||
|
|
b5ddf08763 | ||
|
|
367826f6e4 | ||
|
|
f03c3b3f05 | ||
|
|
ebfeb7a6bf | ||
|
|
ac1b2d8c40 | ||
|
|
2087f0c6a1 | ||
|
|
bcfdcc6820 | ||
|
|
b65acfcbb7 | ||
|
|
f7fcfafb4c | ||
|
|
15606b4d88 | ||
|
|
bb8f08734a | ||
|
|
0b00e591e1 | ||
|
|
e39fd7dbb3 | ||
|
|
b8a82923e9 | ||
|
|
28f8b7bafa | ||
|
|
32dd052260 | ||
|
|
8f7f7ee7dc | ||
|
|
1d8614c7c2 | ||
|
|
436c5fd751 | ||
|
|
f5f7f47c81 | ||
|
|
d4bd387e0e | ||
|
|
d1c85cb32d | ||
|
|
a3a2c641a7 | ||
|
|
54d7551b53 | ||
|
|
e2c10a2b7a | ||
|
|
88b37e80fc | ||
|
|
d5be8fa576 | ||
|
|
208398973b | ||
|
|
8f998741b7 | ||
|
|
9c0ff87c86 | ||
|
|
1a0d1cb7b2 | ||
|
|
cf8b3ed988 | ||
|
|
b7575a889e | ||
|
|
154d4a43db | ||
|
|
b5c023044b | ||
|
|
072a13f3b2 | ||
|
|
c00ea63bb0 | ||
|
|
8350758635 | ||
|
|
2dabce59ce | ||
|
|
b594f5130d | ||
|
|
e2bb5eecf3 | ||
|
|
e7a4931932 | ||
|
|
ad3c12a43a | ||
|
|
7e2d91f3b7 | ||
|
|
97cef49046 | ||
|
|
016693a1f5 | ||
|
|
89c5185f1c | ||
|
|
b105745299 | ||
|
|
1ae415e395 | ||
|
|
55aff22274 | ||
|
|
e4e1396a98 | ||
|
|
331b8157b0 | ||
|
|
efdb33c975 | ||
|
|
fa1079214b | ||
|
|
82e49af5a7 | ||
|
|
fabc2882aa | ||
|
|
6b3d3f5e21 | ||
|
|
6da6582ced | ||
|
|
45bf07ba31 | ||
|
|
50ae43f886 | ||
|
|
afb877a96b | ||
|
|
0d9172d761 | ||
|
|
dad69afc84 | ||
|
|
787bed4996 | ||
|
|
b6d470a679 | ||
|
|
5fa1a63978 | ||
|
|
6cc57ae772 | ||
|
|
0f6f7059d9 | ||
|
|
67f63ecd7e | ||
|
|
1420d113d8 | ||
|
|
5b4651d9ed | ||
|
|
5f22b68268 | ||
|
|
34590d2144 | ||
|
|
0c93b9b7bb | ||
|
|
b659db0a5b | ||
|
|
9fd9f4c896 | ||
|
|
005b831023 | ||
|
|
8013c4717c | ||
|
|
14e6b21b50 | ||
|
|
62354dff9c | ||
|
|
ccb30665f7 | ||
|
|
0fb2777c6d | ||
|
|
568b8ee96c | ||
|
|
fc60699f03 | ||
|
|
c1da78a271 | ||
|
|
0674f1fa3c | ||
|
|
448394a0de | ||
|
|
3a0fd6be3c | ||
|
|
8b1bec11d0 | ||
|
|
f73dbdbaea | ||
|
|
05f49d2846 | ||
|
|
1d83389776 | ||
|
|
e0e8f11f70 | ||
|
|
36d88f6079 | ||
|
|
2c070952e1 | ||
|
|
fc45148155 | ||
|
|
215c395fc2 | ||
|
|
b56b67cdbd | ||
|
|
a760db9921 | ||
|
|
8eb80ee40a | ||
|
|
f9e3b129ed | ||
|
|
e5050abe2a | ||
|
|
4f0771f67b | ||
|
|
075ff675ac | ||
|
|
c7ea47e886 | ||
|
|
b543339373 | ||
|
|
22c7f659f6 | ||
|
|
79a44d0da4 | ||
|
|
d593a809f0 | ||
|
|
22add31e91 | ||
|
|
b44d740720 | ||
|
|
4d590f9254 | ||
|
|
a5aa48beea | ||
|
|
1bf3861ca4 | ||
|
|
ff9d069a33 | ||
|
|
f8052be369 | ||
|
|
a08438ae97 | ||
|
|
fe00d6aacf | ||
|
|
984692cda2 | ||
|
|
4c12c4fc04 | ||
|
|
794bab45ff | ||
|
|
16e5fa1db9 | ||
|
|
125be3e111 | ||
|
|
b60a53e10d | ||
|
|
9de762faa2 | ||
|
|
5aed38eebc | ||
|
|
e63e483c38 | ||
|
|
277e43e32c | ||
|
|
852aa16ca0 | ||
|
|
82b7153ac1 | ||
|
|
7d2e510087 | ||
|
|
9ca4c10e59 | ||
|
|
a31a79396b | ||
|
|
acc3eb11d0 | ||
|
|
9d9fff2991 | ||
|
|
030ed5d592 | ||
|
|
f6d359932a | ||
|
|
3200b51160 | ||
|
|
4b11ebb30e | ||
|
|
40345642fa | ||
|
|
e932772230 | ||
|
|
63d466fe5e | ||
|
|
c2fada7062 | ||
|
|
d9c29f5ce5 | ||
|
|
f5d5ef6857 | ||
|
|
361a17415f | ||
|
|
fb393c3c51 | ||
|
|
e0158c5d5d | ||
|
|
be12b0771c | ||
|
|
1309fc1f48 | ||
|
|
4fdecfb845 | ||
|
|
31c6f178f3 | ||
|
|
1e2ab8bf1e | ||
|
|
35a1d81518 | ||
|
|
1c4297d8b5 | ||
|
|
e3638a9a9e | ||
|
|
1f8558771a | ||
|
|
2e231d09ec | ||
|
|
727c07bd88 | ||
|
|
c32ad19377 | ||
|
|
ef40ab2933 | ||
|
|
e71fa4a145 | ||
|
|
a7c0887f94 | ||
|
|
53218b91c6 | ||
|
|
2d4de656d2 | ||
|
|
b0f44acf9e | ||
|
|
a828e60067 | ||
|
|
96df70fccf | ||
|
|
0e49dca53c | ||
|
|
8ec4af4641 | ||
|
|
2f6d9417bd | ||
|
|
534a012a4e | ||
|
|
7a3fa9ce03 | ||
|
|
8a67d29748 | ||
|
|
408f4f2dac | ||
|
|
3df2dc0b15 | ||
|
|
5304a8c2d1 | ||
|
|
1569d29b2d | ||
|
|
50c8e74230 | ||
|
|
1045b032a2 | ||
|
|
a813343aa7 | ||
|
|
5a08471dcd | ||
|
|
252dfbcd40 | ||
|
|
75588fe732 | ||
|
|
9bbdeb3d52 | ||
|
|
ec9ba5b784 | ||
|
|
cee4149884 | ||
|
|
5599e4cf35 | ||
|
|
84cdd2df73 | ||
|
|
7929f57460 | ||
|
|
7876679c5d | ||
|
|
bc6928525d | ||
|
|
755c847d9a | ||
|
|
80a8639940 | ||
|
|
6cb5704291 | ||
|
|
4a987c836d | ||
|
|
a2fb55326c | ||
|
|
af29c6a980 | ||
|
|
f2a0e8e5bb | ||
|
|
f6456c2883 | ||
|
|
39f0d000d1 | ||
|
|
a8d9d630bc | ||
|
|
e93a1d8138 | ||
|
|
f6681be6f4 | ||
|
|
c79ac3fe81 | ||
|
|
b78b06353a | ||
|
|
c49b6cc241 | ||
|
|
41fbb4d8b0 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
node_modules
|
||||
**/node_modules/
|
||||
.env
|
||||
docker-compose.extra.yml
|
||||
dist
|
||||
|
||||
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -1,4 +0,0 @@
|
||||
[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: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
|
||||
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
||||
|
||||
## 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`).
|
||||
@@ -8,6 +8,10 @@
|
||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
||||
- Core channel docs: `docs/channels/`
|
||||
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
|
||||
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
|
||||
|
||||
## Docs Linking (Mintlify)
|
||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||
@@ -84,6 +88,7 @@
|
||||
- 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.**
|
||||
|
||||
179
CHANGELOG.md
179
CHANGELOG.md
@@ -2,15 +2,186 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.17 (Unreleased)
|
||||
## 2026.1.18-5
|
||||
|
||||
### Changes
|
||||
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||
- Onboarding: clarify Anthropic token option hints in the onboarding wizard. (#1185) — thanks @KrauseFx.
|
||||
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
|
||||
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
|
||||
|
||||
### Fixes
|
||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
||||
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
|
||||
- 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.
|
||||
- macOS: suppress usage error text in the menubar cost view.
|
||||
- Telegram: honor pairing allowlists for native slash commands.
|
||||
|
||||
## 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
|
||||
- Exec: add host/security/ask routing for gateway + node exec.
|
||||
- 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.
|
||||
- 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
|
||||
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
|
||||
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
|
||||
- Docs: document plugin slots and memory plugin behavior.
|
||||
- Plugins: add the bundled BlueBubbles channel plugin (disabled by default).
|
||||
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
|
||||
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime.
|
||||
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime.
|
||||
|
||||
## 2026.1.17-5
|
||||
|
||||
### Changes
|
||||
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
|
||||
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
|
||||
- CLI: surface FTS + embedding cache state in `clawdbot memory status`.
|
||||
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default.
|
||||
- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
|
||||
- Tools: centralize plugin tool policy helpers.
|
||||
- Commands: add `/subagents info` and show sub-agent counts in `/status`.
|
||||
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
|
||||
|
||||
### Fixes
|
||||
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
||||
|
||||
## 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-3
|
||||
|
||||
### Changes
|
||||
- Memory: add OpenAI Batch API indexing for embeddings when configured.
|
||||
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
|
||||
|
||||
### Fixes
|
||||
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
|
||||
|
||||
## 2026.1.17-2
|
||||
|
||||
### Changes
|
||||
|
||||
### Fixes
|
||||
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
|
||||
- Memory: parallelize embedding indexing with rate-limit retries.
|
||||
- Memory: split overly long lines to keep embeddings under token limits.
|
||||
- Memory: skip empty chunks to avoid invalid embedding inputs.
|
||||
- Sessions: fall back to session labels when listing display names. (#1124) — thanks @abdaraxus.
|
||||
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) — thanks @thewilloftheshadow.
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Telegram: enrich forwarded message context with normalized origin details + legacy fallback. (#1090) — thanks @sleontenko.
|
||||
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
|
||||
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
|
||||
- CLI: surface update availability in `clawdbot status`.
|
||||
- CLI: add `clawdbot memory status --deep/--index` probes.
|
||||
- CLI: add playful update completion quips.
|
||||
|
||||
### Fixes
|
||||
- Doctor: avoid re-adding WhatsApp ack reaction config when only legacy auth files exist. (#1087) — thanks @YuriNachos.
|
||||
- Hooks: parse multi-line/YAML frontmatter metadata blocks (JSON5-friendly). (#1114) — thanks @sebslight.
|
||||
- CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.
|
||||
- Windows: install gateway scheduled task as the current user; show friendly guidance instead of failing on access denied.
|
||||
- Status: show both usage windows with reset hints when usage data is available. (#1101) — thanks @rhjoh.
|
||||
- Memory: probe sqlite-vec availability in `clawdbot memory status`.
|
||||
- Memory: split embedding batches to avoid OpenAI token limits during indexing.
|
||||
- Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118) — thanks @sleontenko.
|
||||
|
||||
## 2026.1.16-2
|
||||
|
||||
### Changes
|
||||
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
|
||||
- CLI: close memory manager after memory commands to avoid hanging processes. (#1127) — thanks @NicholasSpisak.
|
||||
|
||||
## 2026.1.16-1
|
||||
|
||||
@@ -48,6 +219,8 @@ Docs: https://docs.clawd.bot
|
||||
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
|
||||
- Directory: unify `clawdbot directory` across channels and plugin channels.
|
||||
- UI: allow deleting sessions from the Control UI.
|
||||
- Memory: add sqlite-vec vector acceleration with CLI status details.
|
||||
- Memory: add experimental session transcript indexing for memory_search (opt-in via memorySearch.experimental.sessionMemory + sources).
|
||||
- Skills: add user-invocable skill commands and expanded skill command registration.
|
||||
- Telegram: default reaction level to minimal and enable reaction notifications by default.
|
||||
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
|
||||
@@ -66,6 +239,10 @@ Docs: https://docs.clawd.bot
|
||||
### Fixes
|
||||
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash.
|
||||
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels.
|
||||
- Tools: include provider/session context in elevated exec denial errors.
|
||||
- Tools: normalize exec tool alias naming in tool error logs.
|
||||
- Logging: reuse shared ANSI stripping to keep console capture lint-clean.
|
||||
- Logging: prefix nested agent output with session/run/channel context.
|
||||
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
|
||||
- Telegram: split long captions into follow-up messages.
|
||||
- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm.
|
||||
|
||||
1
Peekaboo
1
Peekaboo
Submodule Peekaboo deleted from 5c195f5e46
44
README.md
44
README.md
@@ -249,7 +249,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||
- `/verbose on|off`
|
||||
- `/cost on|off` — append per-response token/cost usage lines
|
||||
- `/usage off|tokens|full` — per-response usage footer
|
||||
- `/restart` — restart the gateway (owner-only in groups)
|
||||
- `/activation mention|always` — group activation toggle (groups only)
|
||||
|
||||
@@ -474,24 +474,26 @@ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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/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/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/KrauseFx"><img src="https://avatars.githubusercontent.com/u/869950?v=4&s=48" width="48" height="48" alt="KrauseFx" title="KrauseFx"/></a>
|
||||
|
||||
</p>
|
||||
|
||||
@@ -13,7 +13,7 @@ let package = Package(
|
||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../Peekaboo/Commander"),
|
||||
.package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"),
|
||||
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||
],
|
||||
targets: [
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
{
|
||||
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
|
||||
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
|
||||
"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",
|
||||
@@ -10,15 +28,6 @@
|
||||
"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",
|
||||
@@ -28,6 +37,15 @@
|
||||
"version" : "1.2.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "peekaboo",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/steipete/Peekaboo.git",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "sparkle",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -46,33 +64,6 @@
|
||||
"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",
|
||||
@@ -82,24 +73,6 @@
|
||||
"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",
|
||||
@@ -118,24 +91,6 @@
|
||||
"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,10 +20,9 @@ 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(
|
||||
@@ -61,8 +60,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: "PeekabooCore"),
|
||||
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
||||
.product(name: "PeekabooBridge", package: "Peekaboo"),
|
||||
.product(name: "PeekabooAutomationKit", package: "Peekaboo"),
|
||||
],
|
||||
exclude: [
|
||||
"Resources/Info.plist",
|
||||
|
||||
64
apps/macos/README.md
Normal file
64
apps/macos/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# 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)
|
||||
@@ -170,8 +170,15 @@ final class AppState {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||
}
|
||||
|
||||
var systemRunPolicy: SystemRunPolicy {
|
||||
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
|
||||
var execApprovalMode: ExecApprovalQuickMode {
|
||||
didSet {
|
||||
self.ifNotPreview {
|
||||
ExecApprovalsStore.updateDefaults { defaults in
|
||||
defaults.security = self.execApprovalMode.security
|
||||
defaults.ask = self.execApprovalMode.ask
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks whether the Canvas panel is currently visible (not persisted).
|
||||
@@ -274,7 +281,8 @@ final class AppState {
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
||||
self.systemRunPolicy = SystemRunPolicy.load()
|
||||
let execDefaults = ExecApprovalsStore.resolveDefaults()
|
||||
self.execApprovalMode = ExecApprovalQuickMode.from(security: execDefaults.security, ask: execDefaults.ask)
|
||||
self.peekabooBridgeEnabled = UserDefaults.standard
|
||||
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
||||
if !self.isPreview {
|
||||
|
||||
@@ -8,6 +8,8 @@ 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?
|
||||
@@ -147,6 +149,8 @@ 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(),
|
||||
@@ -171,6 +175,8 @@ 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,
|
||||
@@ -186,6 +192,8 @@ 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,
|
||||
|
||||
@@ -35,7 +35,7 @@ enum CLIInstaller {
|
||||
}
|
||||
|
||||
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
|
||||
let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
||||
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
||||
let prefix = Self.installPrefix()
|
||||
await statusHandler("Installing clawdbot CLI…")
|
||||
let cmd = self.installScriptCommand(version: expected, prefix: prefix)
|
||||
|
||||
@@ -6,11 +6,11 @@ struct ConfigSchemaForm: View {
|
||||
let path: ConfigPath
|
||||
|
||||
var body: some View {
|
||||
self.renderNode(schema, path: path)
|
||||
self.renderNode(self.schema, path: self.path)
|
||||
}
|
||||
|
||||
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
|
||||
let storedValue = store.configValue(at: path)
|
||||
let storedValue = self.store.configValue(at: path)
|
||||
let value = storedValue ?? schema.explicitDefault
|
||||
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
|
||||
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
|
||||
@@ -21,7 +21,7 @@ struct ConfigSchemaForm: View {
|
||||
if nonNull.count == 1, let only = nonNull.first {
|
||||
return self.renderNode(only, path: path)
|
||||
}
|
||||
let literals = nonNull.compactMap { $0.literalValue }
|
||||
let literals = nonNull.compactMap(\.literalValue)
|
||||
if !literals.isEmpty, literals.count == nonNull.count {
|
||||
return AnyView(
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
@@ -31,15 +31,20 @@ struct ConfigSchemaForm: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Picker("", selection: self.enumBinding(path, options: literals, defaultValue: schema.explicitDefault)) {
|
||||
Picker(
|
||||
"",
|
||||
selection: self.enumBinding(
|
||||
path,
|
||||
options: literals,
|
||||
defaultValue: schema.explicitDefault))
|
||||
{
|
||||
Text("Select…").tag(-1)
|
||||
ForEach(literals.indices, id: \ .self) { index in
|
||||
Text(String(describing: literals[index])).tag(index)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +76,7 @@ struct ConfigSchemaForm: View {
|
||||
if schema.allowsAdditionalProperties {
|
||||
self.renderAdditionalProperties(schema, path: path, value: value)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
case "array":
|
||||
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
|
||||
case "boolean":
|
||||
@@ -80,8 +84,7 @@ struct ConfigSchemaForm: View {
|
||||
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
|
||||
if let label { Text(label) } else { Text("Enabled") }
|
||||
}
|
||||
.help(help ?? "")
|
||||
)
|
||||
.help(help ?? ""))
|
||||
case "number", "integer":
|
||||
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
|
||||
case "string":
|
||||
@@ -93,8 +96,7 @@ struct ConfigSchemaForm: View {
|
||||
Text("Unsupported field type.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,9 +157,7 @@ struct ConfigSchemaForm: View {
|
||||
text: self.numberBinding(
|
||||
path,
|
||||
isInteger: schema.schemaType == "integer",
|
||||
defaultValue: defaultValue
|
||||
)
|
||||
)
|
||||
defaultValue: defaultValue))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
@@ -189,7 +189,7 @@ struct ConfigSchemaForm: View {
|
||||
Button("Remove") {
|
||||
var next = items
|
||||
next.remove(at: index)
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
self.store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -202,7 +202,7 @@ struct ConfigSchemaForm: View {
|
||||
} else {
|
||||
next.append("")
|
||||
}
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
self.store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -238,7 +238,7 @@ struct ConfigSchemaForm: View {
|
||||
Button("Remove") {
|
||||
var next = dict
|
||||
next.removeValue(forKey: key)
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
self.store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -254,7 +254,7 @@ struct ConfigSchemaForm: View {
|
||||
key = "new-\(index)"
|
||||
}
|
||||
next[key] = additionalSchema.defaultValue
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
self.store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.small)
|
||||
@@ -270,9 +270,8 @@ struct ConfigSchemaForm: View {
|
||||
},
|
||||
set: { newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
|
||||
}
|
||||
)
|
||||
self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
|
||||
})
|
||||
}
|
||||
|
||||
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
|
||||
@@ -282,16 +281,15 @@ struct ConfigSchemaForm: View {
|
||||
return defaultValue ?? false
|
||||
},
|
||||
set: { newValue in
|
||||
store.updateConfigValue(path: path, value: newValue)
|
||||
}
|
||||
)
|
||||
self.store.updateConfigValue(path: path, value: newValue)
|
||||
})
|
||||
}
|
||||
|
||||
private func numberBinding(
|
||||
_ path: ConfigPath,
|
||||
isInteger: Bool,
|
||||
defaultValue: Double?
|
||||
) -> Binding<String> {
|
||||
defaultValue: Double?) -> Binding<String>
|
||||
{
|
||||
Binding(
|
||||
get: {
|
||||
if let value = store.configValue(at: path) { return String(describing: value) }
|
||||
@@ -301,22 +299,21 @@ struct ConfigSchemaForm: View {
|
||||
set: { newValue in
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
store.updateConfigValue(path: path, value: nil)
|
||||
self.store.updateConfigValue(path: path, value: nil)
|
||||
} else if let value = Double(trimmed) {
|
||||
store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
|
||||
self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private func enumBinding(
|
||||
_ path: ConfigPath,
|
||||
options: [Any],
|
||||
defaultValue: Any?
|
||||
) -> Binding<Int> {
|
||||
defaultValue: Any?) -> Binding<Int>
|
||||
{
|
||||
Binding(
|
||||
get: {
|
||||
let value = store.configValue(at: path) ?? defaultValue
|
||||
let value = self.store.configValue(at: path) ?? defaultValue
|
||||
guard let value else { return -1 }
|
||||
return options.firstIndex { option in
|
||||
String(describing: option) == String(describing: value)
|
||||
@@ -324,12 +321,11 @@ struct ConfigSchemaForm: View {
|
||||
},
|
||||
set: { index in
|
||||
guard index >= 0, index < options.count else {
|
||||
store.updateConfigValue(path: path, value: nil)
|
||||
self.store.updateConfigValue(path: path, value: nil)
|
||||
return
|
||||
}
|
||||
store.updateConfigValue(path: path, value: options[index])
|
||||
}
|
||||
)
|
||||
self.store.updateConfigValue(path: path, value: options[index])
|
||||
})
|
||||
}
|
||||
|
||||
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
|
||||
@@ -339,14 +335,13 @@ struct ConfigSchemaForm: View {
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
guard trimmed != key else { return }
|
||||
let current = store.configValue(at: path) as? [String: Any] ?? [:]
|
||||
let current = self.store.configValue(at: path) as? [String: Any] ?? [:]
|
||||
guard current[trimmed] == nil else { return }
|
||||
var next = current
|
||||
next[trimmed] = current[key]
|
||||
next.removeValue(forKey: key)
|
||||
store.updateConfigValue(path: path, value: next)
|
||||
}
|
||||
)
|
||||
self.store.updateConfigValue(path: path, value: next)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,10 +350,10 @@ struct ChannelConfigForm: View {
|
||||
let channelId: String
|
||||
|
||||
var body: some View {
|
||||
if store.configSchemaLoading {
|
||||
if self.store.configSchemaLoading {
|
||||
ProgressView().controlSize(.small)
|
||||
} else if let schema = store.channelConfigSchema(for: channelId) {
|
||||
ConfigSchemaForm(store: store, schema: schema, path: [.key("channels"), .key(channelId)])
|
||||
ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)])
|
||||
} else {
|
||||
Text("Schema unavailable for this channel.")
|
||||
.font(.caption)
|
||||
|
||||
@@ -434,25 +434,25 @@ extension ChannelsSettings {
|
||||
|
||||
private func resolveChannelDetailTitle(_ id: String) -> String {
|
||||
switch id {
|
||||
case "whatsapp": return "WhatsApp Web"
|
||||
case "telegram": return "Telegram Bot"
|
||||
case "discord": return "Discord Bot"
|
||||
case "slack": return "Slack Bot"
|
||||
case "signal": return "Signal REST"
|
||||
case "imessage": return "iMessage"
|
||||
default: return self.resolveChannelTitle(id)
|
||||
case "whatsapp": "WhatsApp Web"
|
||||
case "telegram": "Telegram Bot"
|
||||
case "discord": "Discord Bot"
|
||||
case "slack": "Slack Bot"
|
||||
case "signal": "Signal REST"
|
||||
case "imessage": "iMessage"
|
||||
default: self.resolveChannelTitle(id)
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveChannelSystemImage(_ id: String) -> String {
|
||||
switch id {
|
||||
case "whatsapp": return "message"
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
case "slack": return "number"
|
||||
case "signal": return "antenna.radiowaves.left.and.right"
|
||||
case "imessage": return "message.fill"
|
||||
default: return "message"
|
||||
case "whatsapp": "message"
|
||||
case "telegram": "paperplane"
|
||||
case "discord": "bubble.left.and.bubble.right"
|
||||
case "slack": "number"
|
||||
case "signal": "antenna.radiowaves.left.and.right"
|
||||
case "imessage": "message.fill"
|
||||
default: "message"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ extension ChannelsStore {
|
||||
return value
|
||||
}
|
||||
guard path.count >= 2 else { return nil }
|
||||
if case .key("channels") = path[0], case .key(_) = path[1] {
|
||||
if case .key("channels") = path[0], case .key = path[1] {
|
||||
let fallbackPath = Array(path.dropFirst())
|
||||
return valueAtPath(self.configDraft, path: fallbackPath)
|
||||
}
|
||||
@@ -93,10 +93,10 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
|
||||
var current: Any? = root
|
||||
for segment in path {
|
||||
switch segment {
|
||||
case .key(let key):
|
||||
case let .key(key):
|
||||
guard let dict = current as? [String: Any] else { return nil }
|
||||
current = dict[key]
|
||||
case .index(let index):
|
||||
case let .index(index):
|
||||
guard let array = current as? [Any], array.indices.contains(index) else { return nil }
|
||||
current = array[index]
|
||||
}
|
||||
@@ -107,7 +107,7 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
|
||||
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
|
||||
guard let segment = path.first else { return }
|
||||
switch segment {
|
||||
case .key(let key):
|
||||
case let .key(key):
|
||||
var dict = root as? [String: Any] ?? [:]
|
||||
if path.count == 1 {
|
||||
if let value {
|
||||
@@ -122,7 +122,7 @@ private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
|
||||
setValue(&child, path: Array(path.dropFirst()), value: value)
|
||||
dict[key] = child
|
||||
root = dict
|
||||
case .index(let index):
|
||||
case let .index(index):
|
||||
var array = root as? [Any] ?? []
|
||||
if index >= array.count {
|
||||
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
|
||||
|
||||
@@ -214,9 +214,10 @@ enum CommandResolver {
|
||||
subcommand: String,
|
||||
extraArgs: [String] = [],
|
||||
defaults: UserDefaults = .standard,
|
||||
configRoot: [String: Any]? = nil,
|
||||
searchPaths: [String]? = nil) -> [String]
|
||||
{
|
||||
let settings = self.connectionSettings(defaults: defaults)
|
||||
let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot)
|
||||
if settings.mode == .remote, let ssh = self.sshNodeCommand(
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs,
|
||||
@@ -264,12 +265,14 @@ enum CommandResolver {
|
||||
subcommand: String,
|
||||
extraArgs: [String] = [],
|
||||
defaults: UserDefaults = .standard,
|
||||
configRoot: [String: Any]? = nil,
|
||||
searchPaths: [String]? = nil) -> [String]
|
||||
{
|
||||
self.clawdbotNodeCommand(
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs,
|
||||
defaults: defaults,
|
||||
configRoot: configRoot,
|
||||
searchPaths: searchPaths)
|
||||
}
|
||||
|
||||
@@ -384,8 +387,11 @@ enum CommandResolver {
|
||||
let cliPath: String
|
||||
}
|
||||
|
||||
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
static func connectionSettings(
|
||||
defaults: UserDefaults = .standard,
|
||||
configRoot: [String: Any]? = nil) -> RemoteSettings
|
||||
{
|
||||
let root = configRoot ?? ClawdbotConfigFile.loadDict()
|
||||
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
||||
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||
|
||||
@@ -133,7 +133,7 @@ struct ConfigSchemaNode {
|
||||
for segment in path {
|
||||
guard let node = current else { return nil }
|
||||
switch segment {
|
||||
case .key(let key):
|
||||
case let .key(key):
|
||||
if node.schemaType == "object" {
|
||||
if let next = node.properties[key] {
|
||||
current = next
|
||||
@@ -174,7 +174,7 @@ func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiH
|
||||
var match = true
|
||||
for (index, seg) in segments.enumerated() {
|
||||
let hintSegment = hintSegments[index]
|
||||
if hintSegment != "*" && hintSegment != seg {
|
||||
if hintSegment != "*", hintSegment != seg {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
@@ -196,7 +196,7 @@ func isSensitivePath(_ path: ConfigPath) -> Bool {
|
||||
func pathKey(_ path: ConfigPath) -> String {
|
||||
path.compactMap { segment -> String? in
|
||||
switch segment {
|
||||
case .key(let key): return key
|
||||
case let .key(key): return key
|
||||
case .index: return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ extension ConfigSettings {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if self.store.configDirty && !self.isNixMode {
|
||||
if self.store.configDirty, !self.isNixMode {
|
||||
Text("Unsaved changes")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -12,6 +12,9 @@ final class ConnectionModeCoordinator {
|
||||
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
|
||||
switch mode {
|
||||
case .unconfigured:
|
||||
if let error = await NodeServiceManager.stop() {
|
||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||
}
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
GatewayProcessManager.shared.stop()
|
||||
@@ -20,6 +23,9 @@ final class ConnectionModeCoordinator {
|
||||
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
|
||||
|
||||
case .local:
|
||||
if let error = await NodeServiceManager.stop() {
|
||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
||||
}
|
||||
await RemoteTunnelManager.shared.stopAll()
|
||||
WebChatManager.shared.resetTunnels()
|
||||
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
||||
@@ -50,6 +56,9 @@ final class ConnectionModeCoordinator {
|
||||
WebChatManager.shared.resetTunnels()
|
||||
|
||||
do {
|
||||
if let error = await NodeServiceManager.start() {
|
||||
NodesStore.shared.lastError = "Node service start failed: \(error)"
|
||||
}
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
try await ControlChannel.shared.configure(mode: .remote(
|
||||
|
||||
673
apps/macos/Sources/Clawdbot/ExecApprovals.swift
Normal file
673
apps/macos/Sources/Clawdbot/ExecApprovals.swift
Normal file
@@ -0,0 +1,673 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import OSLog
|
||||
import Security
|
||||
|
||||
enum ExecSecurity: String, CaseIterable, Codable, Identifiable {
|
||||
case deny
|
||||
case allowlist
|
||||
case full
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .deny: "Deny"
|
||||
case .allowlist: "Allowlist"
|
||||
case .full: "Always Allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecApprovalQuickMode: String, CaseIterable, Identifiable {
|
||||
case deny
|
||||
case ask
|
||||
case allow
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .deny: "Deny"
|
||||
case .ask: "Always Ask"
|
||||
case .allow: "Always Allow"
|
||||
}
|
||||
}
|
||||
|
||||
var security: ExecSecurity {
|
||||
switch self {
|
||||
case .deny: .deny
|
||||
case .ask: .allowlist
|
||||
case .allow: .full
|
||||
}
|
||||
}
|
||||
|
||||
var ask: ExecAsk {
|
||||
switch self {
|
||||
case .deny: .off
|
||||
case .ask: .onMiss
|
||||
case .allow: .off
|
||||
}
|
||||
}
|
||||
|
||||
static func from(security: ExecSecurity, ask: ExecAsk) -> ExecApprovalQuickMode {
|
||||
switch security {
|
||||
case .deny:
|
||||
return .deny
|
||||
case .full:
|
||||
return .allow
|
||||
case .allowlist:
|
||||
return .ask
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecAsk: String, CaseIterable, Codable, Identifiable {
|
||||
case off
|
||||
case onMiss = "on-miss"
|
||||
case always
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .off: "Never Ask"
|
||||
case .onMiss: "Ask on Allowlist Miss"
|
||||
case .always: "Always Ask"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecApprovalDecision: String, Codable, Sendable {
|
||||
case allowOnce = "allow-once"
|
||||
case allowAlways = "allow-always"
|
||||
case deny
|
||||
}
|
||||
|
||||
struct ExecAllowlistEntry: Codable, Hashable {
|
||||
var pattern: String
|
||||
var lastUsedAt: Double? = nil
|
||||
var lastUsedCommand: String? = nil
|
||||
var lastResolvedPath: String? = nil
|
||||
}
|
||||
|
||||
struct ExecApprovalsDefaults: Codable {
|
||||
var security: ExecSecurity?
|
||||
var ask: ExecAsk?
|
||||
var askFallback: ExecSecurity?
|
||||
var autoAllowSkills: Bool?
|
||||
}
|
||||
|
||||
struct ExecApprovalsAgent: Codable {
|
||||
var security: ExecSecurity?
|
||||
var ask: ExecAsk?
|
||||
var askFallback: ExecSecurity?
|
||||
var autoAllowSkills: Bool?
|
||||
var allowlist: [ExecAllowlistEntry]?
|
||||
|
||||
var isEmpty: Bool {
|
||||
security == nil && ask == nil && askFallback == nil && autoAllowSkills == nil && (allowlist?.isEmpty ?? true)
|
||||
}
|
||||
}
|
||||
|
||||
struct ExecApprovalsSocketConfig: Codable {
|
||||
var path: String?
|
||||
var token: String?
|
||||
}
|
||||
|
||||
struct ExecApprovalsFile: Codable {
|
||||
var version: Int
|
||||
var socket: ExecApprovalsSocketConfig?
|
||||
var defaults: ExecApprovalsDefaults?
|
||||
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
|
||||
let token: String
|
||||
let defaults: ExecApprovalsResolvedDefaults
|
||||
let agent: ExecApprovalsResolvedDefaults
|
||||
let allowlist: [ExecAllowlistEntry]
|
||||
var file: ExecApprovalsFile
|
||||
}
|
||||
|
||||
struct ExecApprovalsResolvedDefaults {
|
||||
var security: ExecSecurity
|
||||
var ask: ExecAsk
|
||||
var askFallback: ExecSecurity
|
||||
var autoAllowSkills: Bool
|
||||
}
|
||||
|
||||
enum ExecApprovalsStore {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
|
||||
private static let defaultSecurity: ExecSecurity = .deny
|
||||
private static let defaultAsk: ExecAsk = .onMiss
|
||||
private static let defaultAskFallback: ExecSecurity = .deny
|
||||
private static let defaultAutoAllowSkills = false
|
||||
|
||||
static func fileURL() -> URL {
|
||||
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.json")
|
||||
}
|
||||
|
||||
static func socketPath() -> String {
|
||||
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 {
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
}
|
||||
do {
|
||||
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 decoded
|
||||
} catch {
|
||||
self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)")
|
||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||
}
|
||||
}
|
||||
|
||||
static func saveFile(_ file: ExecApprovalsFile) {
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(file)
|
||||
let url = self.fileURL()
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
} catch {
|
||||
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
static func ensureFile() -> ExecApprovalsFile {
|
||||
var file = self.loadFile()
|
||||
if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) }
|
||||
let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if path.isEmpty {
|
||||
file.socket?.path = self.socketPath()
|
||||
}
|
||||
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if token.isEmpty {
|
||||
file.socket?.token = self.generateToken()
|
||||
}
|
||||
if file.agents == nil { file.agents = [:] }
|
||||
self.saveFile(file)
|
||||
return file
|
||||
}
|
||||
|
||||
static func resolve(agentId: String?) -> ExecApprovalsResolved {
|
||||
let file = self.ensureFile()
|
||||
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||
let resolvedDefaults = ExecApprovalsResolvedDefaults(
|
||||
security: defaults.security ?? self.defaultSecurity,
|
||||
ask: defaults.ask ?? self.defaultAsk,
|
||||
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
||||
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
: "default"
|
||||
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
||||
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||
security: agentEntry.security ?? resolvedDefaults.security,
|
||||
ask: agentEntry.ask ?? resolvedDefaults.ask,
|
||||
askFallback: agentEntry.askFallback ?? resolvedDefaults.askFallback,
|
||||
autoAllowSkills: agentEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
|
||||
let allowlist = (agentEntry.allowlist ?? [])
|
||||
.map { entry in
|
||||
ExecAllowlistEntry(
|
||||
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
lastUsedAt: entry.lastUsedAt,
|
||||
lastUsedCommand: entry.lastUsedCommand,
|
||||
lastResolvedPath: entry.lastResolvedPath)
|
||||
}
|
||||
.filter { !$0.pattern.isEmpty }
|
||||
let socketPath = self.expandPath(file.socket?.path ?? self.socketPath())
|
||||
let token = file.socket?.token ?? ""
|
||||
return ExecApprovalsResolved(
|
||||
url: self.fileURL(),
|
||||
socketPath: socketPath,
|
||||
token: token,
|
||||
defaults: resolvedDefaults,
|
||||
agent: resolvedAgent,
|
||||
allowlist: allowlist,
|
||||
file: file)
|
||||
}
|
||||
|
||||
static func resolveDefaults() -> ExecApprovalsResolvedDefaults {
|
||||
let file = self.ensureFile()
|
||||
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||
return ExecApprovalsResolvedDefaults(
|
||||
security: defaults.security ?? self.defaultSecurity,
|
||||
ask: defaults.ask ?? self.defaultAsk,
|
||||
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
||||
}
|
||||
|
||||
static func saveDefaults(_ defaults: ExecApprovalsDefaults) {
|
||||
self.updateFile { file in
|
||||
file.defaults = defaults
|
||||
}
|
||||
}
|
||||
|
||||
static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) {
|
||||
self.updateFile { file in
|
||||
var defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||
mutate(&defaults)
|
||||
file.defaults = defaults
|
||||
}
|
||||
}
|
||||
|
||||
static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) {
|
||||
self.updateFile { file in
|
||||
var agents = file.agents ?? [:]
|
||||
let key = self.agentKey(agentId)
|
||||
if agent.isEmpty {
|
||||
agents.removeValue(forKey: key)
|
||||
} else {
|
||||
agents[key] = agent
|
||||
}
|
||||
file.agents = agents.isEmpty ? nil : agents
|
||||
}
|
||||
}
|
||||
|
||||
static func addAllowlistEntry(agentId: String?, pattern: String) {
|
||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
self.updateFile { file in
|
||||
let key = self.agentKey(agentId)
|
||||
var agents = file.agents ?? [:]
|
||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||
var allowlist = entry.allowlist ?? []
|
||||
if allowlist.contains(where: { $0.pattern == trimmed }) { return }
|
||||
allowlist.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: Date().timeIntervalSince1970 * 1000))
|
||||
entry.allowlist = allowlist
|
||||
agents[key] = entry
|
||||
file.agents = agents
|
||||
}
|
||||
}
|
||||
|
||||
static func recordAllowlistUse(
|
||||
agentId: String?,
|
||||
pattern: String,
|
||||
command: String,
|
||||
resolvedPath: String?)
|
||||
{
|
||||
self.updateFile { file in
|
||||
let key = self.agentKey(agentId)
|
||||
var agents = file.agents ?? [:]
|
||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
||||
guard item.pattern == pattern else { return item }
|
||||
return ExecAllowlistEntry(
|
||||
pattern: item.pattern,
|
||||
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
||||
lastUsedCommand: command,
|
||||
lastResolvedPath: resolvedPath)
|
||||
}
|
||||
entry.allowlist = allowlist
|
||||
agents[key] = entry
|
||||
file.agents = agents
|
||||
}
|
||||
}
|
||||
|
||||
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) {
|
||||
self.updateFile { file in
|
||||
let key = self.agentKey(agentId)
|
||||
var agents = file.agents ?? [:]
|
||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||
let cleaned = allowlist
|
||||
.map { item in
|
||||
ExecAllowlistEntry(
|
||||
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
lastUsedAt: item.lastUsedAt,
|
||||
lastUsedCommand: item.lastUsedCommand,
|
||||
lastResolvedPath: item.lastResolvedPath)
|
||||
}
|
||||
.filter { !$0.pattern.isEmpty }
|
||||
entry.allowlist = cleaned
|
||||
agents[key] = entry
|
||||
file.agents = agents
|
||||
}
|
||||
}
|
||||
|
||||
static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) {
|
||||
self.updateFile { file in
|
||||
let key = self.agentKey(agentId)
|
||||
var agents = file.agents ?? [:]
|
||||
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||
mutate(&entry)
|
||||
if entry.isEmpty {
|
||||
agents.removeValue(forKey: key)
|
||||
} else {
|
||||
agents[key] = entry
|
||||
}
|
||||
file.agents = agents.isEmpty ? nil : agents
|
||||
}
|
||||
}
|
||||
|
||||
private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) {
|
||||
var file = self.ensureFile()
|
||||
mutate(&file)
|
||||
self.saveFile(file)
|
||||
}
|
||||
|
||||
private static func generateToken() -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: 24)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
if status == errSecSuccess {
|
||||
return Data(bytes)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
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 == "~" {
|
||||
return FileManager.default.homeDirectoryForCurrentUser.path
|
||||
}
|
||||
if trimmed.hasPrefix("~/") {
|
||||
let suffix = trimmed.dropFirst(2)
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(String(suffix)).path
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private static func agentKey(_ agentId: String?) -> String {
|
||||
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return trimmed.isEmpty ? "default" : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
struct ExecCommandResolution: Sendable {
|
||||
let rawExecutable: String
|
||||
let resolvedPath: String?
|
||||
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 hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
|
||||
let resolvedPath: String? = {
|
||||
if hasPathSeparator {
|
||||
if expanded.hasPrefix("/") {
|
||||
return expanded
|
||||
}
|
||||
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath
|
||||
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
|
||||
}
|
||||
let searchPaths = self.searchPaths(from: env)
|
||||
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
|
||||
}()
|
||||
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
|
||||
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 {
|
||||
return raw.split(separator: ":").map(String.init)
|
||||
}
|
||||
return CommandResolver.preferredPaths()
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecCommandFormatter {
|
||||
static func displayString(for argv: [String]) -> String {
|
||||
argv.map { arg in
|
||||
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "\"\"" }
|
||||
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
|
||||
if !needsQuotes { return trimmed }
|
||||
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
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 {
|
||||
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
||||
guard let resolution, !entries.isEmpty else { return nil }
|
||||
let rawExecutable = resolution.rawExecutable
|
||||
let resolvedPath = resolution.resolvedPath
|
||||
let executableName = resolution.executableName
|
||||
|
||||
for entry in entries {
|
||||
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if pattern.isEmpty { continue }
|
||||
let hasPath = pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
|
||||
if hasPath {
|
||||
let target = resolvedPath ?? rawExecutable
|
||||
if self.matches(pattern: pattern, target: target) { return entry }
|
||||
} else if self.matches(pattern: pattern, target: executableName) {
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func matches(pattern: String, target: String) -> Bool {
|
||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
|
||||
let normalizedPattern = self.normalizeMatchTarget(expanded)
|
||||
let normalizedTarget = self.normalizeMatchTarget(target)
|
||||
guard let regex = self.regex(for: normalizedPattern) else { return false }
|
||||
let range = NSRange(location: 0, length: normalizedTarget.utf16.count)
|
||||
return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil
|
||||
}
|
||||
|
||||
private static func normalizeMatchTarget(_ value: String) -> String {
|
||||
value.replacingOccurrences(of: "\\\\", with: "/").lowercased()
|
||||
}
|
||||
|
||||
private static func regex(for pattern: String) -> NSRegularExpression? {
|
||||
var regex = "^"
|
||||
var idx = pattern.startIndex
|
||||
while idx < pattern.endIndex {
|
||||
let ch = pattern[idx]
|
||||
if ch == "*" {
|
||||
let next = pattern.index(after: idx)
|
||||
if next < pattern.endIndex, pattern[next] == "*" {
|
||||
regex += ".*"
|
||||
idx = pattern.index(after: next)
|
||||
} else {
|
||||
regex += "[^/]*"
|
||||
idx = next
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ch == "?" {
|
||||
regex += "."
|
||||
idx = pattern.index(after: idx)
|
||||
continue
|
||||
}
|
||||
regex += NSRegularExpression.escapedPattern(for: String(ch))
|
||||
idx = pattern.index(after: idx)
|
||||
}
|
||||
regex += "$"
|
||||
return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive])
|
||||
}
|
||||
}
|
||||
|
||||
struct ExecEventPayload: Codable, Sendable {
|
||||
var sessionKey: String
|
||||
var runId: String
|
||||
var host: String
|
||||
var command: String?
|
||||
var exitCode: Int?
|
||||
var timedOut: Bool?
|
||||
var success: Bool?
|
||||
var output: String?
|
||||
var reason: String?
|
||||
|
||||
static func truncateOutput(_ raw: String, maxChars: Int = 20_000) -> String? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
if trimmed.count <= maxChars { return trimmed }
|
||||
let suffix = trimmed.suffix(maxChars)
|
||||
return "... (truncated) \(suffix)"
|
||||
}
|
||||
}
|
||||
|
||||
actor SkillBinsCache {
|
||||
static let shared = SkillBinsCache()
|
||||
|
||||
private var bins: Set<String> = []
|
||||
private var lastRefresh: Date?
|
||||
private let refreshInterval: TimeInterval = 90
|
||||
|
||||
func currentBins(force: Bool = false) async -> Set<String> {
|
||||
if force || self.isStale() {
|
||||
await self.refresh()
|
||||
}
|
||||
return self.bins
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
do {
|
||||
let report = try await GatewayConnection.shared.skillsStatus()
|
||||
var next = Set<String>()
|
||||
for skill in report.skills {
|
||||
for bin in skill.requirements.bins {
|
||||
let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { next.insert(trimmed) }
|
||||
}
|
||||
}
|
||||
self.bins = next
|
||||
self.lastRefresh = Date()
|
||||
} catch {
|
||||
if self.lastRefresh == nil {
|
||||
self.bins = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func isStale() -> Bool {
|
||||
guard let lastRefresh else { return true }
|
||||
return Date().timeIntervalSince(lastRefresh) > self.refreshInterval
|
||||
}
|
||||
}
|
||||
666
apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift
Normal file
666
apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift
Normal file
@@ -0,0 +1,666 @@
|
||||
import AppKit
|
||||
import ClawdbotKit
|
||||
import CryptoKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
struct ExecApprovalPromptRequest: Codable, Sendable {
|
||||
var command: String
|
||||
var cwd: String?
|
||||
var host: String?
|
||||
var security: String?
|
||||
var ask: String?
|
||||
var agentId: String?
|
||||
var resolvedPath: String?
|
||||
}
|
||||
|
||||
private struct ExecApprovalSocketRequest: Codable {
|
||||
var type: String
|
||||
var token: String
|
||||
var id: String
|
||||
var request: ExecApprovalPromptRequest
|
||||
}
|
||||
|
||||
private struct ExecApprovalSocketDecision: Codable {
|
||||
var type: String
|
||||
var id: String
|
||||
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
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
static func requestDecision(
|
||||
socketPath: String,
|
||||
token: String,
|
||||
request: ExecApprovalPromptRequest,
|
||||
timeoutMs: Int = 15_000) async -> ExecApprovalDecision?
|
||||
{
|
||||
let trimmedPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedPath.isEmpty, !trimmedToken.isEmpty else { return nil }
|
||||
do {
|
||||
return try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: {
|
||||
TimeoutError(message: "exec approvals socket timeout")
|
||||
}, operation: {
|
||||
try await Task.detached {
|
||||
try self.requestDecisionSync(
|
||||
socketPath: trimmedPath,
|
||||
token: trimmedToken,
|
||||
request: request)
|
||||
}.value
|
||||
})
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestDecisionSync(
|
||||
socketPath: String,
|
||||
token: String,
|
||||
request: ExecApprovalPromptRequest) throws -> ExecApprovalDecision?
|
||||
{
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else {
|
||||
throw NSError(domain: "ExecApprovals", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "socket create failed",
|
||||
])
|
||||
}
|
||||
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
||||
if socketPath.utf8.count >= maxLen {
|
||||
throw NSError(domain: "ExecApprovals", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "socket path too long",
|
||||
])
|
||||
}
|
||||
socketPath.withCString { cstr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
||||
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self)
|
||||
strncpy(raw, cstr, maxLen - 1)
|
||||
}
|
||||
}
|
||||
let size = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||
let result = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
|
||||
connect(fd, rebound, size)
|
||||
}
|
||||
}
|
||||
if result != 0 {
|
||||
throw NSError(domain: "ExecApprovals", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "socket connect failed",
|
||||
])
|
||||
}
|
||||
|
||||
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||
|
||||
let message = ExecApprovalSocketRequest(
|
||||
type: "request",
|
||||
token: token,
|
||||
id: UUID().uuidString,
|
||||
request: request)
|
||||
let data = try JSONEncoder().encode(message)
|
||||
var payload = data
|
||||
payload.append(0x0A)
|
||||
try handle.write(contentsOf: payload)
|
||||
|
||||
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||
let lineData = line.data(using: .utf8)
|
||||
else { return nil }
|
||||
let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData)
|
||||
return response.decision
|
||||
}
|
||||
|
||||
private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
|
||||
var buffer = Data()
|
||||
while buffer.count < maxBytes {
|
||||
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
||||
if chunk.isEmpty { break }
|
||||
buffer.append(chunk)
|
||||
if buffer.contains(0x0A) { break }
|
||||
}
|
||||
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
||||
guard !buffer.isEmpty else { return nil }
|
||||
return String(data: buffer, encoding: .utf8)
|
||||
}
|
||||
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ExecApprovalsPromptServer {
|
||||
static let shared = ExecApprovalsPromptServer()
|
||||
|
||||
private var server: ExecApprovalsSocketServer?
|
||||
|
||||
func start() {
|
||||
guard self.server == nil else { return }
|
||||
let approvals = ExecApprovalsStore.resolve(agentId: nil)
|
||||
let server = ExecApprovalsSocketServer(
|
||||
socketPath: approvals.socketPath,
|
||||
token: approvals.token,
|
||||
onPrompt: { request in
|
||||
await ExecApprovalsPromptPresenter.prompt(request)
|
||||
},
|
||||
onExec: { request in
|
||||
await ExecHostExecutor.handle(request)
|
||||
})
|
||||
server.start()
|
||||
self.server = server
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.server?.stop()
|
||||
self.server = nil
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecApprovalsPromptPresenter {
|
||||
@MainActor
|
||||
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Allow this command?"
|
||||
|
||||
var details = "Clawdbot wants to run:\n\n\(request.command)"
|
||||
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedCwd.isEmpty {
|
||||
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
||||
}
|
||||
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedAgent.isEmpty {
|
||||
details += "\n\nAgent:\n\(trimmedAgent)"
|
||||
}
|
||||
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedPath.isEmpty {
|
||||
details += "\n\nExecutable:\n\(trimmedPath)"
|
||||
}
|
||||
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedHost.isEmpty {
|
||||
details += "\n\nHost:\n\(trimmedHost)"
|
||||
}
|
||||
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
|
||||
details += "\n\nSecurity:\n\(security)"
|
||||
}
|
||||
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
|
||||
details += "\nAsk mode:\n\(ask)"
|
||||
}
|
||||
details += "\n\nThis runs on this machine."
|
||||
alert.informativeText = details
|
||||
|
||||
alert.addButton(withTitle: "Allow Once")
|
||||
alert.addButton(withTitle: "Always Allow")
|
||||
alert.addButton(withTitle: "Don't Allow")
|
||||
|
||||
switch alert.runModal() {
|
||||
case .alertFirstButtonReturn:
|
||||
return .allowOnce
|
||||
case .alertSecondButtonReturn:
|
||||
return .allowAlways
|
||||
default:
|
||||
return .deny
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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 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
|
||||
|
||||
init(
|
||||
socketPath: String,
|
||||
token: String,
|
||||
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision,
|
||||
onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse)
|
||||
{
|
||||
self.socketPath = socketPath
|
||||
self.token = token
|
||||
self.onPrompt = onPrompt
|
||||
self.onExec = onExec
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !self.isRunning else { return }
|
||||
self.isRunning = true
|
||||
self.acceptTask = Task.detached { [weak self] in
|
||||
await self?.runAcceptLoop()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
self.isRunning = false
|
||||
self.acceptTask?.cancel()
|
||||
self.acceptTask = nil
|
||||
if self.socketFD >= 0 {
|
||||
close(self.socketFD)
|
||||
self.socketFD = -1
|
||||
}
|
||||
if !self.socketPath.isEmpty {
|
||||
unlink(self.socketPath)
|
||||
}
|
||||
}
|
||||
|
||||
private func runAcceptLoop() async {
|
||||
let fd = self.openSocket()
|
||||
guard fd >= 0 else {
|
||||
self.isRunning = false
|
||||
return
|
||||
}
|
||||
self.socketFD = fd
|
||||
while self.isRunning {
|
||||
var addr = sockaddr_un()
|
||||
var len = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||
let client = withUnsafeMutablePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
|
||||
accept(fd, rebound, &len)
|
||||
}
|
||||
}
|
||||
if client < 0 {
|
||||
if errno == EINTR { continue }
|
||||
break
|
||||
}
|
||||
Task.detached { [weak self] in
|
||||
await self?.handleClient(fd: client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openSocket() -> Int32 {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else {
|
||||
self.logger.error("exec approvals socket create failed")
|
||||
return -1
|
||||
}
|
||||
unlink(self.socketPath)
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
||||
if self.socketPath.utf8.count >= maxLen {
|
||||
self.logger.error("exec approvals socket path too long")
|
||||
close(fd)
|
||||
return -1
|
||||
}
|
||||
self.socketPath.withCString { cstr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
||||
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self)
|
||||
memset(raw, 0, maxLen)
|
||||
strncpy(raw, cstr, maxLen - 1)
|
||||
}
|
||||
}
|
||||
let size = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||
let result = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
|
||||
bind(fd, rebound, size)
|
||||
}
|
||||
}
|
||||
if result != 0 {
|
||||
self.logger.error("exec approvals socket bind failed")
|
||||
close(fd)
|
||||
return -1
|
||||
}
|
||||
if listen(fd, 16) != 0 {
|
||||
self.logger.error("exec approvals socket listen failed")
|
||||
close(fd)
|
||||
return -1
|
||||
}
|
||||
chmod(self.socketPath, 0o600)
|
||||
self.logger.info("exec approvals socket listening at \(self.socketPath, privacy: .public)")
|
||||
return fd
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
|
||||
var buffer = Data()
|
||||
while buffer.count < maxBytes {
|
||||
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
||||
if chunk.isEmpty { break }
|
||||
buffer.append(chunk)
|
||||
if buffer.contains(0x0A) { break }
|
||||
}
|
||||
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
||||
guard !buffer.isEmpty else { return nil }
|
||||
return String(data: buffer, encoding: .utf8)
|
||||
}
|
||||
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,6 +249,13 @@ 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)
|
||||
|
||||
@@ -27,7 +27,11 @@ struct Semver: Comparable, CustomStringConvertible, Sendable {
|
||||
else { return nil }
|
||||
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5")
|
||||
let patchRaw = String(parts[2])
|
||||
let patchNumeric = patchRaw.split { $0 == "-" || $0 == "+" }.first.flatMap { Int($0) } ?? 0
|
||||
guard let patchToken = patchRaw.split(whereSeparator: { $0 == "-" || $0 == "+" }).first,
|
||||
let patchNumeric = Int(patchToken)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return Semver(major: major, minor: minor, patch: patchNumeric)
|
||||
}
|
||||
|
||||
@@ -80,8 +84,13 @@ enum GatewayEnvironment {
|
||||
}
|
||||
|
||||
static func expectedGatewayVersion() -> Semver? {
|
||||
Semver.parse(self.expectedGatewayVersionString())
|
||||
}
|
||||
|
||||
static func expectedGatewayVersionString() -> String? {
|
||||
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||
return Semver.parse(bundleVersion)
|
||||
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (trimmed?.isEmpty == false) ? trimmed : nil
|
||||
}
|
||||
|
||||
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
|
||||
@@ -100,6 +109,7 @@ enum GatewayEnvironment {
|
||||
}
|
||||
}
|
||||
let expected = self.expectedGatewayVersion()
|
||||
let expectedString = self.expectedGatewayVersionString()
|
||||
|
||||
let projectRoot = CommandResolver.projectRoot()
|
||||
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
|
||||
@@ -110,8 +120,8 @@ enum GatewayEnvironment {
|
||||
kind: .missingNode,
|
||||
nodeVersion: nil,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: expected?.description,
|
||||
message: RuntimeLocator.describeFailure(err))
|
||||
requiredGateway: expectedString,
|
||||
message: RuntimeLocator.describeFailure(err))
|
||||
case let .success(runtime):
|
||||
let gatewayBin = CommandResolver.clawdbotExecutable()
|
||||
|
||||
@@ -120,7 +130,7 @@ enum GatewayEnvironment {
|
||||
kind: .missingGateway,
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: nil,
|
||||
requiredGateway: expected?.description,
|
||||
requiredGateway: expectedString,
|
||||
message: "clawdbot CLI not found in PATH; install the CLI.")
|
||||
}
|
||||
|
||||
@@ -128,13 +138,14 @@ enum GatewayEnvironment {
|
||||
?? self.readLocalGatewayVersion(projectRoot: projectRoot)
|
||||
|
||||
if let expected, let installed, !installed.compatible(with: expected) {
|
||||
let expectedText = expectedString ?? expected.description
|
||||
return GatewayEnvironmentStatus(
|
||||
kind: .incompatible(found: installed.description, required: expected.description),
|
||||
kind: .incompatible(found: installed.description, required: expectedText),
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: installed.description,
|
||||
requiredGateway: expected.description,
|
||||
requiredGateway: expectedText,
|
||||
message: """
|
||||
Gateway version \(installed.description) is incompatible with app \(expected.description);
|
||||
Gateway version \(installed.description) is incompatible with app \(expectedText);
|
||||
install or update the global package.
|
||||
""")
|
||||
}
|
||||
@@ -152,7 +163,7 @@ enum GatewayEnvironment {
|
||||
kind: .ok,
|
||||
nodeVersion: runtime.version.description,
|
||||
gatewayVersion: gatewayVersionText,
|
||||
requiredGateway: expected?.description,
|
||||
requiredGateway: expectedString,
|
||||
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
|
||||
}
|
||||
}
|
||||
@@ -220,8 +231,18 @@ enum GatewayEnvironment {
|
||||
}
|
||||
|
||||
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
||||
await self.installGlobal(versionString: version?.description, statusHandler: statusHandler)
|
||||
}
|
||||
|
||||
static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async {
|
||||
let preferred = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let target = version?.description ?? "latest"
|
||||
let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let target: String
|
||||
if let trimmed, !trimmed.isEmpty {
|
||||
target = trimmed
|
||||
} else {
|
||||
target = "latest"
|
||||
}
|
||||
let npm = CommandResolver.findExecutable(named: "npm")
|
||||
let pnpm = CommandResolver.findExecutable(named: "pnpm")
|
||||
let bun = CommandResolver.findExecutable(named: "bun")
|
||||
|
||||
@@ -16,6 +16,10 @@ 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
|
||||
@@ -112,7 +116,9 @@ extension GatewayLaunchAgentManager {
|
||||
{
|
||||
let command = CommandResolver.clawdbotCommand(
|
||||
subcommand: "daemon",
|
||||
extraArgs: self.withJsonFlag(args))
|
||||
extraArgs: self.withJsonFlag(args),
|
||||
// Launchd management must always run locally, even if remote mode is configured.
|
||||
configRoot: ["gateway": ["mode": "local"]])
|
||||
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,6 +114,9 @@ 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(
|
||||
|
||||
@@ -83,27 +83,7 @@ struct GeneralSettings: View {
|
||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||
binding: self.$cameraEnabled)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Node Run Commands")
|
||||
.font(.body)
|
||||
|
||||
Picker("", selection: self.$state.systemRunPolicy) {
|
||||
ForEach(SystemRunPolicy.allCases) { policy in
|
||||
Text(policy.title).tag(policy)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Text("""
|
||||
Controls remote command execution on this Mac when it is paired as a node. \
|
||||
"Always Ask" prompts on each command; "Always Allow" runs without prompts; \
|
||||
"Never" disables `system.run`.
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
SystemRunSettingsView()
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Location Access")
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum MacNodeConfigFile {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "mac-node-config")
|
||||
|
||||
static func url() -> URL {
|
||||
ClawdbotPaths.stateDirURL.appendingPathComponent("macos-node.json")
|
||||
}
|
||||
|
||||
static func loadDict() -> [String: Any] {
|
||||
let url = self.url()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
self.logger.warning("mac node config JSON root invalid")
|
||||
return [:]
|
||||
}
|
||||
return root
|
||||
} catch {
|
||||
self.logger.warning("mac node config read failed: \(error.localizedDescription, privacy: .public)")
|
||||
return [:]
|
||||
}
|
||||
}
|
||||
|
||||
static func saveDict(_ dict: [String: Any]) {
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||
let url = self.url()
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
} catch {
|
||||
self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
static func systemRunPolicy() -> SystemRunPolicy? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = root["systemRun"] as? [String: Any]
|
||||
let raw = systemRun?["policy"] as? String
|
||||
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
|
||||
return policy
|
||||
}
|
||||
|
||||
static func setSystemRunPolicy(_ policy: SystemRunPolicy) {
|
||||
var root = self.loadDict()
|
||||
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
||||
systemRun["policy"] = policy.rawValue
|
||||
root["systemRun"] = systemRun
|
||||
self.saveDict(root)
|
||||
}
|
||||
|
||||
static func systemRunAllowlist() -> [String]? {
|
||||
let root = self.loadDict()
|
||||
let systemRun = root["systemRun"] as? [String: Any]
|
||||
return systemRun?["allowlist"] as? [String]
|
||||
}
|
||||
|
||||
static func setSystemRunAllowlist(_ allowlist: [String]) {
|
||||
let cleaned = allowlist
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
var root = self.loadDict()
|
||||
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
||||
if cleaned.isEmpty {
|
||||
systemRun.removeValue(forKey: "allowlist")
|
||||
} else {
|
||||
systemRun["allowlist"] = cleaned
|
||||
}
|
||||
if systemRun.isEmpty {
|
||||
root.removeValue(forKey: "systemRun")
|
||||
} else {
|
||||
root["systemRun"] = systemRun
|
||||
}
|
||||
self.saveDict(root)
|
||||
}
|
||||
}
|
||||
@@ -256,6 +256,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
TerminationSignalWatcher.shared.start()
|
||||
NodePairingApprovalPrompter.shared.start()
|
||||
ExecApprovalsPromptServer.shared.start()
|
||||
MacNodeModeCoordinator.shared.start()
|
||||
VoiceWakeGlobalSettingsSync.shared.start()
|
||||
Task { PresenceReporter.shared.start() }
|
||||
@@ -280,6 +281,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
PresenceReporter.shared.stop()
|
||||
NodePairingApprovalPrompter.shared.stop()
|
||||
ExecApprovalsPromptServer.shared.stop()
|
||||
MacNodeModeCoordinator.shared.stop()
|
||||
TerminationSignalWatcher.shared.stop()
|
||||
VoiceWakeGlobalSettingsSync.shared.stop()
|
||||
|
||||
@@ -31,10 +31,10 @@ struct MenuContent: View {
|
||||
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
||||
}
|
||||
|
||||
private var systemRunPolicyBinding: Binding<SystemRunPolicy> {
|
||||
private var execApprovalModeBinding: Binding<ExecApprovalQuickMode> {
|
||||
Binding(
|
||||
get: { self.state.systemRunPolicy },
|
||||
set: { self.state.systemRunPolicy = $0 })
|
||||
get: { self.state.execApprovalMode },
|
||||
set: { self.state.execApprovalMode = $0 })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -74,12 +74,12 @@ struct MenuContent: View {
|
||||
Toggle(isOn: self.$cameraEnabled) {
|
||||
Label("Allow Camera", systemImage: "camera")
|
||||
}
|
||||
Picker(selection: self.systemRunPolicyBinding) {
|
||||
ForEach(SystemRunPolicy.allCases) { policy in
|
||||
Text(policy.title).tag(policy)
|
||||
Picker(selection: self.execApprovalModeBinding) {
|
||||
ForEach(ExecApprovalQuickMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
} label: {
|
||||
Label("Node Run Commands", systemImage: "terminal")
|
||||
Label("Exec Approvals", systemImage: "terminal")
|
||||
}
|
||||
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
||||
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
||||
|
||||
@@ -14,6 +14,7 @@ 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?
|
||||
@@ -87,6 +88,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
self.menuOpenWidth = nil
|
||||
self.loadTask?.cancel()
|
||||
self.nodesLoadTask?.cancel()
|
||||
self.cancelPreviewTasks()
|
||||
}
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
@@ -107,6 +109,7 @@ 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)
|
||||
@@ -280,9 +283,7 @@ extension MenuSessionsInjector {
|
||||
|
||||
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
||||
let rows = self.usageRows
|
||||
let errorText = self.cachedUsageErrorText
|
||||
|
||||
if rows.isEmpty, errorText == nil {
|
||||
if rows.isEmpty {
|
||||
return cursor
|
||||
}
|
||||
|
||||
@@ -306,25 +307,6 @@ 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
|
||||
@@ -440,6 +422,8 @@ extension MenuSessionsInjector {
|
||||
displayName: "Gateway",
|
||||
platform: platform,
|
||||
version: nil,
|
||||
coreVersion: nil,
|
||||
uiVersion: nil,
|
||||
deviceFamily: nil,
|
||||
modelIdentifier: nil,
|
||||
remoteIp: host,
|
||||
@@ -473,15 +457,46 @@ extension MenuSessionsInjector {
|
||||
item.tag = self.tag
|
||||
item.isEnabled = false
|
||||
let view = AnyView(SessionMenuPreviewView(
|
||||
sessionKey: sessionKey,
|
||||
width: width,
|
||||
maxItems: 10,
|
||||
maxLines: maxLines,
|
||||
title: title))
|
||||
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
||||
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)
|
||||
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) {
|
||||
@@ -559,14 +574,11 @@ extension MenuSessionsInjector {
|
||||
|
||||
do {
|
||||
self.cachedUsageSummary = try await UsageLoader.loadSummary()
|
||||
self.cachedUsageErrorText = nil
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
} catch {
|
||||
if self.cachedUsageSummary == nil {
|
||||
self.cachedUsageErrorText = self.compactUsageError(error)
|
||||
}
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
self.cachedUsageSummary = nil
|
||||
self.cachedUsageErrorText = nil
|
||||
}
|
||||
self.usageCacheUpdatedAt = Date()
|
||||
}
|
||||
|
||||
private func compactUsageError(_ error: Error) -> String {
|
||||
@@ -747,8 +759,8 @@ extension MenuSessionsInjector {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
||||
}
|
||||
|
||||
if let version = entry.version?.nonEmpty {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version)))
|
||||
if let version = NodeMenuEntryFormatter.detailRightVersion(entry)?.nonEmpty {
|
||||
menu.addItem(self.makeNodeCopyItem(label: "Version", value: version))
|
||||
}
|
||||
|
||||
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
|
||||
|
||||
@@ -95,6 +95,8 @@ 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,
|
||||
|
||||
@@ -43,7 +43,6 @@ final class MacNodeModeCoordinator {
|
||||
private func run() async {
|
||||
var retryDelay: UInt64 = 1_000_000_000
|
||||
var lastCameraEnabled: Bool?
|
||||
var lastSystemRunPolicy: SystemRunPolicy?
|
||||
let defaults = UserDefaults.standard
|
||||
while !Task.isCancelled {
|
||||
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
||||
@@ -60,15 +59,6 @@ final class MacNodeModeCoordinator {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
let systemRunPolicy = SystemRunPolicy.load()
|
||||
if lastSystemRunPolicy == nil {
|
||||
lastSystemRunPolicy = systemRunPolicy
|
||||
} else if lastSystemRunPolicy != systemRunPolicy {
|
||||
lastSystemRunPolicy = systemRunPolicy
|
||||
await self.session.disconnect()
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
||||
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||
@@ -89,8 +79,13 @@ final class MacNodeModeCoordinator {
|
||||
if let mainSessionKey {
|
||||
await self?.runtime.updateMainSessionKey(mainSessionKey)
|
||||
}
|
||||
await self?.runtime.setEventSender { [weak self] event, payload in
|
||||
guard let self else { return }
|
||||
try? await self.session.sendEvent(event: event, payloadJSON: payload)
|
||||
}
|
||||
},
|
||||
onDisconnected: { reason in
|
||||
onDisconnected: { [weak self] reason in
|
||||
await self?.runtime.setEventSender(nil)
|
||||
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
|
||||
},
|
||||
onInvoke: { [weak self] req in
|
||||
@@ -119,12 +114,19 @@ 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: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
|
||||
version: uiVersion,
|
||||
coreVersion: coreVersion?.isEmpty == false ? coreVersion : nil,
|
||||
uiVersion: uiVersion,
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: InstanceIdentity.modelIdentifier,
|
||||
caps: caps,
|
||||
@@ -161,13 +163,12 @@ final class MacNodeModeCoordinator {
|
||||
ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||
MacNodeScreenCommand.record.rawValue,
|
||||
ClawdbotSystemCommand.notify.rawValue,
|
||||
ClawdbotSystemCommand.which.rawValue,
|
||||
ClawdbotSystemCommand.run.rawValue,
|
||||
ClawdbotSystemCommand.execApprovalsGet.rawValue,
|
||||
ClawdbotSystemCommand.execApprovalsSet.rawValue,
|
||||
]
|
||||
|
||||
if SystemRunPolicy.load() != .never {
|
||||
commands.append(ClawdbotSystemCommand.which.rawValue)
|
||||
commands.append(ClawdbotSystemCommand.run.rawValue)
|
||||
}
|
||||
|
||||
let capsSet = Set(caps)
|
||||
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
|
||||
commands.append(ClawdbotCameraCommand.list.rawValue)
|
||||
|
||||
@@ -8,6 +8,7 @@ actor MacNodeRuntime {
|
||||
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
|
||||
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
|
||||
private var mainSessionKey: String = "main"
|
||||
private var eventSender: (@Sendable (String, String?) async -> Void)?
|
||||
|
||||
init(
|
||||
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
|
||||
@@ -23,6 +24,10 @@ actor MacNodeRuntime {
|
||||
self.mainSessionKey = trimmed
|
||||
}
|
||||
|
||||
func setEventSender(_ sender: (@Sendable (String, String?) async -> Void)?) {
|
||||
self.eventSender = sender
|
||||
}
|
||||
|
||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
let command = req.command
|
||||
if self.isCanvasCommand(command), !Self.canvasEnabled() {
|
||||
@@ -59,6 +64,10 @@ 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")
|
||||
}
|
||||
@@ -427,42 +436,133 @@ 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 wasAllowlisted = SystemRunAllowlist.contains(command)
|
||||
switch Self.systemRunPolicy() {
|
||||
case .never:
|
||||
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
||||
let approvals = ExecApprovalsStore.resolve(agentId: agentId)
|
||||
let security = approvals.agent.security
|
||||
let ask = approvals.agent.ask
|
||||
let autoAllowSkills = approvals.agent.autoAllowSkills
|
||||
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||
? 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 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 {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "security=deny"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DISABLED: policy=never")
|
||||
case .always:
|
||||
break
|
||||
case .ask:
|
||||
if !wasAllowlisted {
|
||||
let services = await self.mainActorServices()
|
||||
let decision = await services.confirmSystemRun(
|
||||
command: SystemRunAllowlist.displayString(for: command),
|
||||
cwd: params.cwd)
|
||||
switch decision {
|
||||
case .allowOnce:
|
||||
break
|
||||
case .allowAlways:
|
||||
SystemRunAllowlist.add(command)
|
||||
case .deny:
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied")
|
||||
message: "SYSTEM_RUN_DISABLED: 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 = await ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: displayCommand,
|
||||
cwd: params.cwd,
|
||||
host: "node",
|
||||
security: security.rawValue,
|
||||
ask: ask.rawValue,
|
||||
agentId: agentId,
|
||||
resolvedPath: resolution?.resolvedPath))
|
||||
|
||||
switch decision {
|
||||
case .deny:
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "user-denied"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied")
|
||||
case .allowAlways:
|
||||
approvedByAsk = true
|
||||
if security == .allowlist {
|
||||
let pattern = resolution?.resolvedPath ??
|
||||
resolution?.rawExecutable ??
|
||||
command.first?.trimmingCharacters(in: .whitespacesAndNewlines) ??
|
||||
""
|
||||
if !pattern.isEmpty {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||
}
|
||||
}
|
||||
case .allowOnce:
|
||||
approvedByAsk = true
|
||||
}
|
||||
}
|
||||
|
||||
let env = Self.sanitizedEnv(params.env)
|
||||
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,
|
||||
resolvedPath: resolution?.resolvedPath)
|
||||
}
|
||||
|
||||
if params.needsScreenRecording == true {
|
||||
let authorized = await PermissionManager
|
||||
.status([.screenRecording])[.screenRecording] ?? false
|
||||
if !authorized {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "permission:screenRecording"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
@@ -471,11 +571,33 @@ actor MacNodeRuntime {
|
||||
}
|
||||
|
||||
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||
await self.emitExecEvent(
|
||||
"exec.started",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand))
|
||||
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")
|
||||
await self.emitExecEvent(
|
||||
"exec.finished",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
output: ExecEventPayload.truncateOutput(combined)))
|
||||
|
||||
struct RunPayload: Encodable {
|
||||
var exitCode: Int?
|
||||
@@ -523,6 +645,82 @@ 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),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else {
|
||||
return
|
||||
}
|
||||
await sender(event, json)
|
||||
}
|
||||
|
||||
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -589,10 +787,6 @@ actor MacNodeRuntime {
|
||||
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||
}
|
||||
|
||||
private nonisolated static func systemRunPolicy() -> SystemRunPolicy {
|
||||
SystemRunPolicy.load()
|
||||
}
|
||||
|
||||
private static let blockedEnvKeys: Set<String> = [
|
||||
"PATH",
|
||||
"NODE_OPTIONS",
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import AppKit
|
||||
import ClawdbotKit
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
|
||||
enum SystemRunDecision: Sendable {
|
||||
case allowOnce
|
||||
case allowAlways
|
||||
case deny
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||
func recordScreen(
|
||||
@@ -24,8 +17,6 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
|
||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -67,30 +58,4 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
|
||||
timeoutMs: timeoutMs)
|
||||
}
|
||||
|
||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
||||
let alert = NSAlert()
|
||||
alert.alertStyle = .warning
|
||||
alert.messageText = "Allow this command?"
|
||||
|
||||
var details = "Clawdbot wants to run:\n\n\(command)"
|
||||
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !trimmedCwd.isEmpty {
|
||||
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
||||
}
|
||||
details += "\n\nThis runs on this Mac via node mode."
|
||||
alert.informativeText = details
|
||||
|
||||
alert.addButton(withTitle: "Allow Once")
|
||||
alert.addButton(withTitle: "Always Allow")
|
||||
alert.addButton(withTitle: "Don't Allow")
|
||||
|
||||
switch alert.runModal() {
|
||||
case .alertFirstButtonReturn:
|
||||
return .allowOnce
|
||||
case .alertSecondButtonReturn:
|
||||
return .allowAlways
|
||||
default:
|
||||
return .deny
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
150
apps/macos/Sources/Clawdbot/NodeServiceManager.swift
Normal file
150
apps/macos/Sources/Clawdbot/NodeServiceManager.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
enum NodeServiceManager {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "node.service")
|
||||
|
||||
static func start() async -> String? {
|
||||
let result = await self.runServiceCommandResult(
|
||||
["node", "start"],
|
||||
timeout: 20,
|
||||
quiet: false)
|
||||
if let error = self.errorMessage(from: result, treatNotLoadedAsError: true) {
|
||||
self.logger.error("node service start failed: \(error, privacy: .public)")
|
||||
return error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func stop() async -> String? {
|
||||
let result = await self.runServiceCommandResult(
|
||||
["node", "stop"],
|
||||
timeout: 15,
|
||||
quiet: false)
|
||||
if let error = self.errorMessage(from: result, treatNotLoadedAsError: false) {
|
||||
self.logger.error("node service stop failed: \(error, privacy: .public)")
|
||||
return error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeServiceManager {
|
||||
private struct CommandResult {
|
||||
let success: Bool
|
||||
let payload: Data?
|
||||
let message: String?
|
||||
let parsed: ParsedServiceJson?
|
||||
}
|
||||
|
||||
private struct ParsedServiceJson {
|
||||
let text: String
|
||||
let object: [String: Any]
|
||||
let ok: Bool?
|
||||
let result: String?
|
||||
let message: String?
|
||||
let error: String?
|
||||
let hints: [String]
|
||||
}
|
||||
|
||||
private static func runServiceCommandResult(
|
||||
_ args: [String],
|
||||
timeout: Double,
|
||||
quiet: Bool) async -> CommandResult
|
||||
{
|
||||
let command = CommandResolver.clawdbotCommand(
|
||||
subcommand: "service",
|
||||
extraArgs: self.withJsonFlag(args),
|
||||
// Service management must always run locally, even if remote mode is configured.
|
||||
configRoot: ["gateway": ["mode": "local"]])
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||
let parsed = self.parseServiceJson(from: response.stdout) ?? self.parseServiceJson(from: response.stderr)
|
||||
let ok = parsed?.ok
|
||||
let message = parsed?.error ?? parsed?.message
|
||||
let payload = parsed?.text.data(using: .utf8)
|
||||
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
|
||||
let success = ok ?? response.success
|
||||
if success {
|
||||
return CommandResult(success: true, payload: payload, message: nil, parsed: parsed)
|
||||
}
|
||||
|
||||
if quiet {
|
||||
return CommandResult(success: false, payload: payload, message: message, parsed: parsed)
|
||||
}
|
||||
|
||||
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
|
||||
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
||||
let fullMessage = detail.map { "Node service command failed (\(exit)): \($0)" }
|
||||
?? "Node service command failed (\(exit))"
|
||||
self.logger.error("\(fullMessage, privacy: .public)")
|
||||
return CommandResult(success: false, payload: payload, message: detail, parsed: parsed)
|
||||
}
|
||||
|
||||
private static func errorMessage(from result: CommandResult, treatNotLoadedAsError: Bool) -> String? {
|
||||
if !result.success {
|
||||
return result.message ?? "Node service command failed"
|
||||
}
|
||||
guard let parsed = result.parsed else { return nil }
|
||||
if parsed.ok == false {
|
||||
return self.mergeHints(message: parsed.error ?? parsed.message, hints: parsed.hints)
|
||||
}
|
||||
if treatNotLoadedAsError, parsed.result == "not-loaded" {
|
||||
let base = parsed.message ?? "Node service not loaded."
|
||||
return self.mergeHints(message: base, hints: parsed.hints)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func withJsonFlag(_ args: [String]) -> [String] {
|
||||
if args.contains("--json") { return args }
|
||||
return args + ["--json"]
|
||||
}
|
||||
|
||||
private static func parseServiceJson(from raw: String) -> ParsedServiceJson? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let start = trimmed.firstIndex(of: "{"),
|
||||
let end = trimmed.lastIndex(of: "}")
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let jsonText = String(trimmed[start...end])
|
||||
guard let data = jsonText.data(using: .utf8) else { return nil }
|
||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
let ok = object["ok"] as? Bool
|
||||
let result = object["result"] as? String
|
||||
let message = object["message"] as? String
|
||||
let error = object["error"] as? String
|
||||
let hints = (object["hints"] as? [String]) ?? []
|
||||
return ParsedServiceJson(
|
||||
text: jsonText,
|
||||
object: object,
|
||||
ok: ok,
|
||||
result: result,
|
||||
message: message,
|
||||
error: error,
|
||||
hints: hints)
|
||||
}
|
||||
|
||||
private static func mergeHints(message: String?, hints: [String]) -> String? {
|
||||
let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let nonEmpty = trimmed?.isEmpty == false ? trimmed : nil
|
||||
guard !hints.isEmpty else { return nonEmpty }
|
||||
let hintText = hints.prefix(2).joined(separator: " · ")
|
||||
if let nonEmpty {
|
||||
return "\(nonEmpty) (\(hintText))"
|
||||
}
|
||||
return hintText
|
||||
}
|
||||
|
||||
private static func summarize(_ text: String) -> String? {
|
||||
let lines = text
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard let last = lines.last else { return nil }
|
||||
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,9 @@ struct NodeMenuEntryFormatter {
|
||||
if let platform = self.platformText(entry) {
|
||||
parts.append("platform \(platform)")
|
||||
}
|
||||
if let version = entry.version?.nonEmpty {
|
||||
parts.append("app \(self.compactVersion(version))")
|
||||
let versionLabels = self.versionLabels(entry)
|
||||
if !versionLabels.isEmpty {
|
||||
parts.append(versionLabels.joined(separator: " · "))
|
||||
}
|
||||
parts.append("status \(self.roleText(entry))")
|
||||
return parts.joined(separator: " · ")
|
||||
@@ -60,8 +61,9 @@ struct NodeMenuEntryFormatter {
|
||||
}
|
||||
|
||||
static func detailRightVersion(_ entry: NodeInfo) -> String? {
|
||||
guard let version = entry.version?.nonEmpty else { return nil }
|
||||
return self.shortVersionLabel(version)
|
||||
let labels = self.versionLabels(entry, compact: false)
|
||||
if labels.isEmpty { return nil }
|
||||
return labels.joined(separator: " · ")
|
||||
}
|
||||
|
||||
static func platformText(_ entry: NodeInfo) -> String? {
|
||||
@@ -127,6 +129,39 @@ 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,6 +7,8 @@ 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?
|
||||
|
||||
@@ -155,7 +155,7 @@ struct OnboardingView: View {
|
||||
|
||||
var canAdvance: Bool { !self.isWizardBlocking }
|
||||
var devLinkCommand: String {
|
||||
let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
||||
let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest"
|
||||
return "npm install -g clawdbot@\(version)"
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ import ClawdbotKit
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
private struct SessionPreviewItem: Identifiable, Sendable {
|
||||
struct SessionPreviewItem: Identifiable, Sendable {
|
||||
let id: String
|
||||
let role: PreviewRole
|
||||
let text: String
|
||||
}
|
||||
|
||||
private enum PreviewRole: String, Sendable {
|
||||
enum PreviewRole: String, Sendable {
|
||||
case user
|
||||
case assistant
|
||||
case tool
|
||||
@@ -27,7 +27,7 @@ private enum PreviewRole: String, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private actor SessionPreviewCache {
|
||||
actor SessionPreviewCache {
|
||||
static let shared = SessionPreviewCache()
|
||||
|
||||
private struct CacheEntry {
|
||||
@@ -52,25 +52,33 @@ private actor SessionPreviewCache {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
@State private var items: [SessionPreviewItem] = []
|
||||
@State private var status: LoadStatus = .loading
|
||||
|
||||
private struct PreviewTimeoutError: LocalizedError {
|
||||
var errorDescription: String? { "preview timeout" }
|
||||
#if DEBUG
|
||||
extension SessionPreviewCache {
|
||||
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
|
||||
}
|
||||
|
||||
private enum LoadStatus: Equatable {
|
||||
func _testReset() {
|
||||
self.entries = [:]
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
struct SessionMenuPreviewSnapshot: Sendable {
|
||||
let items: [SessionPreviewItem]
|
||||
let status: SessionMenuPreviewView.LoadStatus
|
||||
}
|
||||
|
||||
struct SessionMenuPreviewView: View {
|
||||
let width: CGFloat
|
||||
let maxLines: Int
|
||||
let title: String
|
||||
let items: [SessionPreviewItem]
|
||||
let status: LoadStatus
|
||||
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
|
||||
enum LoadStatus: Equatable {
|
||||
case loading
|
||||
case ready
|
||||
case empty
|
||||
@@ -78,15 +86,17 @@ struct SessionMenuPreviewView: View {
|
||||
}
|
||||
|
||||
private var primaryColor: Color {
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
||||
if self.isHighlighted {
|
||||
return Color(nsColor: .selectedMenuItemTextColor)
|
||||
}
|
||||
return Color(nsColor: .labelColor)
|
||||
}
|
||||
|
||||
private var secondaryColor: Color {
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
||||
}
|
||||
|
||||
private var previewLimit: Int {
|
||||
min(max(self.maxItems * 3, 20), 120)
|
||||
if self.isHighlighted {
|
||||
return Color(nsColor: .selectedMenuItemTextColor).opacity(0.85)
|
||||
}
|
||||
return Color(nsColor: .secondaryLabelColor)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -100,21 +110,19 @@ struct SessionMenuPreviewView: View {
|
||||
|
||||
switch self.status {
|
||||
case .loading:
|
||||
Text("Loading preview…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
self.placeholder("Loading preview…")
|
||||
case .empty:
|
||||
Text("No recent messages")
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
self.placeholder("No recent messages")
|
||||
case let .error(message):
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryColor)
|
||||
self.placeholder(message)
|
||||
case .ready:
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.items) { item in
|
||||
self.previewRow(item)
|
||||
if self.items.isEmpty {
|
||||
self.placeholder("No recent messages")
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.items) { item in
|
||||
self.previewRow(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,9 +131,6 @@ 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
|
||||
@@ -157,55 +162,66 @@ struct SessionMenuPreviewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@ViewBuilder
|
||||
private func placeholder(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.primaryColor)
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.status = .loading
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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: self.sessionKey,
|
||||
limit: self.previewLimit,
|
||||
sessionKey: sessionKey,
|
||||
limit: self.previewLimit(for: maxItems),
|
||||
timeoutMs: timeoutMs)
|
||||
})
|
||||
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
|
||||
}
|
||||
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
||||
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
||||
return Self.snapshot(from: built)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
||||
} catch {
|
||||
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 fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
|
||||
if let fallback {
|
||||
return Self.snapshot(from: fallback)
|
||||
}
|
||||
let errorDescription = String(describing: error)
|
||||
Self.logger.warning(
|
||||
"Session preview failed session=\(self.sessionKey, privacy: .public) " +
|
||||
"Session preview failed session=\(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]
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
||||
case never
|
||||
case ask
|
||||
case always
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .never:
|
||||
"Never"
|
||||
case .ask:
|
||||
"Always Ask"
|
||||
case .always:
|
||||
"Always Allow"
|
||||
}
|
||||
}
|
||||
|
||||
static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy {
|
||||
if let policy = MacNodeConfigFile.systemRunPolicy() {
|
||||
return policy
|
||||
}
|
||||
if let raw = defaults.string(forKey: systemRunPolicyKey),
|
||||
let policy = SystemRunPolicy(rawValue: raw)
|
||||
{
|
||||
MacNodeConfigFile.setSystemRunPolicy(policy)
|
||||
return policy
|
||||
}
|
||||
if let legacy = defaults.object(forKey: systemRunEnabledKey) as? Bool {
|
||||
let policy: SystemRunPolicy = legacy ? .ask : .never
|
||||
MacNodeConfigFile.setSystemRunPolicy(policy)
|
||||
return policy
|
||||
}
|
||||
let fallback: SystemRunPolicy = .ask
|
||||
MacNodeConfigFile.setSystemRunPolicy(fallback)
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
enum SystemRunAllowlist {
|
||||
static func key(for argv: [String]) -> String {
|
||||
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
guard !trimmed.isEmpty else { return "" }
|
||||
if let data = try? JSONEncoder().encode(trimmed),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
{
|
||||
return json
|
||||
}
|
||||
return trimmed.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func displayString(for argv: [String]) -> String {
|
||||
argv.map { arg in
|
||||
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "\"\"" }
|
||||
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
|
||||
if !needsQuotes { return trimmed }
|
||||
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
return "\"\(escaped)\""
|
||||
}.joined(separator: " ")
|
||||
}
|
||||
|
||||
static func load(from defaults: UserDefaults = .standard) -> Set<String> {
|
||||
if let allowlist = MacNodeConfigFile.systemRunAllowlist() {
|
||||
return Set(allowlist)
|
||||
}
|
||||
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
|
||||
MacNodeConfigFile.setSystemRunAllowlist(legacy)
|
||||
return Set(legacy)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
|
||||
let key = key(for: argv)
|
||||
return self.load(from: defaults).contains(key)
|
||||
}
|
||||
|
||||
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
|
||||
let key = key(for: argv)
|
||||
guard !key.isEmpty else { return }
|
||||
var allowlist = self.load(from: defaults)
|
||||
if allowlist.insert(key).inserted {
|
||||
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
|
||||
}
|
||||
}
|
||||
}
|
||||
401
apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift
Normal file
401
apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift
Normal file
@@ -0,0 +1,401 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
struct SystemRunSettingsView: View {
|
||||
@State private var model = ExecApprovalsSettingsModel()
|
||||
@State private var tab: ExecApprovalsSettingsTab = .policy
|
||||
@State private var newPattern: String = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 180, alignment: .trailing)
|
||||
}
|
||||
|
||||
Picker("", selection: self.$tab) {
|
||||
ForEach(ExecApprovalsSettingsTab.allCases) { tab in
|
||||
Text(tab.title).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 320)
|
||||
|
||||
if self.tab == .policy {
|
||||
self.policyView
|
||||
} else {
|
||||
self.allowlistView
|
||||
}
|
||||
}
|
||||
.task { await self.model.refresh() }
|
||||
.onChange(of: self.tab) { _, _ in
|
||||
Task { await self.model.refreshSkillBins() }
|
||||
}
|
||||
}
|
||||
|
||||
private var policyView: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Picker("", selection: Binding(
|
||||
get: { self.model.security },
|
||||
set: { self.model.setSecurity($0) }))
|
||||
{
|
||||
ForEach(ExecSecurity.allCases) { security in
|
||||
Text(security.title).tag(security)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Picker("", selection: Binding(
|
||||
get: { self.model.ask },
|
||||
set: { self.model.setAsk($0) }))
|
||||
{
|
||||
ForEach(ExecAsk.allCases) { ask in
|
||||
Text(ask.title).tag(ask)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.menu)
|
||||
|
||||
Picker("", selection: Binding(
|
||||
get: { self.model.askFallback },
|
||||
set: { self.model.setAskFallback($0) }))
|
||||
{
|
||||
ForEach(ExecSecurity.allCases) { mode in
|
||||
Text("Fallback: \(mode.title)").tag(mode)
|
||||
}
|
||||
}
|
||||
.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.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.tertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private var allowlistView: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Toggle("Auto-allow skill CLIs", isOn: Binding(
|
||||
get: { self.model.autoAllowSkills },
|
||||
set: { self.model.setAutoAllowSkills($0) }))
|
||||
|
||||
if self.model.autoAllowSkills, !self.model.skillBins.isEmpty {
|
||||
Text("Skill CLIs: \(self.model.skillBins.joined(separator: ", "))")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if self.model.isDefaultsScope {
|
||||
Text("Allowlists are per-agent. Select an agent to edit its allowlist.")
|
||||
.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) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ExecApprovalsSettingsTab: String, CaseIterable, Identifiable {
|
||||
case policy
|
||||
case allowlist
|
||||
|
||||
var id: String { self.rawValue }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .policy: "Access"
|
||||
case .allowlist: "Allowlist"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ExecAllowlistRow: View {
|
||||
@Binding var entry: ExecAllowlistEntry
|
||||
let onRemove: () -> Void
|
||||
@State private var draftPattern: String = ""
|
||||
|
||||
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Pattern", text: self.patternBinding)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
|
||||
Button(role: .destructive) {
|
||||
self.onRemove()
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
if let lastUsedAt = self.entry.lastUsedAt {
|
||||
let date = Date(timeIntervalSince1970: lastUsedAt / 1000.0)
|
||||
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)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.draftPattern = self.entry.pattern
|
||||
}
|
||||
}
|
||||
|
||||
private var patternBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { self.draftPattern.isEmpty ? self.entry.pattern : self.draftPattern },
|
||||
set: { newValue in
|
||||
self.draftPattern = newValue
|
||||
self.entry.pattern = newValue
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ExecApprovalsSettingsModel {
|
||||
private static let defaultsScopeId = "__defaults__"
|
||||
var agentIds: [String] = []
|
||||
var selectedAgentId: String = "main"
|
||||
var defaultAgentId: String = "main"
|
||||
var security: ExecSecurity = .deny
|
||||
var ask: ExecAsk = .onMiss
|
||||
var askFallback: ExecSecurity = .deny
|
||||
var autoAllowSkills = false
|
||||
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)
|
||||
await self.refreshSkillBins()
|
||||
}
|
||||
|
||||
func refreshAgents() async {
|
||||
let root = await ConfigStore.load()
|
||||
let agents = root["agents"] as? [String: Any]
|
||||
let list = agents?["list"] as? [[String: Any]] ?? []
|
||||
var ids: [String] = []
|
||||
var seen = Set<String>()
|
||||
var defaultId: String?
|
||||
for entry in list {
|
||||
guard let raw = entry["id"] as? String else { continue }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { continue }
|
||||
if !seen.insert(trimmed).inserted { continue }
|
||||
ids.append(trimmed)
|
||||
if (entry["default"] as? Bool) == true, defaultId == nil {
|
||||
defaultId = trimmed
|
||||
}
|
||||
}
|
||||
if ids.isEmpty {
|
||||
ids = ["main"]
|
||||
defaultId = "main"
|
||||
} else if defaultId == nil {
|
||||
defaultId = ids.first
|
||||
}
|
||||
self.agentIds = ids
|
||||
self.defaultAgentId = defaultId ?? "main"
|
||||
if self.selectedAgentId == Self.defaultsScopeId {
|
||||
return
|
||||
}
|
||||
if !self.agentIds.contains(self.selectedAgentId) {
|
||||
self.selectedAgentId = self.defaultAgentId
|
||||
}
|
||||
}
|
||||
|
||||
func selectAgent(_ id: String) {
|
||||
self.selectedAgentId = id
|
||||
self.loadSettings(for: id)
|
||||
Task { await self.refreshSkillBins() }
|
||||
}
|
||||
|
||||
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
|
||||
self.askFallback = resolved.agent.askFallback
|
||||
self.autoAllowSkills = resolved.agent.autoAllowSkills
|
||||
self.entries = resolved.allowlist
|
||||
.sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
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))
|
||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func refreshSkillBins(force: Bool = false) async {
|
||||
guard self.autoAllowSkills else {
|
||||
self.skillBins = []
|
||||
return
|
||||
}
|
||||
let bins = await SkillBinsCache.shared.currentBins(force: force)
|
||||
self.skillBins = bins.sorted()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,18 +75,6 @@ struct UsageRow: Identifiable {
|
||||
extension GatewayUsageSummary {
|
||||
func primaryRows() -> [UsageRow] {
|
||||
self.providers.compactMap { provider in
|
||||
if let error = provider.error, provider.windows.isEmpty {
|
||||
return UsageRow(
|
||||
id: provider.provider,
|
||||
providerId: provider.provider,
|
||||
displayName: provider.displayName,
|
||||
plan: provider.plan,
|
||||
windowLabel: nil,
|
||||
usedPercent: nil,
|
||||
resetAt: nil,
|
||||
error: error)
|
||||
}
|
||||
|
||||
guard let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) else {
|
||||
return nil
|
||||
}
|
||||
@@ -99,7 +87,7 @@ extension GatewayUsageSummary {
|
||||
windowLabel: window.label,
|
||||
usedPercent: window.usedPercent,
|
||||
resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) },
|
||||
error: provider.error)
|
||||
error: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,14 +37,12 @@ struct UsageMenuLabelView: View {
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
if !self.row.hasError {
|
||||
Text(self.row.detailText())
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.layoutPriority(2)
|
||||
}
|
||||
Text(self.row.detailText())
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.layoutPriority(2)
|
||||
|
||||
if self.showsChevron {
|
||||
Image(systemName: "chevron.right")
|
||||
@@ -54,15 +52,6 @@ struct UsageMenuLabelView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if let error = self.row.error?.nonEmpty {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(self.secondaryTextColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(2)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.padding(.leading, self.paddingLeading)
|
||||
|
||||
@@ -530,6 +530,8 @@ 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]?
|
||||
@@ -542,6 +544,8 @@ public struct NodePairRequestParams: Codable, Sendable {
|
||||
displayname: String?,
|
||||
platform: String?,
|
||||
version: String?,
|
||||
coreversion: String?,
|
||||
uiversion: String?,
|
||||
devicefamily: String?,
|
||||
modelidentifier: String?,
|
||||
caps: [String]?,
|
||||
@@ -553,6 +557,8 @@ 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
|
||||
@@ -565,6 +571,8 @@ 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
|
||||
@@ -760,6 +768,10 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
public let reasoninglevel: AnyCodable?
|
||||
public let responseusage: AnyCodable?
|
||||
public let elevatedlevel: AnyCodable?
|
||||
public let exechost: AnyCodable?
|
||||
public let execsecurity: AnyCodable?
|
||||
public let execask: AnyCodable?
|
||||
public let execnode: AnyCodable?
|
||||
public let model: AnyCodable?
|
||||
public let spawnedby: AnyCodable?
|
||||
public let sendpolicy: AnyCodable?
|
||||
@@ -773,6 +785,10 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
reasoninglevel: AnyCodable?,
|
||||
responseusage: AnyCodable?,
|
||||
elevatedlevel: AnyCodable?,
|
||||
exechost: AnyCodable?,
|
||||
execsecurity: AnyCodable?,
|
||||
execask: AnyCodable?,
|
||||
execnode: AnyCodable?,
|
||||
model: AnyCodable?,
|
||||
spawnedby: AnyCodable?,
|
||||
sendpolicy: AnyCodable?,
|
||||
@@ -785,6 +801,10 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
self.reasoninglevel = reasoninglevel
|
||||
self.responseusage = responseusage
|
||||
self.elevatedlevel = elevatedlevel
|
||||
self.exechost = exechost
|
||||
self.execsecurity = execsecurity
|
||||
self.execask = execask
|
||||
self.execnode = execnode
|
||||
self.model = model
|
||||
self.spawnedby = spawnedby
|
||||
self.sendpolicy = sendpolicy
|
||||
@@ -798,6 +818,10 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
case reasoninglevel = "reasoningLevel"
|
||||
case responseusage = "responseUsage"
|
||||
case elevatedlevel = "elevatedLevel"
|
||||
case exechost = "execHost"
|
||||
case execsecurity = "execSecurity"
|
||||
case execask = "execAsk"
|
||||
case execnode = "execNode"
|
||||
case model
|
||||
case spawnedby = "spawnedBy"
|
||||
case sendpolicy = "sendPolicy"
|
||||
@@ -1616,6 +1640,85 @@ 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?
|
||||
|
||||
@@ -34,7 +34,7 @@ import Testing
|
||||
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
|
||||
try self.makeExec(at: clawdbotPath)
|
||||
|
||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults)
|
||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults, configRoot: [:])
|
||||
#expect(cmd.prefix(2).elementsEqual([clawdbotPath.path, "gateway"]))
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import Testing
|
||||
let cmd = CommandResolver.clawdbotCommand(
|
||||
subcommand: "rpc",
|
||||
defaults: defaults,
|
||||
configRoot: [:],
|
||||
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path])
|
||||
|
||||
#expect(cmd.count >= 3)
|
||||
@@ -75,7 +76,7 @@ import Testing
|
||||
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
||||
try self.makeExec(at: pnpmPath)
|
||||
|
||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults)
|
||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults, configRoot: [:])
|
||||
|
||||
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "rpc"]))
|
||||
}
|
||||
@@ -93,7 +94,8 @@ import Testing
|
||||
let cmd = CommandResolver.clawdbotCommand(
|
||||
subcommand: "health",
|
||||
extraArgs: ["--json", "--timeout", "5"],
|
||||
defaults: defaults)
|
||||
defaults: defaults,
|
||||
configRoot: [:])
|
||||
|
||||
#expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "health", "--json"]))
|
||||
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
|
||||
@@ -114,7 +116,11 @@ import Testing
|
||||
defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey)
|
||||
defaults.set("/srv/clawdbot", forKey: remoteProjectRootKey)
|
||||
|
||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "status", extraArgs: ["--json"], defaults: defaults)
|
||||
let cmd = CommandResolver.clawdbotCommand(
|
||||
subcommand: "status",
|
||||
extraArgs: ["--json"],
|
||||
defaults: defaults,
|
||||
configRoot: [:])
|
||||
|
||||
#expect(cmd.first == "/usr/bin/ssh")
|
||||
#expect(cmd.contains("clawd@example.com"))
|
||||
@@ -128,4 +134,27 @@ 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
49
apps/macos/Tests/ClawdbotIPCTests/ExecAllowlistTests.swift
Normal file
49
apps/macos/Tests/ClawdbotIPCTests/ExecAllowlistTests.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
struct ExecAllowlistTests {
|
||||
@Test func matchUsesResolvedPath() {
|
||||
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
|
||||
let resolution = ExecCommandResolution(
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
cwd: nil)
|
||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||
#expect(match?.pattern == entry.pattern)
|
||||
}
|
||||
|
||||
@Test func matchUsesBasenameForSimplePattern() {
|
||||
let entry = ExecAllowlistEntry(pattern: "rg")
|
||||
let resolution = ExecCommandResolution(
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
cwd: nil)
|
||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||
#expect(match?.pattern == entry.pattern)
|
||||
}
|
||||
|
||||
@Test func matchIsCaseInsensitive() {
|
||||
let entry = ExecAllowlistEntry(pattern: "RG")
|
||||
let resolution = ExecCommandResolution(
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
cwd: nil)
|
||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||
#expect(match?.pattern == entry.pattern)
|
||||
}
|
||||
|
||||
@Test func matchSupportsGlobStar() {
|
||||
let entry = ExecAllowlistEntry(pattern: "/opt/**/rg")
|
||||
let resolution = ExecCommandResolution(
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
cwd: nil)
|
||||
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||
#expect(match?.pattern == entry.pattern)
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,28 @@ import Testing
|
||||
@Suite struct GatewayEnvironmentTests {
|
||||
@Test func semverParsesCommonForms() {
|
||||
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse(" v1.2.3 \n") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
|
||||
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped
|
||||
#expect(Semver.parse("2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11)) // build suffix stripped
|
||||
#expect(Semver.parse("1.0.5+build.123") == Semver(major: 1, minor: 0, patch: 5)) // metadata suffix stripped
|
||||
#expect(Semver.parse("v1.2.3+build.9") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.3+build.123") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.3-rc.1+build.7") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("v1.2.3-rc.1") == Semver(major: 1, minor: 2, patch: 3))
|
||||
#expect(Semver.parse("1.2.0") == Semver(major: 1, minor: 2, patch: 0))
|
||||
#expect(Semver.parse(nil) == nil)
|
||||
#expect(Semver.parse("invalid") == nil)
|
||||
#expect(Semver.parse("1.2") == nil)
|
||||
#expect(Semver.parse("1.2.x") == nil)
|
||||
}
|
||||
|
||||
@Test func semverCompatibilityRequiresSameMajorAndNotOlder() {
|
||||
let required = Semver(major: 2, minor: 1, patch: 0)
|
||||
#expect(Semver(major: 2, minor: 1, patch: 0).compatible(with: required))
|
||||
#expect(Semver(major: 2, minor: 2, patch: 0).compatible(with: required))
|
||||
#expect(Semver(major: 2, minor: 1, patch: 1).compatible(with: required))
|
||||
#expect(Semver(major: 2, minor: 0, patch: 9).compatible(with: required) == false)
|
||||
#expect(Semver(major: 3, minor: 0, patch: 0).compatible(with: required) == false)
|
||||
#expect(Semver(major: 1, minor: 9, patch: 9).compatible(with: required) == false)
|
||||
}
|
||||
@@ -38,6 +48,7 @@ import Testing
|
||||
|
||||
@Test func expectedGatewayVersionFromStringUsesParser() {
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,10 +74,6 @@ struct MacNodeRuntimeTests {
|
||||
{
|
||||
CLLocation(latitude: 0, longitude: 0)
|
||||
}
|
||||
|
||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
||||
.allowOnce
|
||||
}
|
||||
}
|
||||
|
||||
let services = await MainActor.run { FakeMainActorServices() }
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import Testing
|
||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||
defaults.set("ssh alice@example.com", forKey: remoteTargetKey)
|
||||
|
||||
let settings = CommandResolver.connectionSettings(defaults: defaults)
|
||||
let settings = CommandResolver.connectionSettings(defaults: defaults, configRoot: [:])
|
||||
#expect(settings.mode == .remote)
|
||||
#expect(settings.target == "alice@example.com")
|
||||
}
|
||||
|
||||
@@ -63,6 +63,8 @@ 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]?
|
||||
@@ -76,6 +78,8 @@ 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,
|
||||
@@ -88,6 +92,8 @@ 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
|
||||
@@ -121,6 +127,8 @@ 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]?
|
||||
@@ -135,6 +143,8 @@ 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,
|
||||
@@ -148,6 +158,8 @@ 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,6 +4,8 @@ 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 {
|
||||
@@ -20,23 +22,32 @@ 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?
|
||||
public var needsScreenRecording: Bool?
|
||||
public var agentId: String?
|
||||
public var sessionKey: String?
|
||||
|
||||
public init(
|
||||
command: [String],
|
||||
rawCommand: String? = nil,
|
||||
cwd: String? = nil,
|
||||
env: [String: String]? = nil,
|
||||
timeoutMs: Int? = nil,
|
||||
needsScreenRecording: Bool? = nil)
|
||||
needsScreenRecording: Bool? = nil,
|
||||
agentId: String? = nil,
|
||||
sessionKey: String? = nil)
|
||||
{
|
||||
self.command = command
|
||||
self.rawCommand = rawCommand
|
||||
self.cwd = cwd
|
||||
self.env = env
|
||||
self.timeoutMs = timeoutMs
|
||||
self.needsScreenRecording = needsScreenRecording
|
||||
self.agentId = agentId
|
||||
self.sessionKey = sessionKey
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
194
docs.acp.md
Normal file
194
docs.acp.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# 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`
|
||||
40
docs/brave-search.md
Normal file
40
docs/brave-search.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
summary: "Brave Search API setup for web_search"
|
||||
read_when:
|
||||
- You want to use Brave Search for web_search
|
||||
- You need a BRAVE_API_KEY or plan details
|
||||
---
|
||||
|
||||
# Brave Search API
|
||||
|
||||
Clawdbot uses Brave Search as the default provider for `web_search`.
|
||||
|
||||
## Get an API key
|
||||
|
||||
1) Create a Brave Search API account at https://brave.com/search/api/
|
||||
2) In the dashboard, choose the **Data for Search** plan and generate an API key.
|
||||
3) Store the key in config (recommended) or set `BRAVE_API_KEY` in the Gateway environment.
|
||||
|
||||
## Config example
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave",
|
||||
apiKey: "BRAVE_API_KEY_HERE",
|
||||
maxResults: 5,
|
||||
timeoutSeconds: 30
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The Data for AI plan is **not** compatible with `web_search`.
|
||||
- Brave provides a free tier plus paid plans; check the Brave API portal for current limits.
|
||||
|
||||
See [Web tools](/tools/web) for the full web_search configuration.
|
||||
64
docs/channels/bluebubbles.md
Normal file
64
docs/channels/bluebubbles.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing)."
|
||||
read_when:
|
||||
- Setting up BlueBubbles channel
|
||||
- Troubleshooting webhook pairing
|
||||
---
|
||||
# BlueBubbles (macOS REST)
|
||||
|
||||
Status: bundled plugin (disabled by default) that talks to the BlueBubbles macOS server over HTTP.
|
||||
|
||||
## Overview
|
||||
- Runs on macOS via the BlueBubbles helper app (`https://bluebubbles.app`).
|
||||
- Clawdbot talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`).
|
||||
- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls.
|
||||
- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible).
|
||||
- Pairing/allowlist works the same way as other channels (`/start/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes.
|
||||
- Reactions are surfaced as system events just like Slack/Telegram so agents can “mention” them before replying.
|
||||
|
||||
## Quick start
|
||||
1. Install the BlueBubbles server on your Mac (follows the app store instructions at `https://bluebubbles.app/install`).
|
||||
2. In the BlueBubbles config, enable the web API and set a password for `guid`/`password`.
|
||||
3. Configure Clawdbot:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://bluebubbles-host:1234",
|
||||
password: "example-password",
|
||||
webhookPath: "/bluebubbles-webhook",
|
||||
actions: { reactions: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
4. Point BlueBubbles webhooks to your gateway (example: `http://your-gateway-host/bluebubbles-webhook?password=<password>`).
|
||||
5. Start the gateway; it will register the webhook handler and start pairing.
|
||||
|
||||
## Configuration notes
|
||||
- `channels.bluebubbles.serverUrl`: base URL of the BlueBubbles REST API.
|
||||
- `channels.bluebubbles.password`: password that BlueBubbles expects on every request (`?password=...` or header).
|
||||
- `channels.bluebubbles.webhookPath`: HTTP path the gateway exposes for BlueBubbles webhooks.
|
||||
- `channels.bluebubbles.dmPolicy` / `groupPolicy` + `allowFrom`/`groupAllowFrom` behave like other channels; pairing/allowlist info is stored in `/pairing`.
|
||||
- `channels.bluebubbles.actions.reactions` toggles whether the gateway enqueues system events for reactions/tapbacks.
|
||||
- `channels.bluebubbles.textChunkLimit` overrides the default 4k limit.
|
||||
- `channels.bluebubbles.mediaMaxMb` controls the max size of inbound attachments saved for analysis (default 8MB).
|
||||
|
||||
## How it works
|
||||
- Outbound replies: `sendMessageBlueBubbles` resolves a chat GUID via `/api/v1/chat/query` and posts to `/api/v1/message/text`. Typing (`/api/v1/chat/<guid>/typing`) and read receipts (`/api/v1/chat/<guid>/read`) are sent before/after responses.
|
||||
- Webhooks: BlueBubbles POSTs JSON payloads with `type` and `data`. The plugin ignores non-message events (typing indicator, read status) and extracts `chatGuid` from `data.chats[0].guid`.
|
||||
- Reactions/tapbacks generate `BlueBubbles reaction added/removed` system events so agents can mention them. Agents can also trigger tapbacks via the `react` action with `messageId`, `emoji`, and a `to`/`chatGuid`.
|
||||
- Attachments are downloaded via the REST API and stored in the inbound media cache; text-less messages are converted into `<media:...>` placeholders so the agent knows something was sent.
|
||||
|
||||
## Security
|
||||
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
||||
- Keep the API password and webhook endpoint secret (treat them like credentials).
|
||||
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
|
||||
|
||||
## Troubleshooting
|
||||
- If Voice/typing events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`.
|
||||
- Pairing codes expire after one hour; use `clawdbot pairing list bluebubbles` and `clawdbot pairing approve bluebubbles <code>`.
|
||||
- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it.
|
||||
|
||||
For general channel workflow reference, see [/channels/index] and the [[plugins|/plugin]] guide.
|
||||
@@ -58,7 +58,7 @@ Minimal config:
|
||||
- The `discord` tool is only exposed when the current channel is Discord.
|
||||
13. Native commands use isolated session keys (`agent:<agentId>:discord:slash:<userId>`) rather than the shared `main` session.
|
||||
|
||||
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
|
||||
Note: Name → id resolution uses guild member search and requires Server Members Intent; if the bot can’t search members, use ids or `<@id>` mentions.
|
||||
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
|
||||
Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy.
|
||||
|
||||
@@ -175,6 +175,7 @@ Notes:
|
||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
|
||||
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
|
||||
- If `channels` is present, any channel not listed is denied by default.
|
||||
- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread channel id explicitly.
|
||||
- Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered).
|
||||
- Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
|
||||
|
||||
@@ -192,8 +193,11 @@ Notes:
|
||||
- Your config requires mentions and you didn’t mention it, or
|
||||
- Your guild/channel allowlist denies the channel/user.
|
||||
- **`requireMention: false` but still no replies**:
|
||||
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
|
||||
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
|
||||
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
|
||||
- If you only set `DISCORD_BOT_TOKEN` and never create a `channels.discord` section, the runtime
|
||||
defaults `groupPolicy` to `open`. Add `channels.discord.groupPolicy`,
|
||||
`channels.defaults.groupPolicy`, or a guild/channel allowlist to lock it down.
|
||||
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
|
||||
- **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions.
|
||||
- **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`).
|
||||
|
||||
@@ -283,7 +287,7 @@ ack reaction after the bot replies.
|
||||
|
||||
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
|
||||
- `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`.
|
||||
- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation.
|
||||
- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation. The wizard accepts usernames and resolves them to ids when the bot can search members.
|
||||
- `dm.groupEnabled`: enable group DMs (default `false`).
|
||||
- `dm.groupChannels`: optional allowlist for group DM channel ids or slugs.
|
||||
- `groupPolicy`: controls guild channel handling (`open|disabled|allowlist`); `allowlist` requires channel allowlists.
|
||||
@@ -361,6 +365,10 @@ Allowlist matching notes:
|
||||
- Use `*` to allow any sender/channel.
|
||||
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
|
||||
- When `guilds.<id>.channels` is omitted, all channels in the allowlisted guild are allowed.
|
||||
- To allow **no channels**, set `channels.discord.groupPolicy: "disabled"` (or keep an empty allowlist).
|
||||
- The configure wizard accepts `Guild/Channel` names (public + private) and resolves them to IDs when possible.
|
||||
- On startup, Clawdbot resolves channel/user names in allowlists to IDs (when the bot can search members)
|
||||
and logs the mapping; unresolved entries are kept as typed.
|
||||
|
||||
Native command notes:
|
||||
- The registered commands mirror Clawdbot’s chat commands.
|
||||
|
||||
@@ -244,7 +244,7 @@ Provider options:
|
||||
- `channels.imessage.service`: `imessage | sms | auto`.
|
||||
- `channels.imessage.region`: SMS region.
|
||||
- `channels.imessage.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.imessage.allowFrom`: DM allowlist (handles or `chat_id:*`). `open` requires `"*"`.
|
||||
- `channels.imessage.allowFrom`: DM allowlist (handles, emails, E.164 numbers, or `chat_id:*`). `open` requires `"*"`. iMessage has no usernames; use handles or chat targets.
|
||||
- `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.imessage.groupAllowFrom`: group sender allowlist.
|
||||
- `channels.imessage.historyLimit` / `channels.imessage.accounts.*.historyLimit`: max group messages to include as context (0 disables).
|
||||
|
||||
@@ -17,6 +17,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||
- [iMessage](/channels/imessage) — macOS only; native integration.
|
||||
- [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default).
|
||||
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||
|
||||
@@ -70,9 +70,10 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
|
||||
- `clawdbot pairing list matrix`
|
||||
- `clawdbot pairing approve matrix <CODE>`
|
||||
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
|
||||
- `channels.matrix.dm.allowFrom` accepts user IDs or display names. The wizard resolves display names to user IDs when directory search is available.
|
||||
|
||||
## Rooms (groups)
|
||||
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated).
|
||||
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||
- Allowlist rooms with `channels.matrix.rooms`:
|
||||
```json5
|
||||
{
|
||||
@@ -86,6 +87,9 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
|
||||
}
|
||||
```
|
||||
- `requireMention: false` enables auto-reply in that room.
|
||||
- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names when possible.
|
||||
- On startup, Clawdbot resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
|
||||
- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist).
|
||||
|
||||
## Threads
|
||||
- Reply threading is supported.
|
||||
@@ -117,7 +121,7 @@ Provider options:
|
||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
||||
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.matrix.dm.allowFrom`: DM allowlist. `open` requires `"*"`.
|
||||
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
||||
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
||||
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
|
||||
- `channels.matrix.rooms`: per-room settings and allowlist.
|
||||
|
||||
@@ -76,12 +76,13 @@ Disable with:
|
||||
|
||||
**DM access**
|
||||
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
|
||||
- `channels.msteams.allowFrom` accepts AAD object IDs or UPNs.
|
||||
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names. The wizard resolves names to IDs via Microsoft Graph when credentials allow.
|
||||
|
||||
**Group access**
|
||||
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`).
|
||||
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||
- `channels.msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`).
|
||||
- Set `groupPolicy: "open"` to allow any member (still mention‑gated by default).
|
||||
- To allow **no channels**, set `channels.msteams.groupPolicy: "disabled"`.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
@@ -95,6 +96,32 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
**Teams + channel allowlist**
|
||||
- Scope group/channel replies by listing teams and channels under `channels.msteams.teams`.
|
||||
- Keys can be team IDs or names; channel keys can be conversation IDs or names.
|
||||
- When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mention‑gated).
|
||||
- The configure wizard accepts `Team/Channel` entries and stores them for you.
|
||||
- On startup, Clawdbot resolves team/channel and user allowlist names to IDs (when Graph permissions allow)
|
||||
and logs the mapping; unresolved entries are kept as typed.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
msteams: {
|
||||
groupPolicy: "allowlist",
|
||||
teams: {
|
||||
"My Team": {
|
||||
channels: {
|
||||
"General": { requireMention: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How it works
|
||||
1. Install the Microsoft Teams plugin.
|
||||
2. Create an **Azure Bot** (App ID + secret + tenant ID).
|
||||
@@ -386,7 +413,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
||||
- `channels.msteams.webhook.port` (default `3978`)
|
||||
- `channels.msteams.webhook.path` (default `/api/messages`)
|
||||
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
||||
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs or UPNs).
|
||||
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
|
||||
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
||||
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
||||
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
|
||||
|
||||
@@ -120,7 +120,7 @@ Provider options:
|
||||
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
||||
- `channels.signal.sendReadReceipts`: forward read receipts.
|
||||
- `channels.signal.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`.
|
||||
- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`. Signal has no usernames; use phone/UUID ids.
|
||||
- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.signal.groupAllowFrom`: group sender allowlist.
|
||||
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
---
|
||||
summary: "Slack socket mode setup and Clawdbot config"
|
||||
read_when: "Setting up Slack or debugging Slack socket mode"
|
||||
summary: "Slack setup for socket or HTTP webhook mode"
|
||||
read_when: "Setting up Slack or debugging Slack socket/HTTP mode"
|
||||
---
|
||||
|
||||
# Slack (socket mode)
|
||||
# Slack
|
||||
|
||||
## Quick setup (beginner)
|
||||
## Socket mode (default)
|
||||
|
||||
### 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.
|
||||
@@ -23,7 +25,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-...`).
|
||||
@@ -43,7 +45,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-...`
|
||||
@@ -63,7 +65,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
|
||||
@@ -102,18 +104,51 @@ 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).
|
||||
|
||||
## Manifest (optional)
|
||||
## 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)
|
||||
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.
|
||||
|
||||
@@ -335,6 +370,7 @@ For fine-grained control, use these tags in agent responses:
|
||||
- DMs share the `main` session (like WhatsApp/Telegram).
|
||||
- Channels map to `agent:<agentId>:slack:channel:<channelId>` sessions.
|
||||
- Slash commands use `agent:<agentId>:slack:slash:<userId>` sessions (prefix configurable via `channels.slack.slashCommand.sessionPrefix`).
|
||||
- If Slack doesn’t provide `channel_type`, Clawdbot infers it from the channel ID prefix (`D`, `C`, `G`) and defaults to `channel` to keep session keys stable.
|
||||
- Native command registration uses `commands.native` (global default `"auto"` → Slack off) and can be overridden per-workspace with `channels.slack.commands.native`. Text commands require standalone `/...` messages and can be disabled with `commands.text: false`. Slack slash commands are managed in the Slack app and are not removed automatically. Use `commands.useAccessGroups: false` to bypass access-group checks for commands.
|
||||
- Full command list + config: [Slash commands](/tools/slash-commands)
|
||||
|
||||
@@ -342,10 +378,19 @@ For fine-grained control, use these tags in agent responses:
|
||||
- Default: `channels.slack.dm.policy="pairing"` — unknown DM senders get a pairing code (expires after 1 hour).
|
||||
- Approve via: `clawdbot pairing approve slack <code>`.
|
||||
- To allow anyone: set `channels.slack.dm.policy="open"` and `channels.slack.dm.allowFrom=["*"]`.
|
||||
- `channels.slack.dm.allowFrom` accepts user IDs, @handles, or emails (resolved at startup when tokens allow). The wizard accepts usernames and resolves them to ids during setup when tokens allow.
|
||||
|
||||
## Group policy
|
||||
- `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
|
||||
- `allowlist` requires channels to be listed in `channels.slack.channels`.
|
||||
- If you only set `SLACK_BOT_TOKEN`/`SLACK_APP_TOKEN` and never create a `channels.slack` section,
|
||||
the runtime defaults `groupPolicy` to `open`. Add `channels.slack.groupPolicy`,
|
||||
`channels.defaults.groupPolicy`, or a channel allowlist to lock it down.
|
||||
- The configure wizard accepts `#channel` names and resolves them to IDs when possible
|
||||
(public + private); if multiple matches exist, it prefers the active channel.
|
||||
- On startup, Clawdbot resolves channel/user names in allowlists to IDs (when tokens allow)
|
||||
and logs the mapping; unresolved entries are kept as typed.
|
||||
- To allow **no channels**, set `channels.slack.groupPolicy: "disabled"` (or keep an empty allowlist).
|
||||
|
||||
Channel options (`channels.slack.channels.<id>` or `channels.slack.channels.<name>`):
|
||||
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
||||
|
||||
@@ -152,6 +152,7 @@ By default, the bot only responds to mentions in groups (`@botname` or patterns
|
||||
```
|
||||
|
||||
**Important:** Setting `channels.telegram.groups` creates an **allowlist** - only listed groups (or `"*"`) will be accepted.
|
||||
Forum topics inherit their parent group config (allowFrom, requireMention, skills, prompts) unless you add per-topic overrides under `channels.telegram.groups.<groupId>.topics.<topicId>`.
|
||||
|
||||
To allow all groups with always-respond:
|
||||
```json5
|
||||
@@ -216,6 +217,7 @@ Telegram forum topics include a `message_thread_id` per message. Clawdbot:
|
||||
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
|
||||
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
|
||||
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
|
||||
- Topic configs inherit group settings (requireMention, allowlists, skills, prompts, enabled) unless overridden per topic.
|
||||
|
||||
Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps the DM session key unchanged, but still uses the thread id for replies/draft streaming when it is present.
|
||||
|
||||
@@ -303,7 +305,7 @@ Use the global setting when all Telegram bots/accounts should behave the same. U
|
||||
- `clawdbot pairing list telegram`
|
||||
- `clawdbot pairing approve telegram <CODE>`
|
||||
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing)
|
||||
- `channels.telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries. It is **not** the bot username; use the human sender’s ID.
|
||||
- `channels.telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries. It is **not** the bot username; use the human sender’s ID. The wizard accepts `@username` and resolves it to the numeric ID when possible.
|
||||
|
||||
#### Finding your Telegram user ID
|
||||
Safer (no third-party bot):
|
||||
@@ -360,6 +362,19 @@ To force a voice note bubble in agent replies, include this tag anywhere in the
|
||||
|
||||
The tag is stripped from the delivered text. Other channels ignore this tag.
|
||||
|
||||
For message tool sends, set `asVoice: true` with a voice-compatible audio `media` URL
|
||||
(`message` is optional when media is present):
|
||||
|
||||
```json5
|
||||
{
|
||||
"action": "send",
|
||||
"channel": "telegram",
|
||||
"to": "123456789",
|
||||
"media": "https://example.com/voice.ogg",
|
||||
"asVoice": true
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming (drafts)
|
||||
Telegram can stream **draft bubbles** while the agent is generating a response.
|
||||
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
|
||||
|
||||
@@ -308,7 +308,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
||||
## Config quick map
|
||||
- `channels.whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
|
||||
- `channels.whatsapp.selfChatMode` (same-phone setup; bot uses your personal WhatsApp number).
|
||||
- `channels.whatsapp.allowFrom` (DM allowlist).
|
||||
- `channels.whatsapp.allowFrom` (DM allowlist). WhatsApp uses E.164 phone numbers (no usernames).
|
||||
- `channels.whatsapp.mediaMaxMb` (inbound media save cap).
|
||||
- `channels.whatsapp.ackReaction` (auto-reaction on message receipt: `{emoji, direct, group}`).
|
||||
- `channels.whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
|
||||
|
||||
@@ -92,7 +92,7 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
|
||||
- `clawdbot pairing list zalo`
|
||||
- `clawdbot pairing approve zalo <CODE>`
|
||||
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
|
||||
- `channels.zalo.allowFrom` accepts numeric user IDs.
|
||||
- `channels.zalo.allowFrom` accepts numeric user IDs (no username lookup available).
|
||||
|
||||
## Long-polling vs webhook
|
||||
- Default: long-polling (no public URL required).
|
||||
@@ -147,7 +147,7 @@ Provider options:
|
||||
- `channels.zalo.botToken`: bot token from Zalo Bot Platform.
|
||||
- `channels.zalo.tokenFile`: read token from file path.
|
||||
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`.
|
||||
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs.
|
||||
- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5).
|
||||
- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required).
|
||||
- `channels.zalo.webhookSecret`: webhook secret (8-256 chars).
|
||||
|
||||
@@ -66,11 +66,36 @@ clawdbot directory groups list --channel zalouser --query "work"
|
||||
|
||||
## Access control (DMs)
|
||||
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
|
||||
`channels.zalouser.allowFrom` accepts user IDs or names. The wizard resolves names to IDs via `zca friend find` when available.
|
||||
|
||||
Approve via:
|
||||
- `clawdbot pairing list zalouser`
|
||||
- `clawdbot pairing approve zalouser <code>`
|
||||
|
||||
## Group access (optional)
|
||||
- Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||
- Restrict to an allowlist with:
|
||||
- `channels.zalouser.groupPolicy = "allowlist"`
|
||||
- `channels.zalouser.groups` (keys are group IDs or names)
|
||||
- Block all groups: `channels.zalouser.groupPolicy = "disabled"`.
|
||||
- The configure wizard can prompt for group allowlists.
|
||||
- On startup, Clawdbot resolves group/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalouser: {
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"123456789": { allow: true },
|
||||
"Work Chat": { allow: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-account
|
||||
Accounts map to zca profiles. Example:
|
||||
|
||||
|
||||
166
docs/cli/acp.md
Normal file
166
docs/cli/acp.md
Normal file
@@ -0,0 +1,166 @@
|
||||
---
|
||||
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.
|
||||
@@ -7,6 +7,7 @@ read_when:
|
||||
# `clawdbot agent`
|
||||
|
||||
Run an agent turn via the Gateway (use `--local` for embedded).
|
||||
Use `--agent <id>` to target a configured agent directly.
|
||||
|
||||
Related:
|
||||
- Agent send tool: [Agent send](/tools/agent-send)
|
||||
@@ -15,6 +16,7 @@ Related:
|
||||
|
||||
```bash
|
||||
clawdbot agent --to +15555550123 --message "status update" --deliver
|
||||
clawdbot agent --agent ops --message "Summarize logs"
|
||||
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
||||
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
|
||||
```
|
||||
|
||||
|
||||
44
docs/cli/approvals.md
Normal file
44
docs/cli/approvals.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
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`.
|
||||
@@ -18,6 +18,9 @@ Related docs:
|
||||
```bash
|
||||
clawdbot channels list
|
||||
clawdbot channels status
|
||||
clawdbot channels capabilities
|
||||
clawdbot channels capabilities --channel discord --target channel:123
|
||||
clawdbot channels resolve --channel slack "#general" "@jane"
|
||||
clawdbot channels logs --channel all
|
||||
```
|
||||
|
||||
@@ -42,3 +45,30 @@ clawdbot channels logout --channel whatsapp
|
||||
- Run `clawdbot status --deep` for a broad probe.
|
||||
- Use `clawdbot doctor` for guided fixes.
|
||||
|
||||
## Capabilities probe
|
||||
|
||||
Fetch provider capability hints (intents/scopes where available) plus static feature support:
|
||||
|
||||
```bash
|
||||
clawdbot channels capabilities
|
||||
clawdbot channels capabilities --channel discord --target channel:123
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `--channel` is optional; omit it to list every channel (including extensions).
|
||||
- `--target` accepts `channel:<id>` or a raw numeric channel id and only applies to Discord.
|
||||
- Probes are provider-specific: Discord intents + optional channel permissions; Slack bot + user scopes; Telegram bot flags + webhook; Signal daemon version; MS Teams app token + Graph roles/scopes (annotated where known). Channels without probes report `Probe: unavailable`.
|
||||
|
||||
## Resolve names to IDs
|
||||
|
||||
Resolve channel/user names to IDs using the provider directory:
|
||||
|
||||
```bash
|
||||
clawdbot channels resolve --channel slack "#general" "@jane"
|
||||
clawdbot channels resolve --channel discord "My Server/#support" "@someone"
|
||||
clawdbot channels resolve --channel matrix "Project Room"
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Use `--kind user|group|auto` to force the target type.
|
||||
- Resolution prefers active matches when multiple entries share the same name.
|
||||
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
```
|
||||
|
||||
@@ -27,6 +28,13 @@ 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.
|
||||
|
||||
@@ -17,6 +17,7 @@ Related:
|
||||
|
||||
Notes:
|
||||
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
||||
- Channel-oriented services (Slack/Discord/Matrix/Microsoft Teams) prompt for channel/room allowlists during setup. You can enter names or IDs; the wizard resolves names to IDs when possible.
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ 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)
|
||||
|
||||
@@ -21,6 +21,9 @@ clawdbot doctor --repair
|
||||
clawdbot doctor --deep
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
|
||||
|
||||
## macOS: `launchctl` env overrides
|
||||
|
||||
If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
|
||||
|
||||
@@ -29,6 +29,7 @@ Notes:
|
||||
- By default, the Gateway refuses to start unless `gateway.mode=local` is set in `~/.clawdbot/clawdbot.json`. Use `--allow-unconfigured` for ad-hoc/dev runs.
|
||||
- Binding beyond loopback without auth is blocked (safety guardrail).
|
||||
- `SIGUSR1` triggers an in-process restart (useful without a supervisor).
|
||||
- `SIGINT`/`SIGTERM` handlers stop the gateway process, but they don’t restore any custom terminal state. If you wrap the CLI with a TUI or raw-mode input, restore the terminal before exit.
|
||||
|
||||
### Options
|
||||
|
||||
|
||||
@@ -7,10 +7,11 @@ read_when:
|
||||
|
||||
# `clawdbot hooks`
|
||||
|
||||
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
|
||||
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, and gateway startup).
|
||||
|
||||
Related:
|
||||
- Hooks: [Hooks](/hooks)
|
||||
- Plugin hooks: [Plugins](/plugin#plugin-hooks)
|
||||
|
||||
## List All Hooks
|
||||
|
||||
@@ -28,11 +29,13 @@ List all discovered hooks from workspace, managed, and bundled directories.
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Hooks (2/2 ready)
|
||||
Hooks (4/4 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
|
||||
```
|
||||
|
||||
**Example (verbose):**
|
||||
@@ -105,8 +108,8 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
|
||||
```
|
||||
Hooks Status
|
||||
|
||||
Total hooks: 2
|
||||
Ready: 2
|
||||
Total hooks: 4
|
||||
Ready: 4
|
||||
Not ready: 0
|
||||
```
|
||||
|
||||
@@ -118,6 +121,9 @@ clawdbot hooks enable <name>
|
||||
|
||||
Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
|
||||
|
||||
**Note:** Hooks managed by plugins show `plugin:<id>` in `clawdbot hooks list` and
|
||||
can’t be enabled/disabled here. Enable/disable the plugin instead.
|
||||
|
||||
**Arguments:**
|
||||
- `<name>`: Hook name (e.g., `session-memory`)
|
||||
|
||||
@@ -256,3 +262,29 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
|
||||
```
|
||||
|
||||
**See:** [command-logger documentation](/hooks#command-logger)
|
||||
|
||||
### soul-evil
|
||||
|
||||
Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance.
|
||||
|
||||
**Enable:**
|
||||
|
||||
```bash
|
||||
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)
|
||||
|
||||
@@ -23,15 +23,19 @@ 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)
|
||||
@@ -125,6 +129,7 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
list
|
||||
add
|
||||
delete
|
||||
acp
|
||||
status
|
||||
health
|
||||
sessions
|
||||
@@ -140,6 +145,21 @@ 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
|
||||
@@ -168,21 +188,19 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
runs
|
||||
run
|
||||
nodes
|
||||
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
|
||||
node
|
||||
start
|
||||
daemon
|
||||
status
|
||||
install
|
||||
uninstall
|
||||
start
|
||||
stop
|
||||
restart
|
||||
approvals
|
||||
get
|
||||
set
|
||||
allowlist add|remove
|
||||
browser
|
||||
status
|
||||
start
|
||||
@@ -292,7 +310,7 @@ Options:
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--flow <quickstart|advanced>`
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
||||
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
|
||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||
@@ -302,6 +320,7 @@ Options:
|
||||
- `--openrouter-api-key <key>`
|
||||
- `--ai-gateway-api-key <key>`
|
||||
- `--moonshot-api-key <key>`
|
||||
- `--kimi-code-api-key <key>`
|
||||
- `--gemini-api-key <key>`
|
||||
- `--zai-api-key <key>`
|
||||
- `--minimax-api-key <key>`
|
||||
@@ -505,6 +524,11 @@ 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.
|
||||
|
||||
@@ -517,11 +541,14 @@ 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.
|
||||
|
||||
Surfaces:
|
||||
- `/status` (alias: `/usage`; adds a short usage line when available)
|
||||
- `/status` (adds a short provider usage line when available)
|
||||
- `clawdbot status --usage` (prints full provider breakdown)
|
||||
- macOS menu bar (Usage section under Context)
|
||||
|
||||
@@ -771,6 +798,23 @@ 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).
|
||||
@@ -787,7 +831,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 only)
|
||||
- `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 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:
|
||||
|
||||
@@ -7,16 +7,35 @@ read_when:
|
||||
|
||||
# `clawdbot memory`
|
||||
|
||||
Memory search tools (semantic memory status/index/search).
|
||||
Manage semantic memory indexing and search.
|
||||
Provided by the active memory plugin (default: `memory-core`; set `plugins.slots.memory = "none"` to disable).
|
||||
|
||||
Related:
|
||||
- Memory concept: [Memory](/concepts/memory)
|
||||
- Plugins: [Plugins](/plugins)
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
clawdbot memory status
|
||||
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).
|
||||
|
||||
@@ -26,6 +26,11 @@ clawdbot models scan
|
||||
When provider usage snapshots are available, the OAuth/token status section includes
|
||||
provider usage headers.
|
||||
|
||||
Notes:
|
||||
- `models set <model-or-alias>` accepts `provider/model` or an alias.
|
||||
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
|
||||
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
|
||||
|
||||
## Aliases + fallbacks
|
||||
|
||||
```bash
|
||||
|
||||
96
docs/cli/node.md
Normal file
96
docs/cli/node.md
Normal file
@@ -0,0 +1,96 @@
|
||||
---
|
||||
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)
|
||||
51
docs/cli/service.md
Normal file
51
docs/cli/service.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
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,3 +19,5 @@ 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,13 +5,19 @@ read_when:
|
||||
---
|
||||
# Agent Loop (Clawdbot)
|
||||
|
||||
Short, exact flow of one agent run.
|
||||
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.
|
||||
|
||||
## Entry points
|
||||
- Gateway RPC: `agent` and `agent.wait`.
|
||||
- CLI: `agent` command.
|
||||
|
||||
## High-level flow
|
||||
## How it works (high-level)
|
||||
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
|
||||
@@ -19,8 +25,9 @@ Short, exact flow of one agent run.
|
||||
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
|
||||
- emits **lifecycle end/error** if the embedded loop does not emit one
|
||||
3) `runEmbeddedPiAgent`:
|
||||
- builds `AgentSession` and subscribes to pi events
|
||||
- streams assistant deltas + tool events
|
||||
- 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
|
||||
- enforces timeout -> aborts run if exceeded
|
||||
- returns payloads + usage metadata
|
||||
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream:
|
||||
@@ -31,6 +38,73 @@ Short, exact flow of one agent run.
|
||||
- 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,6 +86,10 @@ 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.
|
||||
|
||||
@@ -98,6 +98,14 @@ Verbose tool summaries are emitted at tool start (no debounce); Control UI
|
||||
streams tool output via agent events when available.
|
||||
More details: [Streaming + chunking](/concepts/streaming).
|
||||
|
||||
## Model refs
|
||||
|
||||
Model refs in config (for example `agents.defaults.model` and `agents.defaults.models`) are parsed by splitting on the **first** `/`.
|
||||
|
||||
- Use `provider/model` when configuring models.
|
||||
- If the model ID itself contains `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
|
||||
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
|
||||
|
||||
## Configuration (minimal)
|
||||
|
||||
At minimum, set:
|
||||
|
||||
@@ -21,7 +21,7 @@ Context is *not the same thing* as “memory”: memory can be stored on disk an
|
||||
- `/status` → quick “how full is my window?” view + session settings.
|
||||
- `/context list` → what’s injected + rough sizes (per file + totals).
|
||||
- `/context detail` → deeper breakdown: per-file, per-tool schema sizes, per-skill entry sizes, and system prompt size.
|
||||
- `/cost on` → append per-reply usage line to normal replies.
|
||||
- `/usage tokens` → append per-reply usage footer to normal replies.
|
||||
- `/compact` → summarize older history into a compact entry to free window space.
|
||||
|
||||
See also: [Slash commands](/tools/slash-commands), [Token use & costs](/token-use), [Compaction](/concepts/compaction).
|
||||
@@ -149,4 +149,3 @@ Docs: [Session](/concepts/session), [Compaction](/concepts/compaction), [Session
|
||||
- `System prompt (estimate)` = computed on the fly when no run report exists (or when running via a CLI backend that doesn’t generate the report).
|
||||
|
||||
Either way, it reports sizes and top contributors; it does **not** dump the full system prompt or tool schemas.
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ read_when:
|
||||
Clawdbot memory is **plain Markdown in the agent workspace**. The files are the
|
||||
source of truth; the model only "remembers" what gets written to disk.
|
||||
|
||||
Memory search tools are provided by the active memory plugin (default:
|
||||
`memory-core`). Disable memory plugins with `plugins.slots.memory = "none"`.
|
||||
|
||||
## Memory files (Markdown)
|
||||
|
||||
The default workspace layout uses two memory layers:
|
||||
@@ -76,17 +79,46 @@ semantic queries can find related notes even when wording differs.
|
||||
Defaults:
|
||||
- Enabled by default.
|
||||
- Watches memory files for changes (debounced).
|
||||
- Uses remote embeddings (OpenAI) unless configured for local.
|
||||
- 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.
|
||||
- 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. 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`).
|
||||
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`).
|
||||
|
||||
If you want to use a **custom OpenAI-compatible endpoint** (like Gemini, OpenRouter, or a proxy),
|
||||
you can use the `remote` configuration:
|
||||
### 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:
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
@@ -95,8 +127,8 @@ agents: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
remote: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
apiKey: "YOUR_GEMINI_API_KEY",
|
||||
baseUrl: "https://api.example.com/v1/",
|
||||
apiKey: "YOUR_OPENAI_COMPAT_API_KEY",
|
||||
headers: { "X-Custom-Header": "value" }
|
||||
}
|
||||
}
|
||||
@@ -107,6 +139,24 @@ 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.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
- OpenAI offers discounted pricing for Batch API workloads, so large indexing runs are usually cheaper than sending the same requests synchronously.
|
||||
- See the OpenAI Batch API docs and pricing for details:
|
||||
- https://platform.openai.com/docs/api-reference/batch
|
||||
- https://platform.openai.com/pricing
|
||||
|
||||
Config example:
|
||||
|
||||
```json5
|
||||
@@ -116,6 +166,9 @@ agents: {
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
fallback: "openai",
|
||||
remote: {
|
||||
batch: { enabled: true, concurrency: 2 }
|
||||
},
|
||||
sync: { watch: true }
|
||||
}
|
||||
}
|
||||
@@ -140,8 +193,147 @@ Local mode:
|
||||
### What gets indexed (and when)
|
||||
|
||||
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
|
||||
- Index storage: per-agent SQLite at `~/.clawdbot/state/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
||||
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval. Reindex triggers when embedding model/provider or chunk sizes change.
|
||||
- Index storage: per-agent SQLite at `~/.clawdbot/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
||||
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval.
|
||||
- Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Clawdbot automatically resets and reindexes the entire store.
|
||||
|
||||
### Hybrid search (BM25 + vector)
|
||||
|
||||
When enabled, Clawdbot combines:
|
||||
- **Vector similarity** (semantic match, wording can differ)
|
||||
- **BM25 keyword relevance** (exact tokens like IDs, env vars, code symbols)
|
||||
|
||||
If full-text search is unavailable on your platform, Clawdbot falls back to vector-only search.
|
||||
|
||||
#### Why hybrid?
|
||||
|
||||
Vector search is great at “this means the same thing”:
|
||||
- “Mac Studio gateway host” vs “the machine running the gateway”
|
||||
- “debounce file updates” vs “avoid indexing on every write”
|
||||
|
||||
But it can be weak at exact, high-signal tokens:
|
||||
- IDs (`a828e60`, `b3b9895a…`)
|
||||
- code symbols (`memorySearch.query.hybrid`)
|
||||
- error strings (“sqlite-vec unavailable”)
|
||||
|
||||
BM25 (full-text) is the opposite: strong at exact tokens, weaker at paraphrases.
|
||||
Hybrid search is the pragmatic middle ground: **use both retrieval signals** so you get
|
||||
good results for both “natural language” queries and “needle in a haystack” queries.
|
||||
|
||||
#### How we merge results (the current design)
|
||||
|
||||
Implementation sketch:
|
||||
|
||||
1) Retrieve a candidate pool from both sides:
|
||||
- **Vector**: top `maxResults * candidateMultiplier` by cosine similarity.
|
||||
- **BM25**: top `maxResults * candidateMultiplier` by FTS5 BM25 rank (lower is better).
|
||||
|
||||
2) Convert BM25 rank into a 0..1-ish score:
|
||||
- `textScore = 1 / (1 + max(0, bm25Rank))`
|
||||
|
||||
3) Union candidates by chunk id and compute a weighted score:
|
||||
- `finalScore = vectorWeight * vectorScore + textWeight * textScore`
|
||||
|
||||
Notes:
|
||||
- `vectorWeight` + `textWeight` is normalized to 1.0 in config resolution, so weights behave as percentages.
|
||||
- If embeddings are unavailable (or the provider returns a zero-vector), we still run BM25 and return keyword matches.
|
||||
- If FTS5 can’t be created, we keep vector-only search (no hard failure).
|
||||
|
||||
This isn’t “IR-theory perfect”, but it’s simple, fast, and tends to improve recall/precision on real notes.
|
||||
If we want to get fancier later, common next steps are Reciprocal Rank Fusion (RRF) or score normalization
|
||||
(min/max or z-score) before mixing.
|
||||
|
||||
Config:
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
query: {
|
||||
hybrid: {
|
||||
enabled: true,
|
||||
vectorWeight: 0.7,
|
||||
textWeight: 0.3,
|
||||
candidateMultiplier: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Embedding cache
|
||||
|
||||
Clawdbot can cache **chunk embeddings** in SQLite so reindexing and frequent updates (especially session transcripts) don't re-embed unchanged text.
|
||||
|
||||
Config:
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
cache: {
|
||||
enabled: true,
|
||||
maxEntries: 50000
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Session memory search (experimental)
|
||||
|
||||
You can optionally index **session transcripts** and surface them via `memory_search`.
|
||||
This is gated behind an experimental flag.
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
experimental: { sessionMemory: true },
|
||||
sources: ["memory", "sessions"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Session indexing is **opt-in** (off by default).
|
||||
- Session updates are debounced and indexed lazily on the next `memory_search` (or manual `clawdbot memory index`).
|
||||
- Results still include snippets only; `memory_get` remains limited to memory files.
|
||||
- Session indexing is isolated per agent (only that agent’s session logs are indexed).
|
||||
- Session logs live on disk (`~/.clawdbot/agents/<agentId>/sessions/*.jsonl`). Any process/user with filesystem access can read them, so treat disk access as the trust boundary. For stricter isolation, run agents under separate OS users or hosts.
|
||||
|
||||
### SQLite vector acceleration (sqlite-vec)
|
||||
|
||||
When the sqlite-vec extension is available, Clawdbot stores embeddings in a
|
||||
SQLite virtual table (`vec0`) and performs vector distance queries in the
|
||||
database. This keeps search fast without loading every embedding into JS.
|
||||
|
||||
Configuration (optional):
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
store: {
|
||||
vector: {
|
||||
enabled: true,
|
||||
extensionPath: "/path/to/sqlite-vec"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `enabled` defaults to true; when disabled, search falls back to in-process
|
||||
cosine similarity over stored embeddings.
|
||||
- If the sqlite-vec extension is missing or fails to load, Clawdbot logs the
|
||||
error and continues with the JS fallback (no vector table).
|
||||
- `extensionPath` overrides the bundled sqlite-vec path (useful for custom builds
|
||||
or non-standard install locations).
|
||||
|
||||
### Local embedding auto-download
|
||||
|
||||
|
||||
@@ -59,6 +59,11 @@ 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:
|
||||
|
||||
@@ -155,6 +155,50 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
||||
}
|
||||
```
|
||||
|
||||
### Kimi Code
|
||||
|
||||
Kimi Code uses a dedicated endpoint and key (separate from Moonshot):
|
||||
|
||||
- Provider: `kimi-code`
|
||||
- Auth: `KIMICODE_API_KEY`
|
||||
- Example model: `kimi-code/kimi-for-coding`
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { KIMICODE_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: { model: { primary: "kimi-code/kimi-for-coding" } }
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
"kimi-code": {
|
||||
baseUrl: "https://api.kimi.com/coding/v1",
|
||||
apiKey: "${KIMICODE_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "kimi-for-coding", name: "Kimi For Coding" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Qwen OAuth (free tier)
|
||||
|
||||
Qwen provides OAuth access to Qwen Coder + Vision via a device-code flow.
|
||||
Enable the bundled plugin, then log in:
|
||||
|
||||
```bash
|
||||
clawdbot plugins enable qwen-portal-auth
|
||||
clawdbot models auth login --provider qwen-portal --set-default
|
||||
```
|
||||
|
||||
Model refs:
|
||||
- `qwen-portal/coder-model`
|
||||
- `qwen-portal/vision-model`
|
||||
|
||||
See [/providers/qwen](/providers/qwen) for setup details and notes.
|
||||
|
||||
### Synthetic
|
||||
|
||||
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
|
||||
|
||||
@@ -102,6 +102,9 @@ Notes:
|
||||
- `/model` (and `/model list`) is a compact, numbered picker (model family + available providers).
|
||||
- `/model <#>` selects from that picker.
|
||||
- `/model status` is the detailed view (auth candidates and, when configured, provider endpoint `baseUrl` + `api` mode).
|
||||
- Model refs are parsed by splitting on the **first** `/`. Use `provider/model` when typing `/model <ref>`.
|
||||
- If the model ID itself contains `/` (OpenRouter-style), you must include the provider prefix (example: `/model openrouter/moonshotai/kimi-k2`).
|
||||
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
|
||||
|
||||
Full command behavior/config: [Slash commands](/tools/slash-commands).
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ All session state is **owned by the gateway** (the “master” Clawdbot). UI cl
|
||||
- Transcripts: `~/.clawdbot/agents/<agentId>/sessions/<SessionId>.jsonl` (Telegram topic sessions use `.../<SessionId>-topic-<threadId>.jsonl`).
|
||||
- The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand.
|
||||
- Group entries may include `displayName`, `channel`, `subject`, `room`, and `space` to label sessions in UIs.
|
||||
- Session entries include `origin` metadata (label + routing hints) so UIs can explain where a session came from.
|
||||
- Clawdbot does **not** read legacy Pi/Tau session folders.
|
||||
|
||||
## Session pruning
|
||||
@@ -53,8 +54,12 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
||||
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
|
||||
- Node bridge runs: `node-<nodeId>`
|
||||
|
||||
## Lifecyle
|
||||
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
|
||||
## Lifecycle
|
||||
- Reset policy: sessions are reused until they expire, and expiry is evaluated on the next inbound message.
|
||||
- Daily reset: defaults to **4:00 AM local time on the gateway host**. A session is stale once its last update is earlier than the most recent daily reset time.
|
||||
- Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
|
||||
- Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, Clawdbot stays in idle-only mode for backward compatibility.
|
||||
- Per-type overrides (optional): `resetByType` lets you override the policy for `dm`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
|
||||
- Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. If `/new` or `/reset` is sent alone, Clawdbot runs a short “hello” greeting turn to confirm the reset.
|
||||
- Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
|
||||
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
|
||||
@@ -92,7 +97,18 @@ Send these as standalone messages so they register.
|
||||
identityLinks: {
|
||||
alice: ["telegram:123456789", "discord:987654321012345678"]
|
||||
},
|
||||
idleMinutes: 120,
|
||||
reset: {
|
||||
// Defaults: mode=daily, atHour=4 (gateway host local time).
|
||||
// If you also set idleMinutes, whichever expires first wins.
|
||||
mode: "daily",
|
||||
atHour: 4,
|
||||
idleMinutes: 120
|
||||
},
|
||||
resetByType: {
|
||||
thread: { mode: "daily", atHour: 4 },
|
||||
dm: { mode: "idle", idleMinutes: 240 },
|
||||
group: { mode: "idle", idleMinutes: 120 }
|
||||
},
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
||||
mainKey: "main",
|
||||
@@ -113,3 +129,18 @@ Send these as standalone messages so they register.
|
||||
## Tips
|
||||
- Keep the primary key dedicated to 1:1 traffic; let groups keep their own keys.
|
||||
- When automating cleanup, delete individual keys instead of the whole store to preserve context elsewhere.
|
||||
|
||||
## Session origin metadata
|
||||
Each session entry records where it came from (best-effort) in `origin`:
|
||||
- `label`: human label (resolved from conversation label + group subject/channel)
|
||||
- `provider`: normalized channel id (including extensions)
|
||||
- `from`/`to`: raw routing ids from the inbound envelope
|
||||
- `accountId`: provider account id (when multi-account)
|
||||
- `threadId`: thread/topic id when the channel supports it
|
||||
The origin fields are populated for direct messages, channels, and groups. If a
|
||||
connector only updates delivery routing (for example, to keep a DM main session
|
||||
fresh), it should still provide inbound context so the session keeps its
|
||||
explainer metadata. Extensions can do this by sending `ConversationLabel`,
|
||||
`GroupSubject`, `GroupChannel`, `GroupSpace`, and `SenderName` in the inbound
|
||||
context and calling `recordSessionMetaFromInbound` (or passing the same context
|
||||
to `updateLastRoute`).
|
||||
|
||||
@@ -18,6 +18,7 @@ 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.
|
||||
@@ -58,6 +59,9 @@ Large files are truncated with a marker. The max per-file size is controlled by
|
||||
`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
|
||||
short missing-file marker.
|
||||
|
||||
Internal hooks can intercept this step via `agent:bootstrap` to mutate or replace
|
||||
the injected bootstrap files (for example swapping `SOUL.md` for an alternate persona).
|
||||
|
||||
To inspect how much each injected file contributes (raw vs injected, truncation, plus tool schema overhead), use `/context list` or `/context detail`. See [Context](/concepts/context).
|
||||
|
||||
## Time handling
|
||||
@@ -95,3 +99,12 @@ 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)
|
||||
## Message envelopes (UTC by default)
|
||||
|
||||
Inbound messages are wrapped in an envelope like:
|
||||
|
||||
@@ -17,7 +17,46 @@ Inbound messages are wrapped in an envelope like:
|
||||
[Provider ... 2026-01-05T21:26Z] message text
|
||||
```
|
||||
|
||||
The timestamp in the envelope is **always UTC**, with minutes precision.
|
||||
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
|
||||
```
|
||||
|
||||
## Tool payloads (raw provider data + normalized fields)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ read_when:
|
||||
|
||||
## Where it shows up
|
||||
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
|
||||
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
|
||||
- `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only).
|
||||
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
||||
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||
- macOS menu bar: “Usage” section under Context (only if available).
|
||||
|
||||
@@ -7,10 +7,10 @@ read_when:
|
||||
|
||||
# Date & Time
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Message envelopes (UTC)
|
||||
## Message envelopes (UTC by default)
|
||||
|
||||
Inbound messages are wrapped with a UTC timestamp (minute precision):
|
||||
|
||||
@@ -18,7 +18,47 @@ Inbound messages are wrapped with a UTC timestamp (minute precision):
|
||||
[Provider ... 2026-01-05T21:26Z] message text
|
||||
```
|
||||
|
||||
This envelope timestamp is **always UTC**, regardless of the host timezone.
|
||||
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
|
||||
```
|
||||
|
||||
## System prompt: Current Date & Time
|
||||
|
||||
|
||||
72
docs/debug/node-issue.md
Normal file
72
docs/debug/node-issue.md
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
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,6 +77,7 @@ 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,6 +657,10 @@
|
||||
"source": "/templates/AGENTS",
|
||||
"destination": "/reference/templates/AGENTS"
|
||||
},
|
||||
{
|
||||
"source": "/templates/BOOT",
|
||||
"destination": "/reference/templates/BOOT"
|
||||
},
|
||||
{
|
||||
"source": "/templates/BOOTSTRAP",
|
||||
"destination": "/reference/templates/BOOTSTRAP"
|
||||
@@ -822,8 +826,10 @@
|
||||
"cli/models",
|
||||
"cli/logs",
|
||||
"cli/nodes",
|
||||
"cli/approvals",
|
||||
"cli/gateway",
|
||||
"cli/daemon",
|
||||
"cli/service",
|
||||
"cli/tui",
|
||||
"cli/voicecall",
|
||||
"cli/wake",
|
||||
@@ -894,7 +900,6 @@
|
||||
"gateway/heartbeat",
|
||||
"gateway/doctor",
|
||||
"gateway/logging",
|
||||
"logging",
|
||||
"gateway/security",
|
||||
"gateway/sandbox-vs-tool-policy-vs-elevated",
|
||||
"gateway/sandboxing",
|
||||
@@ -957,6 +962,8 @@
|
||||
{
|
||||
"group": "Automation & Hooks",
|
||||
"pages": [
|
||||
"hooks",
|
||||
"hooks/soul-evil",
|
||||
"automation/auth-monitoring",
|
||||
"automation/webhook",
|
||||
"automation/gmail-pubsub",
|
||||
@@ -1050,6 +1057,7 @@
|
||||
"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)
|
||||
- `event`: node signals (voice transcript, agent request, chat subscribe)
|
||||
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake, skills.bins)
|
||||
- `event`: node signals (voice transcript, agent request, chat subscribe, exec lifecycle)
|
||||
|
||||
Gateway → Client:
|
||||
- `invoke` / `invoke-res`: node commands (`canvas.*`, `camera.*`, `screen.record`,
|
||||
@@ -57,6 +57,18 @@ 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user