Compare commits

...
Author SHA1 Message Date
Peter Steinberger 63ac6ccd20 fix: resolve TwiML/status callbacks (#1180) (thanks @andrew-kurin) 2026-01-18 16:34:18 +00:00
Ghost 340a054f73 fix(voice-call): resolve StatusCallback with inline TwiML
- Switch from inline to URL-based TwiML for outbound calls
- Store TwiML content temporarily and serve on webhook request
- Add twimlStorage map and cleanup helper methods
- Fix TwiML serving to handle CallStatus='in-progress' on initial request

Closes #864
2026-01-18 16:11:19 +00:00
Peter Steinberger 810394f43b fix: improve remote bin probe logging 2026-01-18 16:09:48 +00:00
Peter Steinberger 835162fb62 fix: retry openai batch indexing 2026-01-18 16:08:22 +00:00
Peter Steinberger 82883095fe docs: explain Copilot provider options 2026-01-18 16:06:48 +00:00
Peter Steinberger 49d8ad3049 feat: surface node core/ui versions in macOS 2026-01-18 16:00:36 +00:00
Peter Steinberger 1721d04405 feat: add node core/ui versions in bridge 2026-01-18 15:59:54 +00:00
Peter Steinberger 633e0d9382 Merge pull request #1164 from ngutman/feat/boot-md
feat(hooks): run BOOT.md on gateway startup
2026-01-18 15:59:53 +00:00
Peter Steinberger f06ce98312 refactor: rename lancedb memory plugin 2026-01-18 15:48:05 +00:00
Peter Steinberger b546b2a48d fix: stabilize slack http receiver import 2026-01-18 15:44:17 +00:00
Peter Steinberger c11b016d22 fix: prefer node service naming 2026-01-18 15:33:22 +00:00
Peter Steinberger 3686bde783 feat: add exec approvals tooling and service status 2026-01-18 15:23:41 +00:00
Peter Steinberger 9c06689569 fix: sanitize oversized image payloads 2026-01-18 15:21:38 +00:00
Peter Steinberger 891a2cc64a docs: tighten GitHub newline guidance 2026-01-18 15:20:09 +00:00
Peter Steinberger 01211937fc fix: link bash disabled docs 2026-01-18 15:17:09 +00:00
Peter SteinbergerandJonathan Rhyne 4726580c7e feat(slack): add HTTP receiver webhook mode (#1143) - thanks @jdrhyne
Co-authored-by: Jonathan Rhyne <jdrhyne@users.noreply.github.com>
2026-01-18 15:04:07 +00:00
Peter Steinberger e9a08dc507 feat: enrich system prompt docs guidance 2026-01-18 15:00:36 +00:00
Peter Steinberger f3698e360b docs: add api usage and costs overview 2026-01-18 14:55:09 +00:00
Peter Steinberger c69947dff8 feat: auto-enable audio understanding when keys exist 2026-01-18 14:55:09 +00:00
Peter Steinberger 173bce34b0 docs: add dep patch approval rule 2026-01-18 14:46:03 +00:00
Peter Steinberger 6a27e385b1 docs: map agent loop hook points 2026-01-18 14:43:35 +00:00
Peter Steinberger 5f0d9c3eb9 docs: expand agent loop overview 2026-01-18 14:30:12 +00:00
Peter Steinberger 0e31c8153c fix: bump Peekaboo revision 2026-01-18 14:26:19 +00:00
Peter Steinberger 9c0773c469 chore: update dependencies 2026-01-18 14:16:04 +00:00
Peter Steinberger f5533baf61 test: add vector dedupe regression coverage 2026-01-18 14:08:06 +00:00
Peter Steinberger 60bc436e99 Merge pull request #1175 from vrknetha/fix/tool-error-fallback
Agents: surface tool failures without assistant output
2026-01-18 14:08:02 +00:00
Peter Steinberger 741b984a68 docs: fix #1151 changelog attribution 2026-01-18 14:04:38 +00:00
Peter Steinberger 858a5f48d8 Merge pull request #1176 from sibbl/fix-matrix-allowfrom
Matrix: fix redundant allowFrom assignment in monitorMatrixProvider
2026-01-18 13:57:00 +00:00
Peter Steinberger 20c26eb303 fix: prevent sqlite-vec duplicate id failures 2026-01-18 13:55:56 +00:00
Peter Steinberger f3ef609839 fix: show exec approval alerts for local mac node 2026-01-18 13:42:23 +00:00
Sebastian Schubotz 234fe5b5cd fix(matrix): remove redundant allowFrom assignment in monitorMatrixProvider 2026-01-18 14:05:08 +01:00
vrknetha 65710932ff Agents: surface tool failures without assistant output 2026-01-18 18:35:03 +05:30
Peter Steinberger e944f21ec0 test: drop core runtime import in matrix directory 2026-01-18 11:03:27 +00:00
Peter Steinberger ee6e534ccb refactor: route channel runtime via plugin api 2026-01-18 11:01:16 +00:00
Nimrod Gutman 11b07f4a29 feat(hooks): run boot.md on gateway startup 2026-01-18 11:50:25 +02:00
Peter Steinberger 676d41d415 fix: seed embedding cache for atomic reindex 2026-01-18 09:28:42 +00:00
Peter Steinberger a3a4996adb feat: add gemini memory embeddings 2026-01-18 09:09:45 +00:00
Peter Steinberger b015c7e5ad fix: sync protocol outputs 2026-01-18 08:58:41 +00:00
Peter Steinberger 4de3c3a028 feat: add exec approvals editor in control ui and mac app 2026-01-18 08:54:38 +00:00
Peter Steinberger b739a3897f fix: stabilize acp streams and tests 2026-01-18 08:54:00 +00:00
Peter Steinberger c5e19f5c67 refactor: migrate messaging plugins to sdk 2026-01-18 08:54:00 +00:00
Peter Steinberger 9241e21114 fix: address acp client typing 2026-01-18 08:51:57 +00:00
Peter Steinberger 65bed815a8 fix: resolve ci failures 2026-01-18 08:45:29 +00:00
Peter Steinberger d776cfb4e1 fix: skip launchd for remote mode 2026-01-18 08:35:14 +00:00
Peter Steinberger c6e7e1821b test: tolerate tool summary payloads in install e2e 2026-01-18 08:33:45 +00:00
Peter Steinberger f76ab69612 feat: add memory indexing progress options 2026-01-18 08:30:04 +00:00
Peter Steinberger 889db137b8 test: add beta tag install option for docker installer 2026-01-18 08:30:00 +00:00
Peter Steinberger 9db682750d chore: point Peekaboo to main 2026-01-18 08:29:00 +00:00
Peter Steinberger 9809b47d45 feat(acp): add interactive client harness 2026-01-18 08:27:37 +00:00
Peter Steinberger 68d79e56c2 feat: add node binding controls in control ui 2026-01-18 08:26:32 +00:00
Peter SteinbergerandMykyta Bozhenko d3862ae30a fix(auth): preserve auto-pin preference
Co-authored-by: Mykyta Bozhenko <21245729+cheeeee@users.noreply.github.com>
2026-01-18 08:22:55 +00:00
Peter Steinberger e49a2952d9 fix: clean up duplicate import (#1098)
Follow-up after rebase.
2026-01-18 08:15:21 +00:00
Peter SteinbergerandTyler Yust 8b57f519c3 fix: tighten native image injection (#1098)
Thanks @tyler6204.

Co-authored-by: Tyler Yust <tyler6204@users.noreply.github.com>
2026-01-18 08:15:21 +00:00
Tyler Yust ddcc05f5f4 fix: improve error handling for file URL processing
- Enhanced error handling in image reference detection to skip malformed file URLs without crashing.
- Updated media loading logic to throw an error for invalid file URLs, ensuring better feedback for users.
2026-01-18 08:15:21 +00:00
Tyler Yust 8c0e290db1 fix: enhance image reference detection and optimize image processing
- Added support for detecting file URLs in prompts using fileURLToPath for accurate path resolution.
- Updated image loading logic to default to JPEG format for optimized image processing.
- Improved error handling in image optimization to continue processing on failures.
2026-01-18 08:15:21 +00:00
Tyler Yust 7bfc77db25 fix: improve file URL handling and enhance image loading logic
- Added handling for file URLs using fileURLToPath for proper resolution.
- Updated logic to skip relative path resolution if ref.resolved is already absolute.
- Enhanced cap calculation for image loading to handle undefined maxBytes more gracefully.
2026-01-18 08:15:21 +00:00
Tyler YustandClaude Opus 4.5 8d74578ceb feat: native image injection for vision-capable models
- Auto-detect and load images referenced in user prompts
- Inject history images at their original message positions
- Fix EXIF orientation - rotate before resizing in resizeToJpeg
- Sandbox security: validate paths, block remote URLs when sandbox enabled
- Prevent duplicate history image injection across turns
- Handle string-based user message content (convert to array)
- Add bounds check for message index in history processing
- Fix regex to properly match relative paths (./  ../)
- Add multi-image support for iMessage attachments
- Pass MAX_IMAGE_BYTES limit to image loading

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 08:15:21 +00:00
Peter Steinberger f7123ec30a fix: repair context report and tool config 2026-01-18 08:15:21 +00:00
Peter Steinberger ad4f4388f4 docs: explain per-agent exec node binding 2026-01-18 08:15:15 +00:00
Peter Steinberger 2a86504723 perf: lazy-load memory manager 2026-01-18 08:05:36 +00:00
Peter SteinbergerandJonathan Taylor de3b68740a feat(acp): add experimental ACP support
Co-authored-by: Jonathan Taylor <visionik@pobox.com>
2026-01-18 08:03:36 +00:00
Peter Steinberger efaa73f543 docs: align exec event text 2026-01-18 08:01:25 +00:00
Peter Steinberger 1589c73697 test: cover bridge exec events 2026-01-18 08:01:25 +00:00
Peter Steinberger 359d2af8a8 fix: resolve mac build errors 2026-01-18 08:00:58 +00:00
Peter Steinberger fa897e5dfe docs: explain node host use cases 2026-01-18 07:59:03 +00:00
Peter Steinberger 7fa8ae56cb docs: add exec events to bridge protocol 2026-01-18 07:59:03 +00:00
Peter SteinbergerandMykyta Bozhenko ec27c813cc fix(fallback): handle timeout aborts
Co-authored-by: Mykyta Bozhenko <21245729+cheeeee@users.noreply.github.com>
2026-01-18 07:52:44 +00:00
Peter Steinberger 3b24fe639a chore: remove peekaboo submodule 2026-01-18 07:47:32 +00:00
Peter Steinberger e5cca6e432 chore: switch Peekaboo to SPM 2026-01-18 07:47:31 +00:00
Peter Steinberger ae0b4c4990 feat: add exec host routing + node daemon 2026-01-18 07:46:00 +00:00
Peter Steinberger 49bd2d96fa test: fix gateway test lint 2026-01-18 07:44:14 +00:00
Peter Steinberger ca350fc66c chore(format): oxfmt memory 2026-01-18 07:30:07 +00:00
Peter Steinberger 30338ce1a7 refactor: share memory plugin config helpers 2026-01-18 07:24:16 +00:00
Peter Steinberger faa94f0168 Merge pull request #1148 from TSavo/refactor/gateway-test-monkeypatching
refactor: remove monkeypatching from gateway tests
2026-01-18 07:16:33 +00:00
Peter Steinberger f5c84768ff chore(format): oxfmt 2026-01-18 07:14:40 +00:00
Peter Steinberger df752d4706 Merge pull request #1149 from radek-paclt/feature/memory-plugin-v2
feat(memory): add lifecycle hooks and vector memory plugin
2026-01-18 07:10:06 +00:00
Peter Steinberger c9c9516206 refactor(memory): extract sync + status helpers 2026-01-18 07:03:06 +00:00
Peter Steinberger d3b15c6afa ci: stabilize vitest runs 2026-01-18 06:58:54 +00:00
Peter SteinbergerandAustin Mudd f86b24c511 refactor(session): centralize thread reset detection
Co-authored-by: Austin Mudd <austinm911@gmail.com>
2026-01-18 06:55:04 +00:00
Peter Steinberger b5ddf08763 test: expand soul-evil coverage 2026-01-18 06:39:26 +00:00
Peter SteinbergerandAustin Mudd 367826f6e4 feat(session): add daily reset policy
Co-authored-by: Austin Mudd <austinm911@gmail.com>
2026-01-18 06:37:37 +00:00
Peter SteinbergerandAndrew Lauppe f03c3b3f05 docs: update changelog for #1147
Co-authored-by: Andrew Lauppe <andy@t5tele.com>
2026-01-18 06:37:29 +00:00
Radek PacltandClaude Opus 4.5 ebfeb7a6bf feat(memory): add lifecycle hooks and vector memory plugin
Add plugin lifecycle hooks infrastructure:
- before_agent_start: inject context before agent loop
- agent_end: analyze conversation after completion
- 13 hook types total (message, tool, session, gateway hooks)

Memory plugin implementation:
- LanceDB vector storage with OpenAI embeddings
- kind: "memory" to integrate with upstream slot system
- Auto-recall: injects <relevant-memories> when context found
- Auto-capture: stores preferences, decisions, entities
- Rule-based capture filtering with 0.95 similarity dedup
- Tools: memory_recall, memory_store, memory_forget
- CLI: clawdbot ltm list|search|stats

Plugin infrastructure:
- api.on() method for hook registration
- Global hook runner singleton for cross-module access
- Priority ordering and error catching

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 06:34:43 +00:00
Peter Steinberger ac1b2d8c40 chore(gate): fix lint and protocol 2026-01-18 06:31:02 +00:00
Peter Steinberger 2087f0c6a1 ci: bump vitest timeouts 2026-01-18 06:31:02 +00:00
Peter Steinberger bcfdcc6820 fix: keep bootstrap files in context report 2026-01-18 06:30:01 +00:00
Peter Steinberger b65acfcbb7 chore(lint): fix context report bootstrap destructure 2026-01-18 06:30:01 +00:00
Peter Steinberger f7fcfafb4c fix: resolve lint after rebase 2026-01-18 06:30:01 +00:00
Peter Steinberger 15606b4d88 test: cover bundled memory plugin package metadata 2026-01-18 06:30:01 +00:00
Peter Steinberger bb8f08734a build: package memory-core as a workspace plugin 2026-01-18 06:30:01 +00:00
Peter SteinbergerandAndrew Lauppe 0b00e591e1 fix(streaming): emit assistant deltas
Co-authored-by: Andrew Lauppe <andy@t5tele.com>
2026-01-18 06:24:52 +00:00
Peter Steinberger e39fd7dbb3 docs: update bundled hooks list 2026-01-18 06:23:09 +00:00
Peter Steinberger b8a82923e9 docs: add soul-evil hook docs 2026-01-18 06:21:00 +00:00
Peter Steinberger 28f8b7bafa refactor: add hook guards and test helpers 2026-01-18 06:15:24 +00:00
Peter Steinberger 32dd052260 chore: show plugin hooks in plugins info 2026-01-18 06:14:09 +00:00
Peter Steinberger 8f7f7ee7dc feat: add /exec session overrides 2026-01-18 06:12:54 +00:00
Peter Steinberger 1d8614c7c2 fix: align exec tool config and test timeouts 2026-01-18 06:12:53 +00:00
Peter SteinbergerandAndrew Lauppe 436c5fd751 fix(openai-http): reuse history markers for chat prompts
Co-authored-by: Andrew Lauppe <andy@t5tele.com>
2026-01-18 06:07:59 +00:00
Peter Steinberger f5f7f47c81 chore(format): oxfmt hooks-cli 2026-01-18 06:03:22 +00:00
Peter Steinberger d4bd387e0e chore(gate): fix lint and formatting 2026-01-18 06:01:25 +00:00
Peter Steinberger d1c85cb32d test(gateway): stabilize cron temp cleanup 2026-01-18 06:01:25 +00:00
Peter Steinberger a3a2c641a7 test(usage): cover modes and full footer 2026-01-18 06:01:25 +00:00
Peter Steinberger 54d7551b53 refactor(usage): centralize responseUsage mode 2026-01-18 06:01:25 +00:00
Peter Steinberger e2c10a2b7a feat: support plugin-managed hooks 2026-01-18 05:57:05 +00:00
Peter Steinberger 88b37e80fc refactor: expand bootstrap helpers and tests 2026-01-18 05:51:55 +00:00
Peter Steinberger d5be8fa576 test: avoid timer hangs in cron tests 2026-01-18 05:44:22 +00:00
Peter Steinberger 208398973b test: stabilize gateway suites 2026-01-18 05:44:22 +00:00
Peter Steinberger 8f998741b7 fix: shorten doctor gateway health timeout in non-interactive 2026-01-18 05:44:22 +00:00
Peter Steinberger 9c0ff87c86 fix: align plugin runtime and exec wiring 2026-01-18 05:44:22 +00:00
Peter Steinberger 1a0d1cb7b2 test: stabilize gateway ports and timers 2026-01-18 05:44:22 +00:00
Peter Steinberger cf8b3ed988 fix: harden memory indexing and embedded session locks 2026-01-18 05:41:45 +00:00
Peter Steinberger b7575a889e refactor: align status with plugin memory slot 2026-01-18 05:40:10 +00:00
Peter Steinberger 154d4a43db build: export plugin-sdk for extensions 2026-01-18 05:40:10 +00:00
Peter Steinberger b5c023044b docs: expand memory hybrid search explainer 2026-01-18 05:40:10 +00:00
Peter Steinberger 072a13f3b2 test: expand memory hybrid coverage 2026-01-18 05:40:10 +00:00
Peter Steinberger c00ea63bb0 refactor: split memory manager internals 2026-01-18 05:40:10 +00:00
Peter Steinberger 8350758635 chore(lint): fix unused vars and formatting 2026-01-18 05:38:23 +00:00
Peter Steinberger 2dabce59ce feat(slash-commands): usage footer modes 2026-01-18 05:35:35 +00:00
tsavo b594f5130d refactor: add afterEach cleanup to all gateway tests
Added afterEach hooks with server/ws cleanup to:
- server.channels.test.ts (3 tests)
- server.config-apply.test.ts (2 tests)
- server.sessions-send.test.ts (already had this)

This ensures ports are properly released between tests, preventing
timeout issues from port conflicts.
2026-01-17 21:35:01 -08:00
tsavo e2bb5eecf3 refactor: remove monkeypatching from gateway tests
Replace manual process.env backup/restore with vi.stubEnv():
- server.config-apply.test.ts: Simplified env var pattern
- server.channels.test.ts: Simplified env var pattern
- server.sessions-send.test.ts: Added afterEach cleanup hook, removed try-finally blocks from all 4 tests

Uses proper Vitest isolation instead of manual restoration.
2026-01-17 21:32:14 -08:00
Peter Steinberger e7a4931932 refactor: centralize bootstrap file resolution 2026-01-18 05:31:04 +00:00
Peter Steinberger ad3c12a43a feat: add bootstrap hook and soul-evil hook 2026-01-18 05:24:47 +00:00
Peter Steinberger 7e2d91f3b7 test: cover subagent helpers 2026-01-18 05:19:56 +00:00
Peter Steinberger 97cef49046 refactor: share subagent helpers 2026-01-18 05:19:56 +00:00
Peter Steinberger 016693a1f5 fix: abort embedded prompts on cancel 2026-01-18 05:18:10 +00:00
Peter Steinberger 89c5185f1c feat: migrate zalouser plugin to sdk
# Conflicts:
#	CHANGELOG.md
2026-01-18 05:17:40 +00:00
Peter Steinberger b105745299 feat: expand subagent status visibility 2026-01-18 04:46:04 +00:00
Peter Steinberger 1ae415e395 fix: align agent exec config 2026-01-18 04:37:15 +00:00
Peter Steinberger 55aff22274 feat: surface batch request progress 2026-01-18 04:30:15 +00:00
Peter Steinberger e4e1396a98 perf: improve batch status logging 2026-01-18 04:28:14 +00:00
Peter Steinberger 331b8157b0 docs: clarify plugin agent tool config 2026-01-18 04:28:00 +00:00
Peter Steinberger efdb33c975 feat: add exec host approvals flow 2026-01-18 04:27:41 +00:00
Peter Steinberger fa1079214b fix: include query in Twilio webhook verification 2026-01-18 04:25:28 +00:00
Peter Steinberger 82e49af5a7 fix: resolve plugin tool meta typing 2026-01-18 04:24:16 +00:00
Peter Steinberger fabc2882aa fix: avoid keychain prompts in embedded runner 2026-01-18 04:19:28 +00:00
Peter Steinberger 6b3d3f5e21 refactor: centralize plugin tool policy helpers 2026-01-18 04:18:32 +00:00
Peter Steinberger 6da6582ced feat: add optional plugin tools 2026-01-18 04:08:00 +00:00
Peter Steinberger 45bf07ba31 Update canvas skill with Tailscale integration details and architecture 2026-01-18 03:57:19 +00:00
Peter Steinberger 50ae43f886 Add canvas skill documentation 2026-01-18 03:55:52 +00:00
Peter Steinberger afb877a96b perf: speed up memory batch polling 2026-01-18 03:55:14 +00:00
Peter Steinberger 0d9172d761 fix: persist session origin metadata 2026-01-18 03:41:51 +00:00
Peter Steinberger dad69afc84 fix: align plugin runtime types 2026-01-18 03:41:25 +00:00
Peter Steinberger 787bed4996 test: stabilize doctor + pi-embedded suites 2026-01-18 03:40:47 +00:00
Peter Steinberger b6d470a679 feat: migrate zalo plugin to sdk 2026-01-18 03:37:26 +00:00
Peter Steinberger 5fa1a63978 Merge pull request #1136 from cheeeee/fix/prompt-failover
fix(agent): Enable model fallback for prompt-phase quota/rate limit errors
2026-01-18 03:32:03 +00:00
Peter Steinberger 6cc57ae772 feat: add bluebubbles plugin 2026-01-18 03:17:43 +00:00
Mykyta Bozhenko 448394a0de fix(agent): Enable model fallback for prompt-phase quota/rate limit errors
When a prompt submission fails with quota or rate limit errors, throw
FailoverError instead of the raw promptError. This enables the model
fallback system to try alternative models.

Previously, rate limit errors during the prompt phase (before streaming)
were thrown directly, bypassing fallback. Only response-phase errors
triggered model fallback.

Now checks if fallback models are configured and the error is failover-
eligible. If so, wraps in FailoverError to trigger the fallback chain.
2026-01-18 01:29:48 +00:00
565 changed files with 30087 additions and 6010 deletions

No files matched your search

-4
View File
@@ -1,4 +0,0 @@
[submodule "Peekaboo"]
path = Peekaboo
url = https://github.com/steipete/Peekaboo.git
branch = main
+2 -1
View File
@@ -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`).
@@ -84,6 +84,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.**
+99 -12
View File
@@ -2,24 +2,76 @@
Docs: https://docs.clawd.bot
## 2026.1.17-6
## 2026.1.17-7
### 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: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
- Exec approvals: add `clawdbot approvals` CLI for viewing and updating gateway/node allowlists.
- CLI: add `clawdbot service` gateway/node management and a `clawdbot node status` alias.
- Status: show gateway + node service summaries in `clawdbot status` and `status --all`.
- Control UI: add gateway/node target selector for exec approvals.
- Docs: add approvals/service references and refresh node/control UI docs.
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
- 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.
- 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.
- Nodes: report core/ui versions in node list + presence; surface both in CLI + macOS UI.
<<<<<<< HEAD
- 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.
||||||| parent of 903e9be49 (feat: surface node core/ui versions in macOS)
## 2026.1.17-5
### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
- 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)
- Voice call: separate TwiML fetches from status callbacks to avoid stuck calls. (#1180) — thanks @andrew-kurin.
## 2026.1.18-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 memory search state in `clawdbot status` and detailed FTS + embedding cache state in `clawdbot memory status`.
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
## 2026.1.17-4
## 2026.1.18-3
### Changes
=======
- Nodes: report core/ui versions in node list + presence; surface both in CLI + macOS UI.
### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
- 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)
- Voice call: separate TwiML fetches from status callbacks to avoid stuck calls. (#1180) — thanks @andrew-kurin.
## 2026.1.18-5
### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
## 2026.1.18-3
### Changes
>>>>>>> 903e9be49 (feat: surface node core/ui versions in macOS)
- 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.
@@ -29,17 +81,52 @@ Docs: https://docs.clawd.bot
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### 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: 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)
- Skills: improve remote bin probe logging with node labels + connectivity hints.
- 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.
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
- 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.17-3
### Changes
- Memory: add OpenAI Batch API indexing for embeddings when configured.
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
- Sessions: persist origin metadata across connectors for generic session explainers.
### Fixes
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
Submodule Peekaboo deleted from 5c195f5e46.
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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: [
+28 -73
View File
@@ -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",
+3 -4
View File
@@ -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",
+11 -3
View File
@@ -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,
@@ -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
}
}
@@ -0,0 +1,360 @@
import AppKit
import ClawdbotKit
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
}
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)
})
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
}
}
}
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 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)
{
self.socketPath = socketPath
self.token = token
self.onPrompt = onPrompt
}
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 let line = try self.readLine(from: handle, maxBytes: 256_000),
let data = line.data(using: .utf8)
else {
return
}
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
guard request.type == "request", request.token == self.token else {
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny)
let data = try JSONEncoder().encode(response)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
return
}
let decision = await self.onPrompt(request.request)
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: decision)
let responseData = try JSONEncoder().encode(response)
var payload = responseData
payload.append(0x0A)
try handle.write(contentsOf: payload)
} catch {
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
}
}
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)
}
}
@@ -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)
@@ -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(
@@ -1,199 +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)")
}
}
private static func systemRunSection(from root: [String: Any]) -> [String: Any] {
root["systemRun"] as? [String: Any] ?? [:]
}
private static func updateSystemRunSection(_ mutate: (inout [String: Any]) -> Void) {
var root = self.loadDict()
var systemRun = self.systemRunSection(from: root)
mutate(&systemRun)
if systemRun.isEmpty {
root.removeValue(forKey: "systemRun")
} else {
root["systemRun"] = systemRun
}
self.saveDict(root)
}
private static func agentSection(_ systemRun: [String: Any], agentId: String) -> [String: Any]? {
let agents = systemRun["agents"] as? [String: Any]
return agents?[agentId] as? [String: Any]
}
private static func updateAgentSection(
_ systemRun: inout [String: Any],
agentId: String,
mutate: (inout [String: Any]) -> Void)
{
var agents = systemRun["agents"] as? [String: Any] ?? [:]
var entry = agents[agentId] as? [String: Any] ?? [:]
mutate(&entry)
if entry.isEmpty {
agents.removeValue(forKey: agentId)
} else {
agents[agentId] = entry
}
if agents.isEmpty {
systemRun.removeValue(forKey: "agents")
} else {
systemRun["agents"] = agents
}
}
static func systemRunPolicy(agentId: String? = nil) -> SystemRunPolicy? {
let root = self.loadDict()
let systemRun = self.systemRunSection(from: root)
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
let raw = agent["policy"] as? String
if let raw, let policy = SystemRunPolicy(rawValue: raw) { return policy }
}
let raw = systemRun["policy"] as? String
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
return policy
}
static func setSystemRunPolicy(_ policy: SystemRunPolicy, agentId: String? = nil) {
self.updateSystemRunSection { systemRun in
if let agentId {
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
entry["policy"] = policy.rawValue
}
return
}
systemRun["policy"] = policy.rawValue
}
}
static func systemRunAutoAllowSkills(agentId: String?) -> Bool? {
let root = self.loadDict()
let systemRun = self.systemRunSection(from: root)
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
if let value = agent["autoAllowSkills"] as? Bool { return value }
}
return systemRun["autoAllowSkills"] as? Bool
}
static func setSystemRunAutoAllowSkills(_ enabled: Bool, agentId: String?) {
self.updateSystemRunSection { systemRun in
if let agentId {
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
entry["autoAllowSkills"] = enabled
}
return
}
systemRun["autoAllowSkills"] = enabled
}
}
static func systemRunAllowlist(agentId: String?) -> [SystemRunAllowlistEntry]? {
let root = self.loadDict()
let systemRun = self.systemRunSection(from: root)
let raw: [Any]? = {
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
return agent["allowlist"] as? [Any]
}
return systemRun["allowlist"] as? [Any]
}()
guard let raw else { return nil }
if raw.allSatisfy({ $0 is String }) {
let legacy = raw.compactMap { $0 as? String }
return legacy.compactMap { key in
let pattern = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !pattern.isEmpty else { return nil }
return SystemRunAllowlistEntry(
pattern: pattern,
enabled: true,
matchKind: .argv,
source: .manual)
}
}
return raw.compactMap { item in
guard let dict = item as? [String: Any] else { return nil }
return SystemRunAllowlistEntry(dict: dict)
}
}
static func setSystemRunAllowlist(_ allowlist: [SystemRunAllowlistEntry], agentId: String?) {
let cleaned = allowlist
.map { $0 }
.filter { !$0.pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
let raw = cleaned.map { $0.asDict() }
self.updateSystemRunSection { systemRun in
if let agentId {
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
if raw.isEmpty {
entry.removeValue(forKey: "allowlist")
} else {
entry["allowlist"] = raw
}
}
return
}
if raw.isEmpty {
systemRun.removeValue(forKey: "allowlist")
} else {
systemRun["allowlist"] = raw
}
}
}
static func systemRunAllowlistStrings() -> [String]? {
let root = self.loadDict()
let systemRun = self.systemRunSection(from: root)
return systemRun["allowlist"] as? [String]
}
static func setSystemRunAllowlistStrings(_ allowlist: [String]) {
let cleaned = allowlist
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
self.updateSystemRunSection { systemRun in
if cleaned.isEmpty {
systemRun.removeValue(forKey: "allowlist")
} else {
systemRun["allowlist"] = cleaned
}
}
}
}
@@ -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")
@@ -747,8 +747,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,17 +436,28 @@ actor MacNodeRuntime {
guard !command.isEmpty else {
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
}
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: params.rawCommand)
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
let policy = SystemRunPolicy.load(agentId: agentId)
let allowlistEntries = SystemRunAllowlistStore.load(agentId: agentId)
let resolution = SystemRunCommandResolution.resolve(command: command, cwd: params.cwd)
let allowlistMatch = SystemRunAllowlistStore.match(
let approvals = ExecApprovalsStore.resolve(agentId: agentId)
let security = approvals.agent.security
let ask = approvals.agent.ask
let askFallback = approvals.agent.askFallback
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,
resolution: resolution,
entries: allowlistEntries)
let autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false
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()
@@ -446,63 +466,138 @@ actor MacNodeRuntime {
skillAllow = false
}
let shouldPrompt: Bool = {
if policy == .never { return false }
if allowlistMatch != nil { return false }
if skillAllow { return false }
return policy == .ask
}()
switch policy {
case .never:
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 shouldPrompt {
let services = await self.mainActorServices()
let decision = await services.confirmSystemRun(context: SystemRunPromptContext(
command: SystemRunAllowlist.displayString(for: command),
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: ExecApprovalDecision? = await ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: displayCommand,
cwd: params.cwd,
host: "node",
security: security.rawValue,
ask: ask.rawValue,
agentId: agentId,
executablePath: resolution?.resolvedPath))
switch decision {
case .allowOnce:
break
case .allowAlways:
if let resolvedPath = resolution?.resolvedPath, !resolvedPath.isEmpty {
_ = SystemRunAllowlistStore.add(pattern: resolvedPath, agentId: agentId)
} else if let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty
{
_ = SystemRunAllowlistStore.add(pattern: raw, 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 nil:
if askFallback == .full {
approvedByAsk = true
} else if askFallback == .allowlist {
if allowlistMatch != nil || skillAllow {
approvedByAsk = true
} else {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "approval-required"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: approval required")
}
case .deny:
} else {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "approval-required"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied")
message: "SYSTEM_RUN_DENIED: approval required")
}
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
}
}
if let match = allowlistMatch {
SystemRunAllowlistStore.markUsed(
entryId: match.id,
command: command,
resolvedPath: resolution?.resolvedPath,
agentId: agentId)
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")
}
let env = Self.sanitizedEnv(params.env)
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,
@@ -511,11 +606,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?
@@ -563,6 +680,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)
@@ -629,10 +822,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,21 +1,7 @@
import AppKit
import ClawdbotKit
import CoreLocation
import Foundation
enum SystemRunDecision: Sendable {
case allowOnce
case allowAlways
case deny
}
struct SystemRunPromptContext: Sendable {
let command: String
let cwd: String?
let agentId: String?
let executablePath: String?
}
@MainActor
protocol MacNodeRuntimeMainActorServices: Sendable {
func recordScreen(
@@ -31,8 +17,6 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
desiredAccuracy: ClawdbotLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision
}
@MainActor
@@ -74,38 +58,4 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
timeoutMs: timeoutMs)
}
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow this command?"
var details = "Clawdbot wants to run:\n\n\(context.command)"
let trimmedCwd = context.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedCwd.isEmpty {
details += "\n\nWorking directory:\n\(trimmedCwd)"
}
let trimmedAgent = context.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedAgent.isEmpty {
details += "\n\nAgent:\n\(trimmedAgent)"
}
let trimmedPath = context.executablePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedPath.isEmpty {
details += "\n\nExecutable:\n\(trimmedPath)"
}
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
}
}
}
+39 -4
View File
@@ -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?
@@ -1,267 +0,0 @@
import Foundation
enum SystemRunAllowlistMatchKind: String {
case glob
case argv
}
enum SystemRunAllowlistSource: String {
case manual
case skill
}
struct SystemRunAllowlistEntry: Identifiable, Hashable {
let id: String
var pattern: String
var enabled: Bool
var matchKind: SystemRunAllowlistMatchKind
var source: SystemRunAllowlistSource?
var skillId: String?
var lastUsedAt: Date?
var lastUsedCommand: String?
var lastUsedPath: String?
init(
id: String = UUID().uuidString,
pattern: String,
enabled: Bool = true,
matchKind: SystemRunAllowlistMatchKind = .glob,
source: SystemRunAllowlistSource? = .manual,
skillId: String? = nil,
lastUsedAt: Date? = nil,
lastUsedCommand: String? = nil,
lastUsedPath: String? = nil)
{
self.id = id
self.pattern = pattern
self.enabled = enabled
self.matchKind = matchKind
self.source = source
self.skillId = skillId
self.lastUsedAt = lastUsedAt
self.lastUsedCommand = lastUsedCommand
self.lastUsedPath = lastUsedPath
}
init?(dict: [String: Any]) {
let id = dict["id"] as? String ?? UUID().uuidString
let pattern = (dict["pattern"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if pattern.isEmpty { return nil }
let enabled = dict["enabled"] as? Bool ?? true
let matchRaw = dict["matchKind"] as? String
let matchKind = SystemRunAllowlistMatchKind(rawValue: matchRaw ?? "") ?? .glob
let sourceRaw = dict["source"] as? String
let source = SystemRunAllowlistSource(rawValue: sourceRaw ?? "")
let skillId = (dict["skillId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let lastUsedAt = (dict["lastUsedAt"] as? Double).map { Date(timeIntervalSince1970: $0) }
let lastUsedCommand = dict["lastUsedCommand"] as? String
let lastUsedPath = dict["lastUsedPath"] as? String
self.init(
id: id,
pattern: pattern,
enabled: enabled,
matchKind: matchKind,
source: source,
skillId: skillId?.isEmpty == true ? nil : skillId,
lastUsedAt: lastUsedAt,
lastUsedCommand: lastUsedCommand,
lastUsedPath: lastUsedPath)
}
func asDict() -> [String: Any] {
var dict: [String: Any] = [
"id": self.id,
"pattern": self.pattern,
"enabled": self.enabled,
"matchKind": self.matchKind.rawValue,
]
if let source = self.source { dict["source"] = source.rawValue }
if let skillId = self.skillId { dict["skillId"] = skillId }
if let lastUsedAt = self.lastUsedAt { dict["lastUsedAt"] = lastUsedAt.timeIntervalSince1970 }
if let lastUsedCommand = self.lastUsedCommand { dict["lastUsedCommand"] = lastUsedCommand }
if let lastUsedPath = self.lastUsedPath { dict["lastUsedPath"] = lastUsedPath }
return dict
}
}
struct SystemRunCommandResolution: Sendable {
let rawExecutable: String
let resolvedPath: String?
let executableName: String
let cwd: String?
static func resolve(command: [String], cwd: String?) -> SystemRunCommandResolution? {
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw
let hasPathSeparator = 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
}
return CommandResolver.findExecutable(named: expanded)
}()
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
return SystemRunCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
}
}
enum SystemRunAllowlistStore {
static func load(agentId: String?) -> [SystemRunAllowlistEntry] {
if let entries = MacNodeConfigFile.systemRunAllowlist(agentId: agentId) {
return entries
}
return []
}
static func save(_ entries: [SystemRunAllowlistEntry], agentId: String?) {
MacNodeConfigFile.setSystemRunAllowlist(entries, agentId: agentId)
}
static func add(pattern: String, agentId: String?, source: SystemRunAllowlistSource = .manual) -> SystemRunAllowlistEntry {
var entries = self.load(agentId: agentId)
let entry = SystemRunAllowlistEntry(pattern: pattern, enabled: true, matchKind: .glob, source: source)
entries.append(entry)
self.save(entries, agentId: agentId)
return entry
}
static func update(_ entry: SystemRunAllowlistEntry, agentId: String?) {
var entries = self.load(agentId: agentId)
guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return }
entries[index] = entry
self.save(entries, agentId: agentId)
}
static func remove(entryId: String, agentId: String?) {
let entries = self.load(agentId: agentId).filter { $0.id != entryId }
self.save(entries, agentId: agentId)
}
static func markUsed(entryId: String, command: [String], resolvedPath: String?, agentId: String?) {
var entries = self.load(agentId: agentId)
guard let index = entries.firstIndex(where: { $0.id == entryId }) else { return }
entries[index].lastUsedAt = Date()
entries[index].lastUsedCommand = SystemRunAllowlist.displayString(for: command)
entries[index].lastUsedPath = resolvedPath
self.save(entries, agentId: agentId)
}
static func match(
command: [String],
resolution: SystemRunCommandResolution?,
entries: [SystemRunAllowlistEntry]) -> SystemRunAllowlistEntry?
{
guard !entries.isEmpty else { return nil }
let argvKey = SystemRunAllowlist.legacyKey(for: command)
let resolvedPath = resolution?.resolvedPath
let executableName = resolution?.executableName
let rawExecutable = resolution?.rawExecutable
for entry in entries {
guard entry.enabled else { continue }
switch entry.matchKind {
case .argv:
if argvKey == entry.pattern { return entry }
case .glob:
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
if pattern.isEmpty { continue }
let hasPath = pattern.contains("/") || pattern.contains("~")
if hasPath {
let target = resolvedPath ?? rawExecutable
if let target, SystemRunGlob.matches(pattern: pattern, target: target) {
return entry
}
} else if let name = executableName, SystemRunGlob.matches(pattern: pattern, target: name) {
return entry
}
}
}
return nil
}
}
enum SystemRunGlob {
static func matches(pattern rawPattern: String, target: String) -> Bool {
let trimmed = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
guard let regex = self.regex(for: expanded) else { return false }
let range = NSRange(location: 0, length: target.utf16.count)
return regex.firstMatch(in: target, options: [], range: range) != nil
}
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)
}
}
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
}
}
@@ -1,78 +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(agentId: String? = nil, from defaults: UserDefaults = .standard) -> SystemRunPolicy {
if let policy = MacNodeConfigFile.systemRunPolicy(agentId: agentId) {
return policy
}
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 legacyKey(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 loadLegacy(from defaults: UserDefaults = .standard) -> Set<String> {
if let allowlist = MacNodeConfigFile.systemRunAllowlistStrings() {
return Set(allowlist)
}
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
MacNodeConfigFile.setSystemRunAllowlistStrings(legacy)
return Set(legacy)
}
return []
}
}
@@ -3,37 +3,35 @@ import Observation
import SwiftUI
struct SystemRunSettingsView: View {
@State private var model = SystemRunSettingsModel()
@State private var tab: SystemRunSettingsTab = .policy
@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("Node Run Commands")
Text("Exec approvals")
.font(.body)
Spacer(minLength: 0)
if self.model.agentIds.count > 1 {
Picker("Agent", selection: Binding(
get: { self.model.selectedAgentId },
set: { self.model.selectAgent($0) }))
{
ForEach(self.model.agentIds, id: \.self) { id in
Text(id).tag(id)
}
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: 160, alignment: .trailing)
}
.pickerStyle(.menu)
.frame(width: 180, alignment: .trailing)
}
Picker("", selection: self.$tab) {
ForEach(SystemRunSettingsTab.allCases) { tab in
ForEach(ExecApprovalsSettingsTab.allCases) { tab in
Text(tab.title).tag(tab)
}
}
.pickerStyle(.segmented)
.frame(width: 280)
.frame(width: 320)
if self.tab == .policy {
self.policyView
@@ -48,19 +46,43 @@ struct SystemRunSettingsView: View {
}
private var policyView: some View {
VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: 8) {
Picker("", selection: Binding(
get: { self.model.policy },
set: { self.model.setPolicy($0) }))
get: { self.model.security },
set: { self.model.setSecurity($0) }))
{
ForEach(SystemRunPolicy.allCases) { policy in
Text(policy.title).tag(policy)
ForEach(ExecSecurity.allCases) { security in
Text(security.title).tag(security)
}
}
.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.")
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)
@@ -79,31 +101,37 @@ struct SystemRunSettingsView: View {
.foregroundStyle(.secondary)
}
HStack(spacing: 8) {
TextField("Add allowlist pattern (supports 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.")
if self.model.isDefaultsScope {
Text("Allowlists are per-agent. Select an agent to edit its allowlist.")
.font(.footnote)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(self.model.entries.enumerated()), id: \.element.id) { index, _ in
SystemRunAllowlistRow(
entry: Binding(
get: { self.model.entries[index] },
set: { self.model.updateEntry($0) }),
onRemove: { self.model.removeEntry($0.id) })
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) })
}
}
}
}
@@ -111,7 +139,7 @@ struct SystemRunSettingsView: View {
}
}
private enum SystemRunSettingsTab: String, CaseIterable, Identifiable {
private enum ExecApprovalsSettingsTab: String, CaseIterable, Identifiable {
case policy
case allowlist
@@ -119,15 +147,15 @@ private enum SystemRunSettingsTab: String, CaseIterable, Identifiable {
var title: String {
switch self {
case .policy: "Policy"
case .policy: "Access"
case .allowlist: "Allowlist"
}
}
}
struct SystemRunAllowlistRow: View {
@Binding var entry: SystemRunAllowlistEntry
let onRemove: (SystemRunAllowlistEntry) -> Void
struct ExecAllowlistRow: View {
@Binding var entry: ExecAllowlistEntry
let onRemove: () -> Void
@State private var draftPattern: String = ""
private static let relativeFormatter: RelativeDateTimeFormatter = {
@@ -139,20 +167,11 @@ struct SystemRunAllowlistRow: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Toggle("", isOn: self.$entry.enabled)
.labelsHidden()
TextField("Pattern", text: self.patternBinding)
.textFieldStyle(.roundedBorder)
if self.entry.matchKind == .argv {
Text("Legacy")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(role: .destructive) {
self.onRemove(self.entry)
self.onRemove()
} label: {
Image(systemName: "trash")
}
@@ -160,11 +179,20 @@ struct SystemRunAllowlistRow: View {
}
if let lastUsedAt = self.entry.lastUsedAt {
Text("Last used \(Self.relativeFormatter.localizedString(for: lastUsedAt, relativeTo: Date()))")
let date = Date(timeIntervalSince1970: lastUsedAt / 1000.0)
Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))")
.font(.caption)
.foregroundStyle(.secondary)
} else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
Text("Last used: \(lastUsedCommand)")
}
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)
}
@@ -180,24 +208,37 @@ struct SystemRunAllowlistRow: View {
set: { newValue in
self.draftPattern = newValue
self.entry.pattern = newValue
if self.entry.matchKind == .argv {
self.entry.matchKind = .glob
}
})
}
}
@MainActor
@Observable
final class SystemRunSettingsModel {
final class ExecApprovalsSettingsModel {
private static let defaultsScopeId = "__defaults__"
var agentIds: [String] = []
var selectedAgentId: String = "main"
var defaultAgentId: String = "main"
var policy: SystemRunPolicy = .ask
var security: ExecSecurity = .deny
var ask: ExecAsk = .onMiss
var askFallback: ExecSecurity = .deny
var autoAllowSkills = false
var entries: [SystemRunAllowlistEntry] = []
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)
@@ -229,6 +270,9 @@ final class SystemRunSettingsModel {
}
self.agentIds = ids
self.defaultAgentId = defaultId ?? "main"
if self.selectedAgentId == Self.defaultsScopeId {
return
}
if !self.agentIds.contains(self.selectedAgentId) {
self.selectedAgentId = self.defaultAgentId
}
@@ -241,43 +285,99 @@ final class SystemRunSettingsModel {
}
func loadSettings(for agentId: String) {
self.policy = SystemRunPolicy.load(agentId: agentId)
self.autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false
self.entries = SystemRunAllowlistStore.load(agentId: agentId)
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 setPolicy(_ policy: SystemRunPolicy) {
self.policy = policy
MacNodeConfigFile.setSystemRunPolicy(policy, agentId: self.selectedAgentId)
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
AppStateStore.shared.systemRunPolicy = policy
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
MacNodeConfigFile.setSystemRunAutoAllowSkills(enabled, agentId: self.selectedAgentId)
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 }
let entry = SystemRunAllowlistEntry(pattern: trimmed, enabled: true, matchKind: .glob, source: .manual)
self.entries.append(entry)
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func updateEntry(_ entry: SystemRunAllowlistEntry) {
guard let index = self.entries.firstIndex(where: { $0.id == entry.id }) else { return }
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
guard !self.isDefaultsScope else { return }
guard self.entries.indices.contains(index) else { return }
self.entries[index] = entry
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func removeEntry(_ id: String) {
self.entries.removeAll { $0.id == id }
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
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 {
@@ -288,4 +388,14 @@ final class SystemRunSettingsModel {
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)
}
}
}
@@ -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,51 @@ 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 ExecApprovalsSnapshot: Codable, Sendable {
public let path: String
public let exists: Bool
public let hash: String
public let file: [String: AnyCodable]
public init(
path: String,
exists: Bool,
hash: String,
file: [String: AnyCodable]
) {
self.path = path
self.exists = exists
self.hash = hash
self.file = file
}
private enum CodingKeys: String, CodingKey {
case path
case exists
case hash
case file
}
}
public struct ChatHistoryParams: Codable, Sendable {
public let sessionkey: String
public let limit: Int?
@@ -134,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")
}
}
}
@@ -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)
}
}
@@ -74,10 +74,6 @@ struct MacNodeRuntimeTests {
{
CLLocation(latitude: 0, longitude: 0)
}
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision {
.allowOnce
}
}
let services = await MainActor.run { FakeMainActorServices() }
@@ -1,43 +0,0 @@
import Foundation
import Testing
@testable import Clawdbot
struct SystemRunAllowlistTests {
@Test func matchUsesResolvedPath() {
let entry = SystemRunAllowlistEntry(pattern: "/opt/homebrew/bin/rg", enabled: true, matchKind: .glob)
let resolution = SystemRunCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = SystemRunAllowlistStore.match(
command: ["rg"],
resolution: resolution,
entries: [entry])
#expect(match?.id == entry.id)
}
@Test func matchUsesBasenameForSimplePattern() {
let entry = SystemRunAllowlistEntry(pattern: "rg", enabled: true, matchKind: .glob)
let resolution = SystemRunCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = SystemRunAllowlistStore.match(
command: ["rg"],
resolution: resolution,
entries: [entry])
#expect(match?.id == entry.id)
}
@Test func matchUsesLegacyArgvKey() {
let key = SystemRunAllowlist.legacyKey(for: ["echo", "hi"])
let entry = SystemRunAllowlistEntry(pattern: key, enabled: true, matchKind: .argv)
let match = SystemRunAllowlistStore.match(
command: ["echo", "hi"],
resolution: nil,
entries: [entry])
#expect(match?.id == entry.id)
}
}
@@ -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,26 +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,
agentId: String? = 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
View 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`
+64
View 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.
+1
View File
@@ -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).
+45 -10
View File
@@ -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.
+166
View 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.
+44
View 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`.
+8
View File
@@ -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.
+3
View File
@@ -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)
+36 -4
View File
@@ -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)
+60 -17
View File
@@ -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
@@ -506,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.
@@ -518,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)
@@ -772,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).
@@ -788,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:
+2
View File
@@ -28,3 +28,5 @@ clawdbot memory search "release checklist"
## Options
- `--verbose`: emit debug logs during memory probes and indexing.
- `--index-mode auto|batch|direct`: override batch usage when indexing (`direct` favors speed; `batch` favors OpenAI Batch pricing).
- `--progress auto|line|log|none`: progress output mode (`log` prints updates even without a TTY).
+96
View 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
View 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)
+1
View File
@@ -19,4 +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)).
+78 -4
View File
@@ -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
+4
View File
@@ -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.
+1 -2
View File
@@ -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.
+60 -3
View File
@@ -89,7 +89,26 @@ 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`).
If you want to use a **custom OpenAI-compatible endpoint** (like Gemini, OpenRouter, or a proxy),
If you want to use **Gemini embeddings** directly, set the provider to `gemini`:
```json5
agents: {
defaults: {
memorySearch: {
provider: "gemini",
model: "gemini-embedding-001", // default
remote: {
apiKey: "${GEMINI_API_KEY}"
}
}
}
}
```
Gemini uses `GEMINI_API_KEY` (or `models.providers.google.apiKey`). Override
`memorySearch.remote.baseUrl` to point at a custom Gemini-compatible endpoint.
If you want to use a **custom OpenAI-compatible endpoint** (like OpenRouter or a proxy),
you can use the `remote` configuration:
```json5
@@ -99,8 +118,8 @@ agents: {
provider: "openai",
model: "text-embedding-3-small",
remote: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
apiKey: "YOUR_GEMINI_API_KEY",
baseUrl: "https://proxy.example/v1",
apiKey: "YOUR_PROXY_KEY",
headers: { "X-Custom-Header": "value" }
}
}
@@ -172,6 +191,44 @@ When enabled, Clawdbot combines:
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
+5
View File
@@ -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:
+25 -3
View File
@@ -54,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).
@@ -93,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",
@@ -122,3 +137,10 @@ Each session entry records where it came from (best-effort) in `origin`:
- `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`).
+13
View File
@@ -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).
+1 -1
View File
@@ -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).
+1
View File
@@ -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):
+9
View File
@@ -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",
@@ -956,6 +962,8 @@
{
"group": "Automation & Hooks",
"pages": [
"hooks",
"hooks/soul-evil",
"automation/auth-monitoring",
"automation/webhook",
"automation/gmail-pubsub",
@@ -1049,6 +1057,7 @@
"reference/RELEASING",
"reference/AGENTS.default",
"reference/templates/AGENTS",
"reference/templates/BOOT",
"reference/templates/BOOTSTRAP",
"reference/templates/HEARTBEAT",
"reference/templates/IDENTITY",
+14 -2
View File
@@ -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
+7 -4
View File
@@ -146,7 +146,11 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
// Session behavior
session: {
scope: "per-sender",
idleMinutes: 60,
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 60
},
heartbeatIdleMinutes: 120,
resetTriggers: ["/new", "/reset"],
store: "~/.clawdbot/agents/default/sessions/sessions.json",
@@ -257,10 +261,9 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
ackMaxChars: 300
},
memorySearch: {
provider: "openai",
model: "text-embedding-004",
provider: "gemini",
model: "gemini-embedding-001",
remote: {
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
apiKey: "${GEMINI_API_KEY}"
}
},
+23 -7
View File
@@ -2416,7 +2416,7 @@ Notes:
### `session`
Controls session scoping, idle expiry, reset triggers, and where the session store is written.
Controls session scoping, reset policy, reset triggers, and where the session store is written.
```json5
{
@@ -2426,7 +2426,16 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"]
},
idleMinutes: 60,
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 60
},
resetByType: {
thread: { mode: "daily", atHour: 4 },
dm: { mode: "idle", idleMinutes: 240 },
group: { mode: "idle", idleMinutes: 120 }
},
resetTriggers: ["/new", "/reset"],
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
// You can override with {agentId} templating:
@@ -2437,12 +2446,12 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
// Max ping-pong reply turns between requester/target (0–5).
maxPingPongTurns: 5
},
sendPolicy: {
rules: [
sendPolicy: {
rules: [
{ action: "deny", match: { channel: "discord", chatType: "group" } }
],
default: "allow"
}
],
default: "allow"
}
}
}
```
@@ -2456,6 +2465,13 @@ Fields:
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer` or `per-channel-peer`.
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
- `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host.
- `mode`: `daily` or `idle` (default: `daily` when `reset` is present).
- `atHour`: local hour (0-23) for the daily reset boundary.
- `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins.
- `resetByType`: per-session overrides for `dm`, `group`, and `thread`.
- If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility.
- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled).
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
+2 -2
View File
@@ -65,8 +65,8 @@ stronger isolation between agents, run them under separate OS users or separate
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
- Requires node pairing (approval + token).
- Controlled on the Mac via **Settings → "Node Run Commands"**: "Always Ask" (default), "Always Allow", or "Never".
- If you don’t want remote execution, set the policy to "Never" and remove node pairing for that Mac.
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
- If you don’t want remote execution, set security to **deny** and remove node pairing for that Mac.
## Dynamic skills (watcher / remote nodes)
+6 -2
View File
@@ -239,11 +239,15 @@ Known issue: When you send an image with ONLY a mention (no other text), WhatsAp
ls -la ~/.clawdbot/agents/<agentId>/sessions/
```
**Check 2:** Is `idleMinutes` too short?
**Check 2:** Is the reset window too short?
```json
{
"session": {
"idleMinutes": 10080 // 7 days
"reset": {
"mode": "daily",
"atHour": 4,
"idleMinutes": 10080 // 7 days
}
}
}
```
+75 -2
View File
@@ -14,6 +14,8 @@ Hooks are small scripts that run when something happens. There are two kinds:
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
- **Webhooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
Hooks can also be bundled inside plugins; see [Plugins](/plugin#plugin-hooks).
Common uses:
- Save a memory snapshot when you reset a session
@@ -35,10 +37,12 @@ The hooks system allows you to:
### Bundled Hooks
Clawdbot ships with two bundled hooks that are automatically discovered:
Clawdbot ships with four bundled hooks that are automatically discovered:
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled)
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
List available hooks:
@@ -192,7 +196,7 @@ Each event includes:
```typescript
{
type: 'command' | 'session' | 'agent',
type: 'command' | 'session' | 'agent' | 'gateway',
action: string, // e.g., 'new', 'reset', 'stop'
sessionKey: string, // Session identifier
timestamp: Date, // When the event occurred
@@ -203,6 +207,8 @@ Each event includes:
sessionFile?: string,
commandSource?: string, // e.g., 'whatsapp', 'telegram'
senderId?: string,
workspaceDir?: string,
bootstrapFiles?: WorkspaceBootstrapFile[],
cfg?: ClawdbotConfig
}
}
@@ -219,6 +225,16 @@ Triggered when agent commands are issued:
- **`command:reset`**: When `/reset` command is issued
- **`command:stop`**: When `/stop` command is issued
### Agent Events
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
### Gateway Events
Triggered when the gateway starts:
- **`gateway:startup`**: After channels start and hooks are loaded
### Future Events
Planned event types:
@@ -497,6 +513,62 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
clawdbot hooks enable command-logger
```
### soul-evil
Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance.
**Events**: `agent:bootstrap`
**Docs**: [SOUL Evil Hook](/hooks/soul-evil)
**Output**: No files written; swaps happen in-memory only.
**Enable**:
```bash
clawdbot hooks enable soul-evil
```
**Config**:
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"soul-evil": {
"enabled": true,
"file": "SOUL_EVIL.md",
"chance": 0.1,
"purge": { "at": "21:00", "duration": "15m" }
}
}
}
}
}
```
### boot-md
Runs `BOOT.md` when the gateway starts (after channels start).
Internal hooks must be enabled for this to run.
**Events**: `gateway:startup`
**Requirements**: `workspace.dir` must be configured
**What it does**:
1. Reads `BOOT.md` from your workspace
2. Runs the instructions via the agent runner
3. Sends any requested outbound messages via the message tool
**Enable**:
```bash
clawdbot hooks enable boot-md
```
## Best Practices
### Keep Handlers Fast
@@ -569,6 +641,7 @@ The gateway logs hook loading at startup:
```
Registered hook: session-memory -> command:new
Registered hook: command-logger -> command
Registered hook: boot-md -> gateway:startup
```
### Check Discovery
+68
View File
@@ -0,0 +1,68 @@
---
summary: "SOUL Evil hook (swap SOUL.md with SOUL_EVIL.md)"
read_when:
- You want to enable or tune the SOUL Evil hook
- You want a purge window or random-chance persona swap
---
# SOUL Evil Hook
The SOUL Evil hook swaps the **injected** `SOUL.md` content with `SOUL_EVIL.md` during
a purge window or by random chance. It does **not** modify files on disk.
## How It Works
When `agent:bootstrap` runs, the hook can replace the `SOUL.md` content in memory
before the system prompt is assembled. If `SOUL_EVIL.md` is missing or empty,
Clawdbot logs a warning and keeps the normal `SOUL.md`.
Sub-agent runs do **not** include `SOUL.md` in their bootstrap files, so this hook
has no effect on sub-agents.
## Enable
```bash
clawdbot hooks enable soul-evil
```
Then set the config:
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"soul-evil": {
"enabled": true,
"file": "SOUL_EVIL.md",
"chance": 0.1,
"purge": { "at": "21:00", "duration": "15m" }
}
}
}
}
}
```
Create `SOUL_EVIL.md` in the agent workspace root (next to `SOUL.md`).
## Options
- `file` (string): alternate SOUL filename (default: `SOUL_EVIL.md`)
- `chance` (number 0–1): random chance per run to use `SOUL_EVIL.md`
- `purge.at` (HH:mm): daily purge start (24-hour clock)
- `purge.duration` (duration): window length (e.g. `30s`, `10m`, `1h`)
**Precedence:** purge window wins over chance.
**Timezone:** uses `agents.defaults.userTimezone` when set; otherwise host timezone.
## Notes
- No files are written or modified on disk.
- If `SOUL.md` is not in the bootstrap list, the hook does nothing.
## See Also
- [Hooks](/hooks)
+50 -3
View File
@@ -147,9 +147,10 @@ Notes:
- The permission prompt must be accepted on the Android device before the capability is advertised.
- Wi-Fi-only devices without telephony will not advertise `sms.send`.
## System commands (mac node)
## System commands (node host / mac node)
The macOS node exposes `system.run` and `system.notify`.
The macOS node exposes `system.run`, `system.notify`, and `system.execApprovals.get/set`.
The headless node host exposes `system.run`, `system.which`, and `system.execApprovals.get/set`.
Examples:
@@ -163,12 +164,58 @@ Notes:
- `system.notify` respects notification permission state on the macOS app.
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
- `system.run` is gated by the macOS app policy (Settings → "Node Run Commands"): "Always Ask" prompts per command, "Always Allow" runs without prompts, and "Never" disables the tool. Denied prompts return `SYSTEM_RUN_DENIED`; disabled returns `SYSTEM_RUN_DISABLED`.
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
- On headless node host, `system.run` is gated by exec approvals (`~/.clawdbot/exec-approvals.json`).
## Exec node binding
When multiple nodes are available, you can bind exec to a specific node.
This sets the default node for `exec host=node` (and can be overridden per agent).
Global default:
```bash
clawdbot config set tools.exec.node "node-id-or-name"
```
Per-agent override:
```bash
clawdbot config get agents.list
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
```
Unset to allow any node:
```bash
clawdbot config unset tools.exec.node
clawdbot config unset agents.list[0].tools.exec.node
```
## Permissions map
Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted).
## Headless node host (cross-platform)
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
bridge and exposes `system.run` / `system.which`. This is useful on Linux/Windows
or for running a minimal node alongside a server.
Start it:
```bash
clawdbot node start --host <gateway-host> --port 18790
```
Notes:
- Pairing is still required (the Gateway will show a node approval prompt).
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
(see [Exec approvals](/tools/exec-approvals)).
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
## Mac node mode
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
+23
View File
@@ -104,6 +104,29 @@ Rules:
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
**active reply model** when its provider supports the capability.
### Auto-enable audio (when keys exist)
If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
audio provider keys configured, Clawdbot will **auto-enable audio transcription**
even when you haven’t listed models explicitly.
Providers checked (in order):
1) OpenAI
2) Groq
3) Deepgram
To disable this behavior, set:
```json5
{
tools: {
media: {
audio: {
enabled: false
}
}
}
}
```
## Capabilities (optional)
If you set `capabilities`, the entry only runs for those media types. For shared
lists, Clawdbot can infer defaults:
+3 -11
View File
@@ -14,15 +14,7 @@ Before building the app, ensure you have the following installed:
1. **Xcode 26.2+**: Required for Swift development.
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
## 1. Initialize Submodules
Clawdbot depends on several submodules (like `Peekaboo`). You must initialize these recursively:
```bash
git submodule update --init --recursive
```
## 2. Install Dependencies
## 1. Install Dependencies
Install the project-wide dependencies:
@@ -30,7 +22,7 @@ Install the project-wide dependencies:
pnpm install
```
## 3. Build and Package the App
## 2. Build and Package the App
To build the macOS app and package it into `dist/Clawdbot.app`, run:
@@ -42,7 +34,7 @@ If you don't have an Apple Developer ID certificate, the script will automatical
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
## 4. Install the CLI
## 3. Install the CLI
The macOS app expects a global `clawdbot` CLI install to manage background tasks.
+1 -1
View File
@@ -2,7 +2,7 @@
summary: "PeekabooBridge integration for macOS UI automation"
read_when:
- Hosting PeekabooBridge in Clawdbot.app
- Integrating Peekaboo as a submodule
- Integrating Peekaboo via Swift Package Manager
- Changing PeekabooBridge protocol/paths
---
# Peekaboo Bridge (macOS UI automation)
+18 -15
View File
@@ -54,29 +54,32 @@ The macOS app presents itself as a node. Common commands:
The node reports a `permissions` map so agents can decide what’s allowed.
## Node run policy + allowlist
## Exec approvals (system.run)
`system.run` is controlled by the macOS app **Node Run Commands** policy:
- `Always Ask`: prompt per command (default).
- `Always Allow`: run without prompts.
- `Never`: disable `system.run` (tool not advertised).
The policy + allowlist live on the Mac in:
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
Security + ask + allowlist are stored locally on the Mac in:
```
~/.clawdbot/macos-node.json
~/.clawdbot/exec-approvals.json
```
Schema:
Example:
```json
{
"systemRun": {
"policy": "ask",
"allowlist": [
"[\"/bin/echo\",\"hello\"]"
]
"version": 1,
"defaults": {
"security": "deny",
"ask": "on-miss"
},
"agents": {
"main": {
"security": "allowlist",
"ask": "on-miss",
"allowlist": [
{ "pattern": "/opt/homebrew/bin/rg" }
]
}
}
}
```
+26 -18
View File
@@ -37,6 +37,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
- Memory (LanceDB) — bundled long-term memory plugin (auto-recall/capture; set `plugins.slots.memory = "memory-lancedb"`)
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
@@ -45,7 +46,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
register:
@@ -58,6 +59,7 @@ register:
- Optional config validation
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
## Discovery & precedence
@@ -214,6 +216,27 @@ Plugins export either:
- A function: `(api) => { ... }`
- An object: `{ id, name, configSchema, register(api) { ... } }`
## Plugin hooks
Plugins can ship hooks and register them at runtime. This lets a plugin bundle
event-driven automation without a separate hook pack install.
### Example
```
import { registerPluginHooksFromDir } from "clawdbot/plugin-sdk";
export default function register(api) {
registerPluginHooksFromDir(api, "./hooks");
}
```
Notes:
- Hook directories follow the normal hook structure (`HOOK.md` + `handler.ts`).
- Hook eligibility rules still apply (OS/bins/env/config requirements).
- Plugin-managed hooks show up in `clawdbot hooks list` with `plugin:<id>`.
- You cannot enable/disable plugin-managed hooks via `clawdbot hooks`; enable/disable the plugin instead.
## Provider plugins (model auth)
Plugins can register **model provider auth** flows so users can run OAuth or
@@ -379,24 +402,9 @@ export default function (api) {
Load the plugin (extensions dir or `plugins.load.paths`), restart the gateway,
then configure `channels.<id>` in your config.
### Register a tool
### Agent tools
```ts
import { Type } from "@sinclair/typebox";
export default function (api) {
api.registerTool({
name: "my_tool",
description: "Do a thing",
parameters: Type.Object({
input: Type.String(),
}),
async execute(_id, params) {
return { content: [{ type: "text", text: params.input }] };
},
});
}
```
See the dedicated guide: [Plugin agent tools](/plugins/agent-tools).
### Register a gateway RPC method
+94
View File
@@ -0,0 +1,94 @@
---
summary: "Write agent tools in a plugin (schemas, optional tools, allowlists)"
read_when:
- You want to add a new agent tool in a plugin
- You need to make a tool opt-in via allowlists
---
# Plugin agent tools
Clawdbot plugins can register **agent tools** (JSON‑schema functions) that are exposed
to the LLM during agent runs. Tools can be **required** (always available) or
**optional** (opt‑in).
Agent tools are configured under `tools` in the main config, or per‑agent under
`agents.list[].tools`. The allowlist/denylist policy controls which tools the agent
can call.
## Basic tool
```ts
import { Type } from "@sinclair/typebox";
export default function (api) {
api.registerTool({
name: "my_tool",
description: "Do a thing",
parameters: Type.Object({
input: Type.String(),
}),
async execute(_id, params) {
return { content: [{ type: "text", text: params.input }] };
},
});
}
```
## Optional tool (opt‑in)
Optional tools are **never** auto‑enabled. Users must add them to an agent
allowlist.
```ts
export default function (api) {
api.registerTool(
{
name: "workflow_tool",
description: "Run a local workflow",
parameters: {
type: "object",
properties: {
pipeline: { type: "string" },
},
required: ["pipeline"],
},
async execute(_id, params) {
return { content: [{ type: "text", text: params.pipeline }] };
},
},
{ optional: true },
);
}
```
Enable optional tools in `agents.list[].tools.allow` (or global `tools.allow`):
```json5
{
agents: {
list: [
{
id: "main",
tools: {
allow: [
"workflow_tool", // specific tool name
"workflow", // plugin id (enables all tools from that plugin)
"group:plugins" // all plugin tools
]
}
}
]
}
}
```
Other config knobs that affect tool availability:
- `tools.profile` / `agents.list[].tools.profile` (base allowlist)
- `tools.byProvider` / `agents.list[].tools.byProvider` (provider‑specific allow/deny)
- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed)
## Rules + tips
- Tool names must **not** clash with core tool names; conflicting tools are skipped.
- Plugin ids used in allowlists must not clash with core tool names.
- Prefer `optional: true` for tools that trigger side effects or require extra
binaries/credentials.
+21
View File
@@ -6,6 +6,27 @@ read_when:
---
# Github Copilot
## What is GitHub Copilot?
GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot
models for your GitHub account and plan. Clawdbot can use Copilot as a model
provider in two different ways.
## Two ways to use Copilot in Clawdbot
### 1) Built-in GitHub Copilot provider (`github-copilot`)
Use the native device-login flow to obtain a GitHub token, then exchange it for
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
because it does not require VS Code.
### 2) Copilot Proxy plugin (`copilot-proxy`)
Use the **Copilot Proxy** VS Code extension as a local bridge. Clawdbot talks to
the proxy’s `/v1` endpoint and uses the model list you configure there. Choose
this when you already run Copilot Proxy in VS Code or need to route through it.
You must enable the plugin and keep the VS Code extension running.
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
the GitHub device flow, saves an auth profile, and updates your config to use that
profile.
+252
View File
@@ -0,0 +1,252 @@
---
summary: "Refactor plan: exec host routing, node approvals, and headless runner"
read_when:
- Designing exec host routing or exec approvals
- Implementing node runner + UI IPC
- Adding exec host security modes and slash commands
---
# Exec host refactor plan
## Goals
- Add `exec.host` + `exec.security` to route execution across **sandbox**, **gateway**, and **node**.
- Keep defaults **safe**: no cross-host execution unless explicitly enabled.
- Split execution into a **headless runner service** with optional UI (macOS app) via local IPC.
- Provide **per-agent** policy, allowlist, ask mode, and node binding.
- Support **ask modes** that work *with* or *without* allowlists.
- Cross-platform: Unix socket + token auth (macOS/Linux/Windows parity).
## Non-goals
- No legacy allowlist migration or legacy schema support.
- No PTY/streaming for node exec (aggregated output only).
- No new network layer beyond the existing Bridge + Gateway.
## Decisions (locked)
- **Config keys:** `exec.host` + `exec.security` (per-agent override allowed).
- **Elevation:** keep `/elevated` as an alias for gateway full access.
- **Ask default:** `on-miss`.
- **Approvals store:** `~/.clawdbot/exec-approvals.json` (JSON, no legacy migration).
- **Runner:** headless system service; UI app hosts a Unix socket for approvals.
- **Node identity:** use existing `nodeId`.
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
## Key concepts
### Host
- `sandbox`: Docker exec (current behavior).
- `gateway`: exec on gateway host.
- `node`: exec on node runner via Bridge (`system.run`).
### Security mode
- `deny`: always block.
- `allowlist`: allow only matches.
- `full`: allow everything (equivalent to elevated).
### Ask mode
- `off`: never ask.
- `on-miss`: ask only when allowlist does not match.
- `always`: ask every time.
Ask is **independent** of allowlist; allowlist can be used with `always` or `on-miss`.
### Policy resolution (per exec)
1) Resolve `exec.host` (tool param → agent override → global default).
2) Resolve `exec.security` and `exec.ask` (same precedence).
3) If host is `sandbox`, proceed with local sandbox exec.
4) If host is `gateway` or `node`, apply security + ask policy on that host.
## Default safety
- Default `exec.host = sandbox`.
- Default `exec.security = deny` for `gateway` and `node`.
- Default `exec.ask = on-miss` (only relevant if security allows).
- If no node binding is set, **agent may target any node**, but only if policy allows it.
## Config surface
### Tool parameters
- `exec.host` (optional): `sandbox | gateway | node`.
- `exec.security` (optional): `deny | allowlist | full`.
- `exec.ask` (optional): `off | on-miss | always`.
- `exec.node` (optional): node id/name to use when `host=node`.
### Config keys (global)
- `tools.exec.host`
- `tools.exec.security`
- `tools.exec.ask`
- `tools.exec.node` (default node binding)
### Config keys (per agent)
- `agents.list[].tools.exec.host`
- `agents.list[].tools.exec.security`
- `agents.list[].tools.exec.ask`
- `agents.list[].tools.exec.node`
### Alias
- `/elevated on` = set `tools.exec.host=gateway`, `tools.exec.security=full` for the agent session.
- `/elevated off` = restore previous exec settings for the agent session.
## Approvals store (JSON)
Path: `~/.clawdbot/exec-approvals.json`
Purpose:
- Local policy + allowlists for the **execution host** (gateway or node runner).
- Ask fallback when no UI is available.
- IPC credentials for UI clients.
Proposed schema (v1):
```json
{
"version": 1,
"socket": {
"path": "~/.clawdbot/exec-approvals.sock",
"token": "base64-opaque-token"
},
"defaults": {
"security": "deny",
"ask": "on-miss",
"askFallback": "deny"
},
"agents": {
"agent-id-1": {
"security": "allowlist",
"ask": "on-miss",
"allowlist": [
{
"pattern": "~/Projects/**/bin/rg",
"lastUsedAt": 0,
"lastUsedCommand": "rg -n TODO",
"lastResolvedPath": "/Users/user/Projects/.../bin/rg"
}
]
}
}
}
```
Notes:
- No legacy allowlist formats.
- `askFallback` applies only when `ask` is required and no UI is reachable.
- File permissions: `0600`.
## Runner service (headless)
### Role
- Enforce `exec.security` + `exec.ask` locally.
- Execute system commands and return output.
- Emit Bridge events for exec lifecycle (optional but recommended).
### Service lifecycle
- Launchd/daemon on macOS; system service on Linux/Windows.
- Approvals JSON is local to the execution host.
- UI hosts a local Unix socket; runners connect on demand.
## UI integration (macOS app)
### IPC
- Unix socket at `~/.clawdbot/exec-approvals.sock`.
- Runner connects and sends an approval request; UI responds with a decision.
- Token stored in `exec-approvals.json`.
### Ask flow
1) Runner receives `system.run` from gateway.
2) If ask required, runner connects to the socket and sends a prompt request.
3) UI shows dialog; returns decision.
4) Runner enforces decision and proceeds.
If UI missing:
- Apply `askFallback` (`deny|allowlist|full`).
## Node identity + binding
- Use existing `nodeId` from Bridge pairing.
- Binding model:
- `tools.exec.node` restricts the agent to a specific node.
- If unset, agent can pick any node (policy still enforces defaults).
- Node selection resolution:
- `nodeId` exact match
- `displayName` (normalized)
- `remoteIp`
- `nodeId` prefix (>= 6 chars)
## Eventing
### Who sees events
- System events are **per session** and shown to the agent on the next prompt.
- Stored in the gateway in-memory queue (`enqueueSystemEvent`).
### Event text
- `Exec started (node=<id>, id=<runId>)`
- `Exec finished (node=<id>, id=<runId>, code=<code>)` + optional output tail
- `Exec denied (node=<id>, id=<runId>, <reason>)`
### Transport
Option A (recommended):
- Runner sends Bridge `event` frames `exec.started` / `exec.finished`.
- Gateway `handleBridgeEvent` maps these into `enqueueSystemEvent`.
Option B:
- Gateway `exec` tool handles lifecycle directly (synchronous only).
## Exec flows
### Sandbox host
- Existing `exec` behavior (Docker or host when unsandboxed).
- PTY supported in non-sandbox mode only.
### Gateway host
- Gateway process executes on its own machine.
- Enforces local `exec-approvals.json` (security/ask/allowlist).
### Node host
- Gateway calls `node.invoke` with `system.run`.
- Runner enforces local approvals.
- Runner returns aggregated stdout/stderr.
- Optional Bridge events for start/finish/deny.
## Output caps
- Cap combined stdout+stderr at **200k**; keep **tail 20k** for events.
- Truncate with a clear suffix (e.g., `"… (truncated)"`).
## Slash commands
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>`
- Per-agent, per-session overrides; non-persistent unless saved via config.
- `/elevated on|off` remains a shortcut for `host=gateway security=full`.
## Cross-platform story
- The runner service is the portable execution target.
- UI is optional; if missing, `askFallback` applies.
- Windows/Linux support the same approvals JSON + socket protocol.
## Implementation phases
### Phase 1: config + exec routing
- Add config schema for `exec.host`, `exec.security`, `exec.ask`, `exec.node`.
- Update tool plumbing to respect `exec.host`.
- Add `/exec` slash command and keep `/elevated` alias.
### Phase 2: approvals store + gateway enforcement
- Implement `exec-approvals.json` reader/writer.
- Enforce allowlist + ask modes for `gateway` host.
- Add output caps.
### Phase 3: node runner enforcement
- Update node runner to enforce allowlist + ask.
- Add Unix socket prompt bridge to macOS app UI.
- Wire `askFallback`.
### Phase 4: events
- Add node → gateway Bridge events for exec lifecycle.
- Map to `enqueueSystemEvent` for agent prompts.
### Phase 5: UI polish
- Mac app: allowlist editor, per-agent switcher, ask policy UI.
- Node binding controls (optional).
## Testing plan
- Unit tests: allowlist matching (glob + case-insensitive).
- Unit tests: policy resolution precedence (tool param → agent override → global).
- Integration tests: node runner deny/allow/ask flows.
- Bridge event tests: node event → system event routing.
## Open risks
- UI unavailability: ensure `askFallback` is respected.
- Long-running commands: rely on timeout + output caps.
- Multi-node ambiguity: error unless node binding or explicit node param.
## Related docs
- [Exec tool](/tools/exec)
- [Exec approvals](/tools/exec-approvals)
- [Nodes](/nodes)
- [Elevated mode](/tools/elevated)
+116
View File
@@ -0,0 +1,116 @@
---
summary: "Audit what can spend money, which keys are used, and how to view usage"
read_when:
- You want to understand which features may call paid APIs
- You need to audit keys, costs, and usage visibility
- You’re explaining /status or /usage cost reporting
---
# API usage & costs
This doc lists **features that can invoke API keys** and where their costs show up. It focuses on
Clawdbot features that can generate provider usage or paid API calls.
## Where costs show up (chat + CLI)
**Per-session cost snapshot**
- `/status` shows the current session model, context usage, and last response tokens.
- If the model uses **API-key auth**, `/status` also shows **estimated cost** for the last reply.
**Per-message cost footer**
- `/usage full` appends a usage footer to every reply, including **estimated cost** (API-key only).
- `/usage tokens` shows tokens only; OAuth flows hide dollar cost.
**CLI usage windows (provider quotas)**
- `clawdbot status --usage` and `clawdbot channels list` show provider **usage windows**
(quota snapshots, not per-message costs).
See [Token use & costs](/token-use) for details and examples.
## How keys are discovered
Clawdbot can pick up credentials from:
- **Auth profiles** (per-agent, stored in `auth-profiles.json`).
- **Environment variables** (e.g. `OPENAI_API_KEY`, `BRAVE_API_KEY`, `FIRECRAWL_API_KEY`).
- **Config** (`models.providers.*.apiKey`, `tools.web.search.*`, `tools.web.fetch.firecrawl.*`,
`memorySearch.*`, `talk.apiKey`).
- **Skills** (`skills.entries.<name>.apiKey`) which may export keys to the skill process env.
## Features that can spend keys
### 1) Core model responses (chat + tools)
Every reply or tool call uses the **current model provider** (OpenAI, Anthropic, etc). This is the
primary source of usage and cost.
See [Models](/providers/models) for pricing config and [Token use & costs](/token-use) for display.
### 2) Media understanding (audio/image/video)
Inbound media can be summarized/transcribed before the reply runs. This uses model/provider APIs.
- Audio: OpenAI / Groq / Deepgram (now **auto-enabled** when keys exist).
- Image: OpenAI / Anthropic / Google.
- Video: Google.
See [Media understanding](/nodes/media-understanding).
### 3) Memory embeddings + semantic search
Semantic memory search uses **embedding APIs** when configured for remote providers:
- `memorySearch.provider = "openai"` → OpenAI embeddings
- `memorySearch.provider = "gemini"` → Gemini embeddings
- Optional fallback to OpenAI if local embeddings fail
You can keep it local with `memorySearch.provider = "local"` (no API usage).
See [Memory](/concepts/memory).
### 4) Web search tool (Brave / Perplexity via OpenRouter)
`web_search` uses API keys and may incur usage charges:
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Perplexity** (via OpenRouter): `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
**Brave free tier (generous):**
- **2,000 requests/month**
- **1 request/second**
- **Credit card required** for verification (no charge unless you upgrade)
See [Web tools](/tools/web).
### 5) Web fetch tool (Firecrawl)
`web_fetch` can call **Firecrawl** when an API key is present:
- `FIRECRAWL_API_KEY` or `tools.web.fetch.firecrawl.apiKey`
If Firecrawl isn’t configured, the tool falls back to direct fetch + readability (no paid API).
See [Web tools](/tools/web).
### 6) Provider usage snapshots (status/health)
Some status commands call **provider usage endpoints** to display quota windows or auth health.
These are typically low-volume calls but still hit provider APIs:
- `clawdbot status --usage`
- `clawdbot models status --json`
See [Models CLI](/cli/models).
### 7) Compaction safeguard summarization
The compaction safeguard can summarize session history using the **current model**, which
invokes provider APIs when it runs.
See [Session management + compaction](/reference/session-management-compaction).
### 8) Model scan / probe
`clawdbot models scan` can probe OpenRouter models and uses `OPENROUTER_API_KEY` when
probing is enabled.
See [Models CLI](/cli/models).
### 9) Talk (speech)
Talk mode can invoke **ElevenLabs** when configured:
- `ELEVENLABS_API_KEY` or `talk.apiKey`
See [Talk mode](/nodes/talk).
### 10) Skills (third-party APIs)
Skills can store `apiKey` in `skills.entries.<name>.apiKey`. If a skill uses that key for external
APIs, it can incur costs according to the skill’s provider.
See [Skills](/tools/skills).
@@ -82,7 +82,8 @@ Each `sessionKey` points at a current `sessionId` (the transcript file that cont
Rules of thumb:
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
- **Idle expiry** (`session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window.
- **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary.
- **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins.
Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`.
+9
View File
@@ -0,0 +1,9 @@
---
summary: "Workspace template for BOOT.md"
read_when:
- Adding a BOOT.md checklist
---
# BOOT.md
Add short, explicit instructions for what Clawdbot should do on startup (enable `hooks.internal.enabled`).
If the task sends a message, use the message tool and then reply with NO_REPLY.
+5 -1
View File
@@ -160,7 +160,11 @@ Example:
session: {
scope: "per-sender",
resetTriggers: ["/new", "/reset"],
idleMinutes: 10080
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 10080
}
}
}
```
+28 -5
View File
@@ -60,6 +60,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Remote gateways + nodes](#remote-gateways-nodes)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [Do nodes run a gateway daemon?](#do-nodes-run-a-gateway-daemon)
- [Can I run a headless node host without the macOS app?](#can-i-run-a-headless-node-host-without-the-macos-app)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
- [How do I set up Tailscale on a VPS and connect from my Mac?](#how-do-i-set-up-tailscale-on-a-vps-and-connect-from-my-mac)
@@ -405,7 +406,7 @@ You have three supported patterns:
Run the Gateway where the macOS binaries exist, then connect from Linux in [remote mode](#how-do-i-run-clawdbot-in-remote-mode-client-connects-to-a-gateway-elsewhere) or over Tailscale. The skills load normally because the Gateway host is macOS.
**Option B - use a macOS node (no SSH).**
Run the Gateway on Linux, pair a macOS node (menubar app), and set **Node Run Commands** to "Always Ask" or "Always Allow" on the Mac. Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Always Ask", approving "Always Allow" in the prompt adds that command to the allowlist.
Run the Gateway on Linux, pair a macOS node (menubar app), and configure **Exec approvals** (Settings → Exec approvals) to "Ask" or "Always Allow". Clawdbot can treat macOS-only skills as eligible when the required binaries exist on the node. The agent runs those skills via the `nodes` tool. If you choose "Ask", selecting "Always Allow" in the prompt adds that command to the allowlist.
**Option C - proxy macOS binaries over SSH (advanced).**
Keep the Gateway on Linux, but make the required CLI binaries resolve to SSH wrappers that run on a Mac. Then override the skill to allow Linux so it stays eligible.
@@ -742,6 +743,23 @@ to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
A full restart is required for `gateway`, `bridge`, `discovery`, and `canvasHost` changes.
### Can I run a headless node host without the macOS app?
Yes. The headless node host is a **command-only** node that exposes `system.run` / `system.which`
without any UI. It has no screen/camera/notify support (use the macOS app for those).
Start it:
```bash
clawdbot node start --host <gateway-host> --port 18790
```
Notes:
- Pairing is still required (`clawdbot nodes pending` → `clawdbot nodes approve <requestId>`).
- Exec approvals still apply via `~/.clawdbot/exec-approvals.json`.
- If prompts are enabled but no companion UI is reachable, `askFallback` decides (default: deny).
Docs: [Node CLI](/cli/node), [Nodes](/nodes), [Exec approvals](/tools/exec-approvals).
### Is there an API / RPC way to apply config?
Yes. `config.apply` validates + writes the full config and restarts the Gateway as part of the operation.
@@ -880,14 +898,19 @@ Send `/new` or `/reset` as a standalone message. See [Session management](/conce
### Do sessions reset automatically if I never send `/new`?
Yes. Sessions expire after `session.idleMinutes` (default **60**). The **next**
message starts a fresh session id for that chat key. This does not delete
transcripts — it just starts a new session.
Yes. By default sessions reset daily at **4:00 AM local time** on the gateway host.
You can also add an idle window; when both daily and idle resets are configured,
whichever expires first starts a new session id on the next message. This does
not delete transcripts — it just starts a new session.
```json5
{
session: {
idleMinutes: 240
reset: {
mode: "daily",
atHour: 4,
idleMinutes: 240
}
}
}
```
+2 -2
View File
@@ -42,13 +42,13 @@ Use these in chat:
- `/status` → **emoji‑rich status card** with the session model, context usage,
last response input/output tokens, and **estimated cost** (API key only).
- `/cost on|off` → appends a **per-response usage line** to every reply.
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
- Persists per session (stored as `responseUsage`).
- OAuth auth **hides cost** (tokens only).
Other surfaces:
- **TUI/Web TUI:** `/status` + `/cost` are supported.
- **TUI/Web TUI:** `/status` + `/usage` are supported.
- **CLI:** `clawdbot status --usage` and `clawdbot channels list` show
provider quota windows (not per-response costs).
+5 -15
View File
@@ -6,9 +6,8 @@ read_when:
# Elevated Mode (/elevated directives)
## What it does
- Elevated mode allows the exec tool to run with elevated privileges when the feature is available and the sender is approved.
- The bash chat command (`!`; `/bash` alias) uses the same `tools.elevated` allowlists because it always runs on the host.
- **Optional for sandboxed agents**: elevated only changes behavior when the agent is running in a sandbox. If the agent already runs unsandboxed, elevated is effectively a no-op.
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full`.
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`.
- Only `on|off` are accepted; anything else returns a hint and does not change state.
@@ -17,18 +16,9 @@ read_when:
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
- **Inline directive**: `/elevated on` inside a message applies to that message only.
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
- **Host execution**: elevated runs `exec` on the host (bypasses sandbox).
- **Unsandboxed agents**: when there is no sandbox to bypass, elevated does not change where `exec` runs.
- **Host execution**: elevated forces `exec` onto the gateway host with full security.
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
- **Not skill-scoped**: elevated cannot be limited to a specific skill; it only changes `exec` location.
Note:
- Sandbox on: `/elevated on` runs that `exec` command on the host.
- Sandbox off: `/elevated on` does not change execution (already on host).
## When elevated matters
- Only impacts `exec` when the agent is running sandboxed (it drops the sandbox for that command).
- For unsandboxed agents, elevated does not change execution; it only affects gating, logging, and status.
## Resolution order
1. Inline directive on the message (applies only to that message).
@@ -38,7 +28,7 @@ Note:
## Setting a session default
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`.
- Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`).
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error (runtime sandboxed/direct + failing config key paths) and does not change session state.
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state.
- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level.
## Availability + allowlists
+100 -59
View File
@@ -1,39 +1,88 @@
---
summary: "Exec approvals, allowlists, and sandbox escape prompts in the macOS app"
summary: "Exec approvals, allowlists, and sandbox escape prompts"
read_when:
- Configuring exec approvals or allowlists
- Implementing exec approval UX in the macOS app
- Reviewing sandbox escape prompts and implications
---
# Exec approvals (macOS app)
# Exec approvals
Exec approvals are the **macOS companion app** guardrail for running host
commands from sandboxed agents. Think of it as a per-agent “run this on my Mac”
approval layer: the agent asks, the app decides, and the command runs (or not).
This is **in addition** to tool policy and elevated gating; all of those checks
must pass before a command can run.
Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
commands are allowed only when policy + allowlist + (optional) user approval all agree.
Exec approvals are **in addition** to tool policy and elevated gating.
If you are **not** running the macOS companion app, exec approvals are
unavailable and `system.run` requests will be rejected with a message that a
companion app is required.
If the companion app UI is **not available**, any request that requires a prompt is
resolved by the **ask fallback** (default: deny).
## Settings
## Where it applies
In the macOS app, each agent has an **Exec approvals** setting:
Exec approvals are enforced locally on the execution host:
- **gateway host** → `clawdbot` process on the gateway machine
- **node host** → node runner (macOS companion app or headless node host)
- **Deny**: block all host exec requests from the agent.
- **Always ask**: show a confirmation dialog for each host exec request.
- **Always allow**: run host exec requests without prompting.
## Settings and storage
Optional toggles:
- **Auto-allow skill CLIs**: when enabled, CLIs referenced by known skills are
treated as allowlisted (see below).
Approvals live in a local JSON file on the execution host:
`~/.clawdbot/exec-approvals.json`
Example schema:
```json
{
"version": 1,
"socket": {
"path": "~/.clawdbot/exec-approvals.sock",
"token": "base64url-token"
},
"defaults": {
"security": "deny",
"ask": "on-miss",
"askFallback": "deny",
"autoAllowSkills": false
},
"agents": {
"main": {
"security": "allowlist",
"ask": "on-miss",
"askFallback": "deny",
"autoAllowSkills": true,
"allowlist": [
{
"pattern": "~/Projects/**/bin/rg",
"lastUsedAt": 1737150000000,
"lastUsedCommand": "rg -n TODO",
"lastResolvedPath": "/Users/user/Projects/.../bin/rg"
}
]
}
}
}
```
## Policy knobs
### Security (`exec.security`)
- **deny**: block all host exec requests.
- **allowlist**: allow only allowlisted commands.
- **full**: allow everything (equivalent to elevated).
### Ask (`exec.ask`)
- **off**: never prompt.
- **on-miss**: prompt only when allowlist does not match.
- **always**: prompt on every command.
### Ask fallback (`askFallback`)
If a prompt is required but no UI is reachable, fallback decides:
- **deny**: block.
- **allowlist**: allow only if allowlist matches.
- **full**: allow.
## Allowlist (per agent)
The allowlist is **per agent**. If multiple agents exist, you can switch which
agent’s allowlist you’re editing. Entries are path-based and support **globs**.
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
editing in the macOS app. Patterns are **case-insensitive glob matches**.
Examples:
- `~/Projects/**/bin/bird`
@@ -41,66 +90,58 @@ Examples:
- `/opt/homebrew/bin/rg`
Each allowlist entry tracks:
- **last used** (timestamp)
- **last used** timestamp
- **last used command**
- **last used path** (resolved absolute path)
- **last seen metadata** (hash/version/mtime when available)
- **last resolved path**
## How matching works
## Auto-allow skill CLIs
1) Parse the command to determine the executable (first token).
2) Resolve the executable to an absolute path using `PATH`.
3) Match against denylist (if present) → **deny**.
4) Match against allowlist → **allow**.
5) Otherwise follow the Exec approvals policy (deny/ask/allow).
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
gateway for the skill bin list. Disable this if you want strict manual allowlists.
If **auto-allow skill CLIs** is enabled, each installed skill can contribute one
or more allowlist entries. A skill-based allowlist entry only auto-allows when:
- the resolved path matches, and
- the binary hash/version matches the last approved record (if tracked).
## Control UI editing
If the binary changes (new hash/version), the command falls back to **Ask** so
the user can re-approve.
Use the **Control UI → Nodes → Exec approvals** card to edit defaults, per‑agent
overrides, and allowlists. Pick a scope (Defaults or an agent), tweak the policy,
add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata
per pattern so you can keep the list tidy.
The target selector chooses **Gateway** (local approvals) or a **Node**. Nodes
must advertise `system.execApprovals.get/set` (macOS app or headless node host).
If a node does not advertise exec approvals yet, edit its local
`~/.clawdbot/exec-approvals.json` directly.
CLI: `clawdbot approvals` supports gateway or node editing (see [Approvals CLI](/cli/approvals)).
## Approval flow
When the policy is **Always ask** (or when a binary has changed), the macOS app
shows a confirmation dialog. The dialog should include:
When a prompt is required, the companion app displays a confirmation dialog with:
- command + args
- cwd
- environment overrides (diff)
- policy + rule that matched (if any)
- agent id
- resolved executable path
- host + policy metadata
Actions:
- **Allow once** → run now
- **Always allow** → add/update allowlist entry + run
- **Always allow** → add to allowlist + run
- **Deny** → block
When approved, the command runs **in the background** and the agent receives
system events as it starts and completes.
## System events
The agent receives system messages for observability and recovery:
Exec lifecycle is surfaced as system messages:
- `exec.started`
- `exec.finished`
- `exec.denied`
- `exec.started` — command accepted and launched
- `exec.finished` — command completed (exit code + output)
- `exec.denied` — command blocked (policy or denylist)
These are **system messages**; no extra agent tool call is required to resume.
These are posted to the agent’s session after the node reports the event.
## Implications
- **Always allow** is powerful: the agent can run any host command without a
prompt. Prefer allowlisting trusted CLIs instead.
- **Ask** keeps you in the loop while still allowing fast approvals.
- Per-agent allowlists prevent one agent’s approval set from leaking into others.
## Storage
Allowlists and approval settings are stored **locally in the macOS app** (SQLite
is a good fit). The Markdown docs describe behavior; they are not the storage
mechanism.
- **full** is powerful; prefer allowlists when possible.
- **ask** keeps you in the loop while still allowing fast approvals.
- Per-agent allowlists prevent one agent’s approvals from leaking into others.
Related:
- [Exec tool](/tools/exec)
+39 -5
View File
@@ -14,21 +14,55 @@ Background sessions are scoped per agent; `process` only sees sessions from the
## Parameters
- `command` (required)
- `workdir` (defaults to cwd)
- `env` (key/value overrides)
- `yieldMs` (default 10000): auto-background after delay
- `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill on expiry
- `pty` (bool): run in a pseudo-terminal when available (TTY-only CLIs, coding agents, terminal UIs)
- `elevated` (bool): run on host if elevated mode is enabled/allowed (only changes behavior when the agent is sandboxed)
- Need a fully interactive session? Use `pty: true` and the `process` tool for stdin/output.
Note: `elevated` is ignored when sandboxing is off (exec already runs on the host).
- `host` (`sandbox | gateway | node`): where to execute
- `security` (`deny | allowlist | full`): enforcement mode for `gateway`/`node`
- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node`
- `node` (string): node id/name for `host=node`
- `elevated` (bool): alias for `host=gateway` + `security=full` when sandboxed and allowed
Notes:
- `host` defaults to `sandbox`.
- `elevated` is ignored when sandboxing is off (exec already runs on the host).
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
- `node` requires a paired node (companion app or headless node host).
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
## Config
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
- `tools.exec.host` (default: `sandbox`)
- `tools.exec.security` (default: `deny`)
- `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset)
## Exec approvals (macOS app)
Per-agent node binding (use the agent list index in config):
Sandboxed agents can require per-request approval before `exec` runs on the host.
```bash
clawdbot config get agents.list
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
```
Control UI: the Nodes tab includes a small “Exec node binding” panel for the same settings.
## Session overrides (`/exec`)
Use `/exec` to set **per-session** defaults for `host`, `security`, `ask`, and `node`.
Send `/exec` with no arguments to show the current values.
Example:
```
/exec host=gateway security=allowlist ask=on-miss node=mac-1
```
## Exec approvals (companion app / node host)
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
## Examples
+7 -2
View File
@@ -169,15 +169,20 @@ Core parameters:
- `background` (immediate background)
- `timeout` (seconds; kills the process if exceeded, default 1800)
- `elevated` (bool; run on host if elevated mode is enabled/allowed; only changes behavior when the agent is sandboxed)
- `host` (`sandbox | gateway | node`)
- `security` (`deny | allowlist | full`)
- `ask` (`off | on-miss | always`)
- `node` (node id/name for `host=node`)
- Need a real TTY? Set `pty: true`.
Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded.
- Use `process` to poll/log/write/kill/clear background sessions.
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and runs on the host.
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
- macOS app approvals/allowlists: [Exec approvals](/tools/exec-approvals).
- `host=node` can target a macOS companion app or a headless node host (`clawdbot node start`).
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
### `process`
Manage background exec sessions.
+1 -1
View File
@@ -187,7 +187,7 @@ Skills can also refresh mid-session when the skills watcher is enabled or when a
## Remote macOS nodes (Linux gateway)
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Node Run Commands policy not set to "Never"), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`).
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Exec approvals security not set to `deny`), Clawdbot can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `nodes` tool (typically `nodes.run`).
This relies on the node reporting its command support and on a bin probe via `system.run`. If the macOS node goes offline later, the skills remain visible; invocations may fail until the node reconnects.
+10 -9
View File
@@ -12,12 +12,12 @@ The host-only bash chat command uses `! <cmd>` (with `/bash <cmd>` as an alias).
There are two related systems:
- **Commands**: standalone `/...` messages.
- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/model`, `/queue`.
- **Directives**: `/think`, `/verbose`, `/reasoning`, `/elevated`, `/exec`, `/model`, `/queue`.
- Directives are stripped from the message before the model sees it.
- In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings.
- In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement.
There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`).
There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status`, `/whoami` (`/id`).
They run immediately, are stripped before the model sees the message, and the remaining text continues through the normal flow.
## Config
@@ -60,11 +60,11 @@ Text + native (when enabled):
- `/commands`
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/usage` (alias: `/status`)
- `/whoami` (show your sender id; alias: `/id`)
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
- `/cost on|off` (toggle per-response usage line)
- `/usage off|tokens|full` (per-response usage footer)
- `/stop`
- `/restart`
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
@@ -77,6 +77,7 @@ Text + native (when enabled):
- `/verbose on|full|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`)
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current)
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)
- `/bash <command>` (host-only; alias for `! <command>`; requires `commands.bash: true` + `tools.elevated` allowlists)
@@ -89,8 +90,8 @@ Text-only:
Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
- `/status` and `/usage` show the same status output; for full provider usage breakdown, use `clawdbot status --usage`.
- `/cost` appends per-response token usage; it only shows dollar cost when the model uses an API key (OAuth hides cost).
- For full provider usage breakdown, use `clawdbot status --usage`.
- `/usage` controls the per-response usage footer. It only shows dollar cost when the model uses an API key (OAuth hides cost).
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
@@ -98,15 +99,15 @@ Notes:
- **Group mention gating:** command-only messages from allowlisted senders bypass mention requirements.
- **Inline shortcuts (allowlisted senders only):** certain commands also work when embedded in a normal message and are stripped before the model sees the remaining text.
- Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow.
- Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`).
- Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`).
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`).
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
## Usage vs cost (what shows where)
## Usage surfaces (what shows where)
- **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` for the current model provider when usage tracking is enabled.
- **Per-response tokens/cost** is controlled by `/cost on|off` (appended to normal replies).
- **Per-response tokens/cost** is controlled by `/usage off|tokens|full` (appended to normal replies).
- `/model status` is about **models/auth/endpoints**, not usage.
## Model selection (`/model`)
+11
View File
@@ -9,6 +9,17 @@ read_when:
Sub-agents are background agent runs spawned from an existing agent run. They run in their own session (`agent:<agentId>:subagent:<uuid>`) and, when finished, **announce** their result back to the requester chat channel.
## Slash command
Use `/subagents` to inspect or control sub-agent runs for the **current session**:
- `/subagents list`
- `/subagents stop <id|#|all>`
- `/subagents log <id|#> [limit] [tools]`
- `/subagents info <id|#>`
- `/subagents send <id|#> <message>`
`/subagents info` shows run metadata (status, timestamps, session id, transcript path, cleanup).
Primary goals:
- Parallelize “research / long task / slow tool” work without blocking the main run.
- Keep sub-agents isolated by default (session separation + optional sandboxing).
+1 -1
View File
@@ -77,7 +77,7 @@ Session controls:
- `/think <off|minimal|low|medium|high>`
- `/verbose <on|full|off>`
- `/reasoning <on|off|stream>`
- `/cost <on|off>`
- `/usage <off|tokens|full>`
- `/elevated <on|off>` (alias: `/elev`)
- `/activation <mention|always>`
- `/deliver <on|off>`
+1
View File
@@ -36,6 +36,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
- Skills: status, enable/disable, install, API key updates (`skills.*`)
- Nodes: list + caps (`node.list`)
- Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
- Config: apply + restart with validation (`config.apply`) and wake the last active session
- Config writes include a base-hash guard to prevent clobbering concurrent edits
+18
View File
@@ -0,0 +1,18 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { bluebubblesPlugin } from "./src/channel.js";
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
import { setBlueBubblesRuntime } from "./src/runtime.js";
const plugin = {
id: "bluebubbles",
name: "BlueBubbles",
description: "BlueBubbles channel plugin (macOS app)",
register(api: ClawdbotPluginApi) {
setBlueBubblesRuntime(api.runtime);
api.registerChannel({ plugin: bluebubblesPlugin });
api.registerHttpHandler(handleBlueBubblesWebhookRequest);
},
};
export default plugin;
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@clawdbot/bluebubbles",
"version": "2026.1.15",
"type": "module",
"description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
}
}
+79
View File
@@ -0,0 +1,79 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
export type ResolvedBlueBubblesAccount = {
accountId: string;
enabled: boolean;
name?: string;
config: BlueBubblesAccountConfig;
configured: boolean;
baseUrl?: string;
};
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = cfg.channels?.bluebubbles?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listBlueBubblesAccountIds(cfg: ClawdbotConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultBlueBubblesAccountId(cfg: ClawdbotConfig): string {
const ids = listBlueBubblesAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): BlueBubblesAccountConfig | undefined {
const accounts = cfg.channels?.bluebubbles?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
return accounts[accountId] as BlueBubblesAccountConfig | undefined;
}
function mergeBlueBubblesAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): BlueBubblesAccountConfig {
const base = (cfg.channels?.bluebubbles ?? {}) as BlueBubblesAccountConfig & {
accounts?: unknown;
};
const { accounts: _ignored, ...rest } = base;
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...rest, ...account };
}
export function resolveBlueBubblesAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedBlueBubblesAccount {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = params.cfg.channels?.bluebubbles?.enabled;
const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const serverUrl = merged.serverUrl?.trim();
const password = merged.password?.trim();
const configured = Boolean(serverUrl && password);
const baseUrl = serverUrl ? normalizeBlueBubblesServerUrl(serverUrl) : undefined;
return {
accountId,
enabled: baseEnabled !== false && accountEnabled,
name: merged.name?.trim() || undefined,
config: merged,
configured,
baseUrl,
};
}
export function listEnabledBlueBubblesAccounts(cfg: ClawdbotConfig): ResolvedBlueBubblesAccount[] {
return listBlueBubblesAccountIds(cfg)
.map((accountId) => resolveBlueBubblesAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
+121
View File
@@ -0,0 +1,121 @@
import {
createActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
type ChannelToolSend,
type ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { sendBlueBubblesReaction } from "./reactions.js";
import { resolveChatGuidForTarget } from "./send.js";
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
import type { BlueBubblesSendTarget } from "./types.js";
const providerId = "bluebubbles";
function mapTarget(raw: string): BlueBubblesSendTarget {
const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "chat_guid") return { kind: "chat_guid", chatGuid: parsed.chatGuid };
if (parsed.kind === "chat_id") return { kind: "chat_id", chatId: parsed.chatId };
if (parsed.kind === "chat_identifier") {
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
}
return {
kind: "handle",
address: normalizeBlueBubblesHandle(parsed.to),
service: parsed.service,
};
}
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const account = resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig });
if (!account.enabled || !account.configured) return [];
const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions);
const actions = new Set<ChannelMessageActionName>();
if (gate("reactions")) actions.add("react");
return Array.from(actions);
},
supportsAction: ({ action }) => action === "react",
extractToolSend: ({ args }): ChannelToolSend | null => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") return null;
const to = typeof args.to === "string" ? args.to : undefined;
if (!to) return null;
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId };
},
handleAction: async ({ action, params, cfg, accountId }) => {
if (action !== "react") {
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
}
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
});
if (isEmpty && !remove) {
throw new Error("Emoji is required to send a BlueBubbles reaction.");
}
const messageId = readStringParam(params, "messageId", { required: true });
const chatGuid = readStringParam(params, "chatGuid");
const chatIdentifier = readStringParam(params, "chatIdentifier");
const chatId = readNumberParam(params, "chatId", { integer: true });
const to = readStringParam(params, "to");
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const account = resolveBlueBubblesAccount({
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined,
});
const baseUrl = account.config.serverUrl?.trim();
const password = account.config.password?.trim();
let resolvedChatGuid = chatGuid?.trim() || "";
if (!resolvedChatGuid) {
const target =
chatIdentifier?.trim()
? ({ kind: "chat_identifier", chatIdentifier: chatIdentifier.trim() } as BlueBubblesSendTarget)
: typeof chatId === "number"
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
: to
? mapTarget(to)
: null;
if (!target) {
throw new Error("BlueBubbles reaction requires chatGuid, chatIdentifier, chatId, or to.");
}
if (!baseUrl || !password) {
throw new Error("BlueBubbles reaction requires serverUrl and password.");
}
resolvedChatGuid =
(await resolveChatGuidForTarget({
baseUrl,
password,
target,
})) ?? "";
}
if (!resolvedChatGuid) {
throw new Error("BlueBubbles reaction failed: chatGuid not found for target.");
}
await sendBlueBubblesReaction({
chatGuid: resolvedChatGuid,
messageGuid: messageId,
emoji,
remove: remove || undefined,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
opts: {
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined,
},
});
if (!remove) {
return jsonResult({ ok: true, added: emoji });
}
return jsonResult({ ok: true, removed: true });
},
};
+57
View File
@@ -0,0 +1,57 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import {
blueBubblesFetchWithTimeout,
buildBlueBubblesApiUrl,
type BlueBubblesAttachment,
} from "./types.js";
export type BlueBubblesAttachmentOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
timeoutMs?: number;
cfg?: ClawdbotConfig;
};
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
function resolveAccount(params: BlueBubblesAttachmentOpts) {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
if (!password) throw new Error("BlueBubbles password is required");
return { baseUrl, password };
}
export async function downloadBlueBubblesAttachment(
attachment: BlueBubblesAttachment,
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
): Promise<{ buffer: Uint8Array; contentType?: string }> {
const guid = attachment.guid?.trim();
if (!guid) throw new Error("BlueBubbles attachment guid is required");
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
password,
});
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(
`BlueBubbles attachment download failed (${res.status}): ${errorText || "unknown"}`,
);
}
const contentType = res.headers.get("content-type") ?? undefined;
const buf = new Uint8Array(await res.arrayBuffer());
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
if (buf.byteLength > maxBytes) {
throw new Error(`BlueBubbles attachment too large (${buf.byteLength} bytes)`);
}
return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
}
+284
View File
@@ -0,0 +1,284 @@
import type { ChannelAccountSnapshot, ChannelPlugin, ClawdbotConfig } from "clawdbot/plugin-sdk";
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
setAccountEnabledInConfigSection,
} from "clawdbot/plugin-sdk";
import {
listBlueBubblesAccountIds,
type ResolvedBlueBubblesAccount,
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import { probeBlueBubbles } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js";
import { normalizeBlueBubblesHandle } from "./targets.js";
import { bluebubblesMessageActions } from "./actions.js";
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
const meta = {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles (macOS app)",
docsPath: "/channels/bluebubbles",
docsLabel: "bluebubbles",
blurb: "iMessage via the BlueBubbles mac app + REST API.",
order: 75,
};
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
id: "bluebubbles",
meta,
capabilities: {
chatTypes: ["direct", "group"],
media: false,
reactions: true,
},
reload: { configPrefixes: ["channels.bluebubbles"] },
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
config: {
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg as ClawdbotConfig),
resolveAccount: (cfg, accountId) =>
resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg as ClawdbotConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as ClawdbotConfig,
sectionKey: "bluebubbles",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as ClawdbotConfig,
sectionKey: "bluebubbles",
accountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
}),
isConfigured: (account) => account.configured,
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ??
[]).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^bluebubbles:/i, ""))
.map((entry) => normalizeBlueBubblesHandle(entry)),
},
actions: bluebubblesMessageActions,
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg as ClawdbotConfig).channels?.bluebubbles?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.bluebubbles.accounts.${resolvedAccountId}.`
: "channels.bluebubbles.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("bluebubbles"),
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
};
},
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`,
];
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "bluebubbles",
accountId,
name,
}),
validateInput: ({ input }) => {
if (!input.httpUrl && !input.password) {
return "BlueBubbles requires --http-url and --password.";
}
if (!input.httpUrl) return "BlueBubbles requires --http-url.";
if (!input.password) return "BlueBubbles requires --password.";
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "bluebubbles",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "bluebubbles",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
...(input.password ? { password: input.password } : {}),
},
},
} as ClawdbotConfig;
}
return {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
accounts: {
...(next.channels?.bluebubbles?.accounts ?? {}),
[accountId]: {
...(next.channels?.bluebubbles?.accounts?.[accountId] ?? {}),
enabled: true,
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
...(input.password ? { password: input.password } : {}),
},
},
},
},
} as ClawdbotConfig;
},
},
pairing: {
idLabel: "bluebubblesSenderId",
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
notifyApproval: async ({ cfg, id }) => {
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
cfg: cfg as ClawdbotConfig,
});
},
},
outbound: {
deliveryMode: "direct",
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error("Delivering to BlueBubbles requires --to <handle|chat_guid:GUID>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId }) => {
const result = await sendMessageBlueBubbles(to, text, {
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined,
});
return { channel: "bluebubbles", ...result };
},
sendMedia: async () => {
throw new Error("BlueBubbles media delivery is not supported yet.");
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) return [];
return [
{
channel: "bluebubbles",
accountId: account.accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
},
];
}),
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
baseUrl: snapshot.baseUrl ?? null,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
probeBlueBubbles({
baseUrl: account.baseUrl,
password: account.config.password ?? null,
timeoutMs,
}),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const webhookPath = resolveWebhookPathFromConfig(account.config);
ctx.setStatus({
accountId: account.accountId,
baseUrl: account.baseUrl,
});
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
return monitorBlueBubblesProvider({
account,
config: ctx.cfg as ClawdbotConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
webhookPath,
});
},
},
};
+66
View File
@@ -0,0 +1,66 @@
import { resolveBlueBubblesAccount } from "./accounts.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
export type BlueBubblesChatOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
timeoutMs?: number;
cfg?: ClawdbotConfig;
};
function resolveAccount(params: BlueBubblesChatOpts) {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
if (!password) throw new Error("BlueBubbles password is required");
return { baseUrl, password };
}
export async function markBlueBubblesChatRead(
chatGuid: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmed = chatGuid.trim();
if (!trimmed) return;
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
password,
});
const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`);
}
}
export async function sendBlueBubblesTyping(
chatGuid: string,
typing: boolean,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmed = chatGuid.trim();
if (!trimmed) return;
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{ method: typing ? "POST" : "DELETE" },
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
}
}
@@ -0,0 +1,30 @@
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
const bluebubblesActionSchema = z
.object({
reactions: z.boolean().optional(),
})
.optional();
const bluebubblesAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
serverUrl: z.string().optional(),
password: z.string().optional(),
webhookPath: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().int().positive().optional(),
});
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
actions: bluebubblesActionSchema,
});
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
export type BlueBubblesProbe = {
ok: boolean;
status?: number | null;
error?: string | null;
};
export async function probeBlueBubbles(params: {
baseUrl?: string | null;
password?: string | null;
timeoutMs?: number;
}): Promise<BlueBubblesProbe> {
const baseUrl = params.baseUrl?.trim();
const password = params.password?.trim();
if (!baseUrl) return { ok: false, error: "serverUrl not configured" };
if (!password) return { ok: false, error: "password not configured" };
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password });
try {
const res = await blueBubblesFetchWithTimeout(
url,
{ method: "GET" },
params.timeoutMs,
);
if (!res.ok) {
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
}
return { ok: true, status: res.status };
} catch (err) {
return {
ok: false,
status: null,
error: err instanceof Error ? err.message : String(err),
};
}
}
Loaded 100 of 565 files, more files were not shown because too many files have changed in this diff. Show more