Merge branch 'clawdbot:main' into main
This commit is contained in:
commit
5455037eab
4
.gitmodules
vendored
4
.gitmodules
vendored
@ -1,4 +0,0 @@
|
|||||||
[submodule "Peekaboo"]
|
|
||||||
path = Peekaboo
|
|
||||||
url = https://github.com/steipete/Peekaboo.git
|
|
||||||
branch = main
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
- Repo: https://github.com/clawdbot/clawdbot
|
- 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
|
## 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`).
|
- 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.
|
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||||
- Never update the Carbon dependency.
|
- Never update the Carbon dependency.
|
||||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
- 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.
|
- 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.
|
- 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.**
|
- 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.**
|
||||||
|
|||||||
144
CHANGELOG.md
144
CHANGELOG.md
@ -2,11 +2,152 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.17-2 (Unreleased)
|
## 2026.1.18-5
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
||||||
|
|
||||||
|
## 2026.1.18-4
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
|
||||||
|
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
|
||||||
|
- Swabble: use the tagged Commander Swift package release.
|
||||||
|
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
||||||
|
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
||||||
|
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
||||||
|
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
|
||||||
|
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
|
||||||
|
- Memory: add native Gemini embeddings provider for memory search. (#1151)
|
||||||
|
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
||||||
|
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
||||||
|
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
|
||||||
|
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev.
|
||||||
|
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||||
|
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
||||||
|
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
||||||
|
|
||||||
|
## 2026.1.18-3
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Exec: add host/security/ask routing for gateway + node exec.
|
||||||
|
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
||||||
|
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
||||||
|
- macOS: add approvals socket UI server + node exec lifecycle events.
|
||||||
|
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
|
||||||
|
- Nodes: add node daemon service install/status/start/stop/restart.
|
||||||
|
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||||
|
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
||||||
|
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
||||||
|
- Agents: auto-inject local image references for vision models and avoid reloading history images. (#1098) — thanks @tyler6204.
|
||||||
|
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
||||||
|
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
|
||||||
|
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
|
||||||
|
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||||
|
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||||
|
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||||
|
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||||
|
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||||
|
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||||
|
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||||
|
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
||||||
|
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||||
|
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
||||||
|
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
|
||||||
|
|
||||||
|
## 2026.1.18-2
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
||||||
|
|
||||||
|
## 2026.1.18-1
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||||
|
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||||
|
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||||
|
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||||
|
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||||
|
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||||
|
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||||
|
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||||
|
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||||
|
- Discord: only emit slow listener warnings after 30s.
|
||||||
|
|
||||||
|
## 2026.1.17-6
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
|
||||||
|
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
|
||||||
|
- Docs: document plugin slots and memory plugin behavior.
|
||||||
|
- Plugins: add the bundled BlueBubbles channel plugin (disabled by default).
|
||||||
|
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
|
||||||
|
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime.
|
||||||
|
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime.
|
||||||
|
|
||||||
|
## 2026.1.17-5
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
|
||||||
|
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
|
||||||
|
- CLI: surface FTS + embedding cache state in `clawdbot memory status`.
|
||||||
|
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default.
|
||||||
|
- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
|
||||||
|
- Tools: centralize plugin tool policy helpers.
|
||||||
|
- Commands: add `/subagents info` and show sub-agent counts in `/status`.
|
||||||
|
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
||||||
|
|
||||||
|
## 2026.1.18-1
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||||
|
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||||
|
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||||
|
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||||
|
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||||
|
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||||
|
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||||
|
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||||
|
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
||||||
|
- Discord: only emit slow listener warnings after 30s.
|
||||||
|
## 2026.1.17-3
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Memory: add OpenAI Batch API indexing for embeddings when configured.
|
||||||
|
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
|
||||||
|
|
||||||
|
## 2026.1.17-2
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.
|
||||||
|
- Memory: parallelize embedding indexing with rate-limit retries.
|
||||||
|
- Memory: split overly long lines to keep embeddings under token limits.
|
||||||
|
- Memory: skip empty chunks to avoid invalid embedding inputs.
|
||||||
|
- Sessions: fall back to session labels when listing display names. (#1124) — thanks @abdaraxus.
|
||||||
|
- Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123) — thanks @thewilloftheshadow.
|
||||||
|
|
||||||
## 2026.1.17-1
|
## 2026.1.17-1
|
||||||
|
|
||||||
@ -32,6 +173,7 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
|
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
|
||||||
|
- CLI: close memory manager after memory commands to avoid hanging processes. (#1127) — thanks @NicholasSpisak.
|
||||||
|
|
||||||
## 2026.1.16-1
|
## 2026.1.16-1
|
||||||
|
|
||||||
|
|||||||
1
Peekaboo
1
Peekaboo
@ -1 +0,0 @@
|
|||||||
Subproject commit 5c195f5e46ebfcc953af74fdd05fbc962d05a50c
|
|
||||||
@ -249,7 +249,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
|
|||||||
- `/compact` — compact session context (summary)
|
- `/compact` — compact session context (summary)
|
||||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||||
- `/verbose on|off`
|
- `/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)
|
- `/restart` — restart the gateway (owner-only in groups)
|
||||||
- `/activation mention|always` — group activation toggle (groups only)
|
- `/activation mention|always` — group activation toggle (groups only)
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,7 @@ let package = Package(
|
|||||||
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
.executable(name: "swabble", targets: ["SwabbleCLI"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
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"),
|
.package(url: "https://github.com/apple/swift-testing", from: "0.99.0"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
|
|||||||
@ -1,6 +1,24 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
|
"originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438",
|
||||||
"pins" : [
|
"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",
|
"identity" : "elevenlabskit",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@ -10,15 +28,6 @@
|
|||||||
"version" : "0.1.0"
|
"version" : "0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "eventsource",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/mattt/eventsource.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0",
|
|
||||||
"version" : "1.3.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "menubarextraaccess",
|
"identity" : "menubarextraaccess",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@ -28,6 +37,15 @@
|
|||||||
"version" : "1.2.2"
|
"version" : "1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "peekaboo",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/steipete/Peekaboo.git",
|
||||||
|
"state" : {
|
||||||
|
"branch" : "main",
|
||||||
|
"revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "sparkle",
|
"identity" : "sparkle",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@ -46,33 +64,6 @@
|
|||||||
"version" : "1.2.1"
|
"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",
|
"identity" : "swift-concurrency-extras",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@ -82,24 +73,6 @@
|
|||||||
"version" : "1.3.2"
|
"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",
|
"identity" : "swift-log",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
@ -118,24 +91,6 @@
|
|||||||
"version" : "1.1.1"
|
"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",
|
"identity" : "swift-subprocess",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
@ -20,10 +20,9 @@ let package = Package(
|
|||||||
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"),
|
.package(url: "https://github.com/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/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/sparkle-project/Sparkle", from: "2.8.1"),
|
||||||
|
.package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"),
|
||||||
.package(path: "../shared/ClawdbotKit"),
|
.package(path: "../shared/ClawdbotKit"),
|
||||||
.package(path: "../../Swabble"),
|
.package(path: "../../Swabble"),
|
||||||
.package(path: "../../Peekaboo/Core/PeekabooCore"),
|
|
||||||
.package(path: "../../Peekaboo/Core/PeekabooAutomationKit"),
|
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
.target(
|
.target(
|
||||||
@ -61,8 +60,8 @@ let package = Package(
|
|||||||
.product(name: "Subprocess", package: "swift-subprocess"),
|
.product(name: "Subprocess", package: "swift-subprocess"),
|
||||||
.product(name: "Logging", package: "swift-log"),
|
.product(name: "Logging", package: "swift-log"),
|
||||||
.product(name: "Sparkle", package: "Sparkle"),
|
.product(name: "Sparkle", package: "Sparkle"),
|
||||||
.product(name: "PeekabooBridge", package: "PeekabooCore"),
|
.product(name: "PeekabooBridge", package: "Peekaboo"),
|
||||||
.product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"),
|
.product(name: "PeekabooAutomationKit", package: "Peekaboo"),
|
||||||
],
|
],
|
||||||
exclude: [
|
exclude: [
|
||||||
"Resources/Info.plist",
|
"Resources/Info.plist",
|
||||||
|
|||||||
64
apps/macos/README.md
Normal file
64
apps/macos/README.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# Clawdbot macOS app (dev + signing)
|
||||||
|
|
||||||
|
## Quick dev run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from repo root
|
||||||
|
scripts/restart-mac.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/restart-mac.sh --no-sign # fastest dev; ad-hoc signing (TCC permissions do not stick)
|
||||||
|
scripts/restart-mac.sh --sign # force code signing (requires cert)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Packaging flow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/package-mac-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Creates `dist/Clawdbot.app` and signs it via `scripts/codesign-mac-app.sh`.
|
||||||
|
|
||||||
|
## Signing behavior
|
||||||
|
|
||||||
|
Auto-selects identity (first match):
|
||||||
|
1) Developer ID Application
|
||||||
|
2) Apple Distribution
|
||||||
|
3) Apple Development
|
||||||
|
4) first available identity
|
||||||
|
|
||||||
|
If none found:
|
||||||
|
- errors by default
|
||||||
|
- set `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` to ad-hoc sign
|
||||||
|
|
||||||
|
## Team ID audit (Sparkle mismatch guard)
|
||||||
|
|
||||||
|
After signing, we read the app bundle Team ID and compare every Mach-O inside the app.
|
||||||
|
If any embedded binary has a different Team ID, signing fails.
|
||||||
|
|
||||||
|
Skip the audit:
|
||||||
|
```bash
|
||||||
|
SKIP_TEAM_ID_CHECK=1 scripts/package-mac-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Library validation workaround (dev only)
|
||||||
|
|
||||||
|
If Sparkle Team ID mismatch blocks loading (common with Apple Development certs), opt in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This adds `com.apple.security.cs.disable-library-validation` to app entitlements.
|
||||||
|
Use for local dev only; keep off for release builds.
|
||||||
|
|
||||||
|
## Useful env flags
|
||||||
|
|
||||||
|
- `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"`
|
||||||
|
- `ALLOW_ADHOC_SIGNING=1` (ad-hoc, TCC permissions do not persist)
|
||||||
|
- `CODESIGN_TIMESTAMP=off` (offline debug)
|
||||||
|
- `DISABLE_LIBRARY_VALIDATION=1` (dev-only Sparkle workaround)
|
||||||
|
- `SKIP_TEAM_ID_CHECK=1` (bypass audit)
|
||||||
@ -170,8 +170,15 @@ final class AppState {
|
|||||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
var systemRunPolicy: SystemRunPolicy {
|
var execApprovalMode: ExecApprovalQuickMode {
|
||||||
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
|
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).
|
/// 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.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||||
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
|
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
|
self.peekabooBridgeEnabled = UserDefaults.standard
|
||||||
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
|
||||||
if !self.isPreview {
|
if !self.isPreview {
|
||||||
|
|||||||
@ -8,6 +8,8 @@ struct BridgeNodeInfo: Sendable {
|
|||||||
var displayName: String?
|
var displayName: String?
|
||||||
var platform: String?
|
var platform: String?
|
||||||
var version: String?
|
var version: String?
|
||||||
|
var coreVersion: String?
|
||||||
|
var uiVersion: String?
|
||||||
var deviceFamily: String?
|
var deviceFamily: String?
|
||||||
var modelIdentifier: String?
|
var modelIdentifier: String?
|
||||||
var remoteAddress: String?
|
var remoteAddress: String?
|
||||||
@ -147,6 +149,8 @@ actor BridgeConnectionHandler {
|
|||||||
displayName: hello.displayName,
|
displayName: hello.displayName,
|
||||||
platform: hello.platform,
|
platform: hello.platform,
|
||||||
version: hello.version,
|
version: hello.version,
|
||||||
|
coreVersion: hello.coreVersion,
|
||||||
|
uiVersion: hello.uiVersion,
|
||||||
deviceFamily: hello.deviceFamily,
|
deviceFamily: hello.deviceFamily,
|
||||||
modelIdentifier: hello.modelIdentifier,
|
modelIdentifier: hello.modelIdentifier,
|
||||||
remoteAddress: self.remoteAddressString(),
|
remoteAddress: self.remoteAddressString(),
|
||||||
@ -171,6 +175,8 @@ actor BridgeConnectionHandler {
|
|||||||
displayName: req.displayName,
|
displayName: req.displayName,
|
||||||
platform: req.platform,
|
platform: req.platform,
|
||||||
version: req.version,
|
version: req.version,
|
||||||
|
coreVersion: req.coreVersion,
|
||||||
|
uiVersion: req.uiVersion,
|
||||||
deviceFamily: req.deviceFamily,
|
deviceFamily: req.deviceFamily,
|
||||||
modelIdentifier: req.modelIdentifier,
|
modelIdentifier: req.modelIdentifier,
|
||||||
caps: req.caps,
|
caps: req.caps,
|
||||||
@ -186,6 +192,8 @@ actor BridgeConnectionHandler {
|
|||||||
displayName: enriched.displayName,
|
displayName: enriched.displayName,
|
||||||
platform: enriched.platform,
|
platform: enriched.platform,
|
||||||
version: enriched.version,
|
version: enriched.version,
|
||||||
|
coreVersion: enriched.coreVersion,
|
||||||
|
uiVersion: enriched.uiVersion,
|
||||||
deviceFamily: enriched.deviceFamily,
|
deviceFamily: enriched.deviceFamily,
|
||||||
modelIdentifier: enriched.modelIdentifier,
|
modelIdentifier: enriched.modelIdentifier,
|
||||||
remoteAddress: enriched.remoteAddress,
|
remoteAddress: enriched.remoteAddress,
|
||||||
|
|||||||
@ -214,9 +214,10 @@ enum CommandResolver {
|
|||||||
subcommand: String,
|
subcommand: String,
|
||||||
extraArgs: [String] = [],
|
extraArgs: [String] = [],
|
||||||
defaults: UserDefaults = .standard,
|
defaults: UserDefaults = .standard,
|
||||||
|
configRoot: [String: Any]? = nil,
|
||||||
searchPaths: [String]? = nil) -> [String]
|
searchPaths: [String]? = nil) -> [String]
|
||||||
{
|
{
|
||||||
let settings = self.connectionSettings(defaults: defaults)
|
let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot)
|
||||||
if settings.mode == .remote, let ssh = self.sshNodeCommand(
|
if settings.mode == .remote, let ssh = self.sshNodeCommand(
|
||||||
subcommand: subcommand,
|
subcommand: subcommand,
|
||||||
extraArgs: extraArgs,
|
extraArgs: extraArgs,
|
||||||
@ -264,12 +265,14 @@ enum CommandResolver {
|
|||||||
subcommand: String,
|
subcommand: String,
|
||||||
extraArgs: [String] = [],
|
extraArgs: [String] = [],
|
||||||
defaults: UserDefaults = .standard,
|
defaults: UserDefaults = .standard,
|
||||||
|
configRoot: [String: Any]? = nil,
|
||||||
searchPaths: [String]? = nil) -> [String]
|
searchPaths: [String]? = nil) -> [String]
|
||||||
{
|
{
|
||||||
self.clawdbotNodeCommand(
|
self.clawdbotNodeCommand(
|
||||||
subcommand: subcommand,
|
subcommand: subcommand,
|
||||||
extraArgs: extraArgs,
|
extraArgs: extraArgs,
|
||||||
defaults: defaults,
|
defaults: defaults,
|
||||||
|
configRoot: configRoot,
|
||||||
searchPaths: searchPaths)
|
searchPaths: searchPaths)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -384,8 +387,11 @@ enum CommandResolver {
|
|||||||
let cliPath: String
|
let cliPath: String
|
||||||
}
|
}
|
||||||
|
|
||||||
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
static func connectionSettings(
|
||||||
let root = ClawdbotConfigFile.loadDict()
|
defaults: UserDefaults = .standard,
|
||||||
|
configRoot: [String: Any]? = nil) -> RemoteSettings
|
||||||
|
{
|
||||||
|
let root = configRoot ?? ClawdbotConfigFile.loadDict()
|
||||||
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
||||||
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||||
|
|||||||
673
apps/macos/Sources/Clawdbot/ExecApprovals.swift
Normal file
673
apps/macos/Sources/Clawdbot/ExecApprovals.swift
Normal file
@ -0,0 +1,673 @@
|
|||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
import Security
|
||||||
|
|
||||||
|
enum ExecSecurity: String, CaseIterable, Codable, Identifiable {
|
||||||
|
case deny
|
||||||
|
case allowlist
|
||||||
|
case full
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .deny: "Deny"
|
||||||
|
case .allowlist: "Allowlist"
|
||||||
|
case .full: "Always Allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecApprovalQuickMode: String, CaseIterable, Identifiable {
|
||||||
|
case deny
|
||||||
|
case ask
|
||||||
|
case allow
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .deny: "Deny"
|
||||||
|
case .ask: "Always Ask"
|
||||||
|
case .allow: "Always Allow"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var security: ExecSecurity {
|
||||||
|
switch self {
|
||||||
|
case .deny: .deny
|
||||||
|
case .ask: .allowlist
|
||||||
|
case .allow: .full
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ask: ExecAsk {
|
||||||
|
switch self {
|
||||||
|
case .deny: .off
|
||||||
|
case .ask: .onMiss
|
||||||
|
case .allow: .off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func from(security: ExecSecurity, ask: ExecAsk) -> ExecApprovalQuickMode {
|
||||||
|
switch security {
|
||||||
|
case .deny:
|
||||||
|
return .deny
|
||||||
|
case .full:
|
||||||
|
return .allow
|
||||||
|
case .allowlist:
|
||||||
|
return .ask
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecAsk: String, CaseIterable, Codable, Identifiable {
|
||||||
|
case off
|
||||||
|
case onMiss = "on-miss"
|
||||||
|
case always
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .off: "Never Ask"
|
||||||
|
case .onMiss: "Ask on Allowlist Miss"
|
||||||
|
case .always: "Always Ask"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecApprovalDecision: String, Codable, Sendable {
|
||||||
|
case allowOnce = "allow-once"
|
||||||
|
case allowAlways = "allow-always"
|
||||||
|
case deny
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecAllowlistEntry: Codable, Hashable {
|
||||||
|
var pattern: String
|
||||||
|
var lastUsedAt: Double? = nil
|
||||||
|
var lastUsedCommand: String? = nil
|
||||||
|
var lastResolvedPath: String? = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsDefaults: Codable {
|
||||||
|
var security: ExecSecurity?
|
||||||
|
var ask: ExecAsk?
|
||||||
|
var askFallback: ExecSecurity?
|
||||||
|
var autoAllowSkills: Bool?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsAgent: Codable {
|
||||||
|
var security: ExecSecurity?
|
||||||
|
var ask: ExecAsk?
|
||||||
|
var askFallback: ExecSecurity?
|
||||||
|
var autoAllowSkills: Bool?
|
||||||
|
var allowlist: [ExecAllowlistEntry]?
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
security == nil && ask == nil && askFallback == nil && autoAllowSkills == nil && (allowlist?.isEmpty ?? true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsSocketConfig: Codable {
|
||||||
|
var path: String?
|
||||||
|
var token: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsFile: Codable {
|
||||||
|
var version: Int
|
||||||
|
var socket: ExecApprovalsSocketConfig?
|
||||||
|
var defaults: ExecApprovalsDefaults?
|
||||||
|
var agents: [String: ExecApprovalsAgent]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsSnapshot: Codable {
|
||||||
|
var path: String
|
||||||
|
var exists: Bool
|
||||||
|
var hash: String
|
||||||
|
var file: ExecApprovalsFile
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsResolved {
|
||||||
|
let url: URL
|
||||||
|
let socketPath: String
|
||||||
|
let token: String
|
||||||
|
let defaults: ExecApprovalsResolvedDefaults
|
||||||
|
let agent: ExecApprovalsResolvedDefaults
|
||||||
|
let allowlist: [ExecAllowlistEntry]
|
||||||
|
var file: ExecApprovalsFile
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsResolvedDefaults {
|
||||||
|
var security: ExecSecurity
|
||||||
|
var ask: ExecAsk
|
||||||
|
var askFallback: ExecSecurity
|
||||||
|
var autoAllowSkills: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecApprovalsStore {
|
||||||
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
|
||||||
|
private static let defaultSecurity: ExecSecurity = .deny
|
||||||
|
private static let defaultAsk: ExecAsk = .onMiss
|
||||||
|
private static let defaultAskFallback: ExecSecurity = .deny
|
||||||
|
private static let defaultAutoAllowSkills = false
|
||||||
|
|
||||||
|
static func fileURL() -> URL {
|
||||||
|
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func socketPath() -> String {
|
||||||
|
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
|
||||||
|
}
|
||||||
|
|
||||||
|
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||||
|
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return ExecApprovalsFile(
|
||||||
|
version: 1,
|
||||||
|
socket: ExecApprovalsSocketConfig(
|
||||||
|
path: socketPath.isEmpty ? nil : socketPath,
|
||||||
|
token: token.isEmpty ? nil : token),
|
||||||
|
defaults: file.defaults,
|
||||||
|
agents: file.agents)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||||
|
let url = self.fileURL()
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
return ExecApprovalsSnapshot(
|
||||||
|
path: url.path,
|
||||||
|
exists: false,
|
||||||
|
hash: self.hashRaw(nil),
|
||||||
|
file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]))
|
||||||
|
}
|
||||||
|
let raw = try? String(contentsOf: url, encoding: .utf8)
|
||||||
|
let data = raw.flatMap { $0.data(using: .utf8) }
|
||||||
|
let decoded: ExecApprovalsFile = {
|
||||||
|
if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 {
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
|
}()
|
||||||
|
return ExecApprovalsSnapshot(
|
||||||
|
path: url.path,
|
||||||
|
exists: true,
|
||||||
|
hash: self.hashRaw(raw),
|
||||||
|
file: decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||||
|
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if socketPath.isEmpty {
|
||||||
|
return ExecApprovalsFile(
|
||||||
|
version: file.version,
|
||||||
|
socket: nil,
|
||||||
|
defaults: file.defaults,
|
||||||
|
agents: file.agents)
|
||||||
|
}
|
||||||
|
return ExecApprovalsFile(
|
||||||
|
version: file.version,
|
||||||
|
socket: ExecApprovalsSocketConfig(path: socketPath, token: nil),
|
||||||
|
defaults: file.defaults,
|
||||||
|
agents: file.agents)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func loadFile() -> ExecApprovalsFile {
|
||||||
|
let url = self.fileURL()
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
||||||
|
if decoded.version != 1 {
|
||||||
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
} catch {
|
||||||
|
self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveFile(_ file: ExecApprovalsFile) {
|
||||||
|
do {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let data = try encoder.encode(file)
|
||||||
|
let url = self.fileURL()
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
try data.write(to: url, options: [.atomic])
|
||||||
|
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||||
|
} catch {
|
||||||
|
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ensureFile() -> ExecApprovalsFile {
|
||||||
|
var file = self.loadFile()
|
||||||
|
if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) }
|
||||||
|
let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if path.isEmpty {
|
||||||
|
file.socket?.path = self.socketPath()
|
||||||
|
}
|
||||||
|
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if token.isEmpty {
|
||||||
|
file.socket?.token = self.generateToken()
|
||||||
|
}
|
||||||
|
if file.agents == nil { file.agents = [:] }
|
||||||
|
self.saveFile(file)
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolve(agentId: String?) -> ExecApprovalsResolved {
|
||||||
|
let file = self.ensureFile()
|
||||||
|
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||||
|
let resolvedDefaults = ExecApprovalsResolvedDefaults(
|
||||||
|
security: defaults.security ?? self.defaultSecurity,
|
||||||
|
ask: defaults.ask ?? self.defaultAsk,
|
||||||
|
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||||
|
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
||||||
|
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||||
|
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
: "default"
|
||||||
|
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
||||||
|
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||||
|
security: agentEntry.security ?? resolvedDefaults.security,
|
||||||
|
ask: agentEntry.ask ?? resolvedDefaults.ask,
|
||||||
|
askFallback: agentEntry.askFallback ?? resolvedDefaults.askFallback,
|
||||||
|
autoAllowSkills: agentEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
|
||||||
|
let allowlist = (agentEntry.allowlist ?? [])
|
||||||
|
.map { entry in
|
||||||
|
ExecAllowlistEntry(
|
||||||
|
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
lastUsedAt: entry.lastUsedAt,
|
||||||
|
lastUsedCommand: entry.lastUsedCommand,
|
||||||
|
lastResolvedPath: entry.lastResolvedPath)
|
||||||
|
}
|
||||||
|
.filter { !$0.pattern.isEmpty }
|
||||||
|
let socketPath = self.expandPath(file.socket?.path ?? self.socketPath())
|
||||||
|
let token = file.socket?.token ?? ""
|
||||||
|
return ExecApprovalsResolved(
|
||||||
|
url: self.fileURL(),
|
||||||
|
socketPath: socketPath,
|
||||||
|
token: token,
|
||||||
|
defaults: resolvedDefaults,
|
||||||
|
agent: resolvedAgent,
|
||||||
|
allowlist: allowlist,
|
||||||
|
file: file)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolveDefaults() -> ExecApprovalsResolvedDefaults {
|
||||||
|
let file = self.ensureFile()
|
||||||
|
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||||
|
return ExecApprovalsResolvedDefaults(
|
||||||
|
security: defaults.security ?? self.defaultSecurity,
|
||||||
|
ask: defaults.ask ?? self.defaultAsk,
|
||||||
|
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||||
|
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveDefaults(_ defaults: ExecApprovalsDefaults) {
|
||||||
|
self.updateFile { file in
|
||||||
|
file.defaults = defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) {
|
||||||
|
self.updateFile { file in
|
||||||
|
var defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||||
|
mutate(&defaults)
|
||||||
|
file.defaults = defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) {
|
||||||
|
self.updateFile { file in
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
if agent.isEmpty {
|
||||||
|
agents.removeValue(forKey: key)
|
||||||
|
} else {
|
||||||
|
agents[key] = agent
|
||||||
|
}
|
||||||
|
file.agents = agents.isEmpty ? nil : agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func addAllowlistEntry(agentId: String?, pattern: String) {
|
||||||
|
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
self.updateFile { file in
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||||
|
var allowlist = entry.allowlist ?? []
|
||||||
|
if allowlist.contains(where: { $0.pattern == trimmed }) { return }
|
||||||
|
allowlist.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: Date().timeIntervalSince1970 * 1000))
|
||||||
|
entry.allowlist = allowlist
|
||||||
|
agents[key] = entry
|
||||||
|
file.agents = agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func recordAllowlistUse(
|
||||||
|
agentId: String?,
|
||||||
|
pattern: String,
|
||||||
|
command: String,
|
||||||
|
resolvedPath: String?)
|
||||||
|
{
|
||||||
|
self.updateFile { file in
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||||
|
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
|
||||||
|
guard item.pattern == pattern else { return item }
|
||||||
|
return ExecAllowlistEntry(
|
||||||
|
pattern: item.pattern,
|
||||||
|
lastUsedAt: Date().timeIntervalSince1970 * 1000,
|
||||||
|
lastUsedCommand: command,
|
||||||
|
lastResolvedPath: resolvedPath)
|
||||||
|
}
|
||||||
|
entry.allowlist = allowlist
|
||||||
|
agents[key] = entry
|
||||||
|
file.agents = agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) {
|
||||||
|
self.updateFile { file in
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||||
|
let cleaned = allowlist
|
||||||
|
.map { item in
|
||||||
|
ExecAllowlistEntry(
|
||||||
|
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
lastUsedAt: item.lastUsedAt,
|
||||||
|
lastUsedCommand: item.lastUsedCommand,
|
||||||
|
lastResolvedPath: item.lastResolvedPath)
|
||||||
|
}
|
||||||
|
.filter { !$0.pattern.isEmpty }
|
||||||
|
entry.allowlist = cleaned
|
||||||
|
agents[key] = entry
|
||||||
|
file.agents = agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) {
|
||||||
|
self.updateFile { file in
|
||||||
|
let key = self.agentKey(agentId)
|
||||||
|
var agents = file.agents ?? [:]
|
||||||
|
var entry = agents[key] ?? ExecApprovalsAgent()
|
||||||
|
mutate(&entry)
|
||||||
|
if entry.isEmpty {
|
||||||
|
agents.removeValue(forKey: key)
|
||||||
|
} else {
|
||||||
|
agents[key] = entry
|
||||||
|
}
|
||||||
|
file.agents = agents.isEmpty ? nil : agents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) {
|
||||||
|
var file = self.ensureFile()
|
||||||
|
mutate(&file)
|
||||||
|
self.saveFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func generateToken() -> String {
|
||||||
|
var bytes = [UInt8](repeating: 0, count: 24)
|
||||||
|
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||||
|
if status == errSecSuccess {
|
||||||
|
return Data(bytes)
|
||||||
|
.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
return UUID().uuidString
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hashRaw(_ raw: String?) -> String {
|
||||||
|
let data = Data((raw ?? "").utf8)
|
||||||
|
let digest = SHA256.hash(data: data)
|
||||||
|
return digest.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func expandPath(_ raw: String) -> String {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if trimmed == "~" {
|
||||||
|
return FileManager.default.homeDirectoryForCurrentUser.path
|
||||||
|
}
|
||||||
|
if trimmed.hasPrefix("~/") {
|
||||||
|
let suffix = trimmed.dropFirst(2)
|
||||||
|
return FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(String(suffix)).path
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func agentKey(_ agentId: String?) -> String {
|
||||||
|
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return trimmed.isEmpty ? "default" : trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecCommandResolution: Sendable {
|
||||||
|
let rawExecutable: String
|
||||||
|
let resolvedPath: String?
|
||||||
|
let executableName: String
|
||||||
|
let cwd: String?
|
||||||
|
|
||||||
|
static func resolve(
|
||||||
|
command: [String],
|
||||||
|
rawCommand: String?,
|
||||||
|
cwd: String?,
|
||||||
|
env: [String: String]?
|
||||||
|
) -> ExecCommandResolution? {
|
||||||
|
let trimmedRaw = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedRaw.isEmpty, let token = self.parseFirstToken(trimmedRaw) {
|
||||||
|
return self.resolveExecutable(rawExecutable: token, cwd: cwd, env: env)
|
||||||
|
}
|
||||||
|
return self.resolve(command: command, cwd: cwd, env: env)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||||
|
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func resolveExecutable(
|
||||||
|
rawExecutable: String,
|
||||||
|
cwd: String?,
|
||||||
|
env: [String: String]?
|
||||||
|
) -> ExecCommandResolution? {
|
||||||
|
let expanded = rawExecutable.hasPrefix("~") ? (rawExecutable as NSString).expandingTildeInPath : rawExecutable
|
||||||
|
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
|
||||||
|
let resolvedPath: String? = {
|
||||||
|
if hasPathSeparator {
|
||||||
|
if expanded.hasPrefix("/") {
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath
|
||||||
|
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
|
||||||
|
}
|
||||||
|
let searchPaths = self.searchPaths(from: env)
|
||||||
|
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
|
||||||
|
}()
|
||||||
|
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
|
||||||
|
return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseFirstToken(_ command: String) -> String? {
|
||||||
|
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
guard let first = trimmed.first else { return nil }
|
||||||
|
if first == "\"" || first == "'" {
|
||||||
|
let rest = trimmed.dropFirst()
|
||||||
|
if let end = rest.firstIndex(of: first) {
|
||||||
|
return String(rest[..<end])
|
||||||
|
}
|
||||||
|
return String(rest)
|
||||||
|
}
|
||||||
|
return trimmed.split(whereSeparator: { $0.isWhitespace }).first.map(String.init)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func searchPaths(from env: [String: String]?) -> [String] {
|
||||||
|
let raw = env?["PATH"]
|
||||||
|
if let raw, !raw.isEmpty {
|
||||||
|
return raw.split(separator: ":").map(String.init)
|
||||||
|
}
|
||||||
|
return CommandResolver.preferredPaths()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecCommandFormatter {
|
||||||
|
static func displayString(for argv: [String]) -> String {
|
||||||
|
argv.map { arg in
|
||||||
|
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return "\"\"" }
|
||||||
|
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
|
||||||
|
if !needsQuotes { return trimmed }
|
||||||
|
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
|
||||||
|
return "\"\(escaped)\""
|
||||||
|
}.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func displayString(for argv: [String], rawCommand: String?) -> String {
|
||||||
|
let trimmed = rawCommand?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmed.isEmpty { return trimmed }
|
||||||
|
return self.displayString(for: argv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecAllowlistMatcher {
|
||||||
|
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
||||||
|
guard let resolution, !entries.isEmpty else { return nil }
|
||||||
|
let rawExecutable = resolution.rawExecutable
|
||||||
|
let resolvedPath = resolution.resolvedPath
|
||||||
|
let executableName = resolution.executableName
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if pattern.isEmpty { continue }
|
||||||
|
let hasPath = pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
|
||||||
|
if hasPath {
|
||||||
|
let target = resolvedPath ?? rawExecutable
|
||||||
|
if self.matches(pattern: pattern, target: target) { return entry }
|
||||||
|
} else if self.matches(pattern: pattern, target: executableName) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func matches(pattern: String, target: String) -> Bool {
|
||||||
|
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return false }
|
||||||
|
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
|
||||||
|
let normalizedPattern = self.normalizeMatchTarget(expanded)
|
||||||
|
let normalizedTarget = self.normalizeMatchTarget(target)
|
||||||
|
guard let regex = self.regex(for: normalizedPattern) else { return false }
|
||||||
|
let range = NSRange(location: 0, length: normalizedTarget.utf16.count)
|
||||||
|
return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeMatchTarget(_ value: String) -> String {
|
||||||
|
value.replacingOccurrences(of: "\\\\", with: "/").lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func regex(for pattern: String) -> NSRegularExpression? {
|
||||||
|
var regex = "^"
|
||||||
|
var idx = pattern.startIndex
|
||||||
|
while idx < pattern.endIndex {
|
||||||
|
let ch = pattern[idx]
|
||||||
|
if ch == "*" {
|
||||||
|
let next = pattern.index(after: idx)
|
||||||
|
if next < pattern.endIndex, pattern[next] == "*" {
|
||||||
|
regex += ".*"
|
||||||
|
idx = pattern.index(after: next)
|
||||||
|
} else {
|
||||||
|
regex += "[^/]*"
|
||||||
|
idx = next
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == "?" {
|
||||||
|
regex += "."
|
||||||
|
idx = pattern.index(after: idx)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
regex += NSRegularExpression.escapedPattern(for: String(ch))
|
||||||
|
idx = pattern.index(after: idx)
|
||||||
|
}
|
||||||
|
regex += "$"
|
||||||
|
return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecEventPayload: Codable, Sendable {
|
||||||
|
var sessionKey: String
|
||||||
|
var runId: String
|
||||||
|
var host: String
|
||||||
|
var command: String?
|
||||||
|
var exitCode: Int?
|
||||||
|
var timedOut: Bool?
|
||||||
|
var success: Bool?
|
||||||
|
var output: String?
|
||||||
|
var reason: String?
|
||||||
|
|
||||||
|
static func truncateOutput(_ raw: String, maxChars: Int = 20_000) -> String? {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
if trimmed.count <= maxChars { return trimmed }
|
||||||
|
let suffix = trimmed.suffix(maxChars)
|
||||||
|
return "... (truncated) \(suffix)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor SkillBinsCache {
|
||||||
|
static let shared = SkillBinsCache()
|
||||||
|
|
||||||
|
private var bins: Set<String> = []
|
||||||
|
private var lastRefresh: Date?
|
||||||
|
private let refreshInterval: TimeInterval = 90
|
||||||
|
|
||||||
|
func currentBins(force: Bool = false) async -> Set<String> {
|
||||||
|
if force || self.isStale() {
|
||||||
|
await self.refresh()
|
||||||
|
}
|
||||||
|
return self.bins
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
do {
|
||||||
|
let report = try await GatewayConnection.shared.skillsStatus()
|
||||||
|
var next = Set<String>()
|
||||||
|
for skill in report.skills {
|
||||||
|
for bin in skill.requirements.bins {
|
||||||
|
let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !trimmed.isEmpty { next.insert(trimmed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.bins = next
|
||||||
|
self.lastRefresh = Date()
|
||||||
|
} catch {
|
||||||
|
if self.lastRefresh == nil {
|
||||||
|
self.bins = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isStale() -> Bool {
|
||||||
|
guard let lastRefresh else { return true }
|
||||||
|
return Date().timeIntervalSince(lastRefresh) > self.refreshInterval
|
||||||
|
}
|
||||||
|
}
|
||||||
666
apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift
Normal file
666
apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift
Normal file
@ -0,0 +1,666 @@
|
|||||||
|
import AppKit
|
||||||
|
import ClawdbotKit
|
||||||
|
import CryptoKit
|
||||||
|
import Darwin
|
||||||
|
import Foundation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
struct ExecApprovalPromptRequest: Codable, Sendable {
|
||||||
|
var command: String
|
||||||
|
var cwd: String?
|
||||||
|
var host: String?
|
||||||
|
var security: String?
|
||||||
|
var ask: String?
|
||||||
|
var agentId: String?
|
||||||
|
var resolvedPath: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExecApprovalSocketRequest: Codable {
|
||||||
|
var type: String
|
||||||
|
var token: String
|
||||||
|
var id: String
|
||||||
|
var request: ExecApprovalPromptRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ExecApprovalSocketDecision: Codable {
|
||||||
|
var type: String
|
||||||
|
var id: String
|
||||||
|
var decision: ExecApprovalDecision
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct ExecHostSocketRequest: Codable {
|
||||||
|
var type: String
|
||||||
|
var id: String
|
||||||
|
var nonce: String
|
||||||
|
var ts: Int
|
||||||
|
var hmac: String
|
||||||
|
var requestJson: String
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct ExecHostRequest: Codable {
|
||||||
|
var command: [String]
|
||||||
|
var rawCommand: String?
|
||||||
|
var cwd: String?
|
||||||
|
var env: [String: String]?
|
||||||
|
var timeoutMs: Int?
|
||||||
|
var needsScreenRecording: Bool?
|
||||||
|
var agentId: String?
|
||||||
|
var sessionKey: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct ExecHostRunResult: Codable {
|
||||||
|
var exitCode: Int?
|
||||||
|
var timedOut: Bool
|
||||||
|
var success: Bool
|
||||||
|
var stdout: String
|
||||||
|
var stderr: String
|
||||||
|
var error: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct ExecHostError: Codable {
|
||||||
|
var code: String
|
||||||
|
var message: String
|
||||||
|
var reason: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate struct ExecHostResponse: Codable {
|
||||||
|
var type: String
|
||||||
|
var id: String
|
||||||
|
var ok: Bool
|
||||||
|
var payload: ExecHostRunResult?
|
||||||
|
var error: ExecHostError?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecApprovalsSocketClient {
|
||||||
|
private struct TimeoutError: LocalizedError {
|
||||||
|
var message: String
|
||||||
|
var errorDescription: String? { message }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func requestDecision(
|
||||||
|
socketPath: String,
|
||||||
|
token: String,
|
||||||
|
request: ExecApprovalPromptRequest,
|
||||||
|
timeoutMs: Int = 15_000) async -> ExecApprovalDecision?
|
||||||
|
{
|
||||||
|
let trimmedPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmedPath.isEmpty, !trimmedToken.isEmpty else { return nil }
|
||||||
|
do {
|
||||||
|
return try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: {
|
||||||
|
TimeoutError(message: "exec approvals socket timeout")
|
||||||
|
}, operation: {
|
||||||
|
try await Task.detached {
|
||||||
|
try self.requestDecisionSync(
|
||||||
|
socketPath: trimmedPath,
|
||||||
|
token: trimmedToken,
|
||||||
|
request: request)
|
||||||
|
}.value
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requestDecisionSync(
|
||||||
|
socketPath: String,
|
||||||
|
token: String,
|
||||||
|
request: ExecApprovalPromptRequest) throws -> ExecApprovalDecision?
|
||||||
|
{
|
||||||
|
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||||
|
guard fd >= 0 else {
|
||||||
|
throw NSError(domain: "ExecApprovals", code: 1, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "socket create failed",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
var addr = sockaddr_un()
|
||||||
|
addr.sun_family = sa_family_t(AF_UNIX)
|
||||||
|
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
||||||
|
if socketPath.utf8.count >= maxLen {
|
||||||
|
throw NSError(domain: "ExecApprovals", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "socket path too long",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
socketPath.withCString { cstr in
|
||||||
|
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
||||||
|
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self)
|
||||||
|
strncpy(raw, cstr, maxLen - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let size = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||||
|
let result = withUnsafePointer(to: &addr) { ptr in
|
||||||
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
|
||||||
|
connect(fd, rebound, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result != 0 {
|
||||||
|
throw NSError(domain: "ExecApprovals", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "socket connect failed",
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||||
|
|
||||||
|
let message = ExecApprovalSocketRequest(
|
||||||
|
type: "request",
|
||||||
|
token: token,
|
||||||
|
id: UUID().uuidString,
|
||||||
|
request: request)
|
||||||
|
let data = try JSONEncoder().encode(message)
|
||||||
|
var payload = data
|
||||||
|
payload.append(0x0A)
|
||||||
|
try handle.write(contentsOf: payload)
|
||||||
|
|
||||||
|
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||||
|
let lineData = line.data(using: .utf8)
|
||||||
|
else { return nil }
|
||||||
|
let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData)
|
||||||
|
return response.decision
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
|
||||||
|
var buffer = Data()
|
||||||
|
while buffer.count < maxBytes {
|
||||||
|
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
||||||
|
if chunk.isEmpty { break }
|
||||||
|
buffer.append(chunk)
|
||||||
|
if buffer.contains(0x0A) { break }
|
||||||
|
}
|
||||||
|
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
||||||
|
guard !buffer.isEmpty else { return nil }
|
||||||
|
return String(data: buffer, encoding: .utf8)
|
||||||
|
}
|
||||||
|
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||||
|
return String(data: lineData, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ExecApprovalsPromptServer {
|
||||||
|
static let shared = ExecApprovalsPromptServer()
|
||||||
|
|
||||||
|
private var server: ExecApprovalsSocketServer?
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard self.server == nil else { return }
|
||||||
|
let approvals = ExecApprovalsStore.resolve(agentId: nil)
|
||||||
|
let server = ExecApprovalsSocketServer(
|
||||||
|
socketPath: approvals.socketPath,
|
||||||
|
token: approvals.token,
|
||||||
|
onPrompt: { request in
|
||||||
|
await ExecApprovalsPromptPresenter.prompt(request)
|
||||||
|
},
|
||||||
|
onExec: { request in
|
||||||
|
await ExecHostExecutor.handle(request)
|
||||||
|
})
|
||||||
|
server.start()
|
||||||
|
self.server = server
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.server?.stop()
|
||||||
|
self.server = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExecApprovalsPromptPresenter {
|
||||||
|
@MainActor
|
||||||
|
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.messageText = "Allow this command?"
|
||||||
|
|
||||||
|
var details = "Clawdbot wants to run:\n\n\(request.command)"
|
||||||
|
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedCwd.isEmpty {
|
||||||
|
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
||||||
|
}
|
||||||
|
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedAgent.isEmpty {
|
||||||
|
details += "\n\nAgent:\n\(trimmedAgent)"
|
||||||
|
}
|
||||||
|
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedPath.isEmpty {
|
||||||
|
details += "\n\nExecutable:\n\(trimmedPath)"
|
||||||
|
}
|
||||||
|
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if !trimmedHost.isEmpty {
|
||||||
|
details += "\n\nHost:\n\(trimmedHost)"
|
||||||
|
}
|
||||||
|
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
|
||||||
|
details += "\n\nSecurity:\n\(security)"
|
||||||
|
}
|
||||||
|
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
|
||||||
|
details += "\nAsk mode:\n\(ask)"
|
||||||
|
}
|
||||||
|
details += "\n\nThis runs on this machine."
|
||||||
|
alert.informativeText = details
|
||||||
|
|
||||||
|
alert.addButton(withTitle: "Allow Once")
|
||||||
|
alert.addButton(withTitle: "Always Allow")
|
||||||
|
alert.addButton(withTitle: "Don't Allow")
|
||||||
|
|
||||||
|
switch alert.runModal() {
|
||||||
|
case .alertFirstButtonReturn:
|
||||||
|
return .allowOnce
|
||||||
|
case .alertSecondButtonReturn:
|
||||||
|
return .allowAlways
|
||||||
|
default:
|
||||||
|
return .deny
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
fileprivate enum ExecHostExecutor {
|
||||||
|
private static let blockedEnvKeys: Set<String> = [
|
||||||
|
"PATH",
|
||||||
|
"NODE_OPTIONS",
|
||||||
|
"PYTHONHOME",
|
||||||
|
"PYTHONPATH",
|
||||||
|
"PERL5LIB",
|
||||||
|
"PERL5OPT",
|
||||||
|
"RUBYOPT",
|
||||||
|
]
|
||||||
|
|
||||||
|
private static let blockedEnvPrefixes: [String] = [
|
||||||
|
"DYLD_",
|
||||||
|
"LD_",
|
||||||
|
]
|
||||||
|
|
||||||
|
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
||||||
|
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
guard !command.isEmpty else {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayCommand = ExecCommandFormatter.displayString(
|
||||||
|
for: command,
|
||||||
|
rawCommand: request.rawCommand)
|
||||||
|
let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil
|
||||||
|
let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent)
|
||||||
|
let security = approvals.agent.security
|
||||||
|
let ask = approvals.agent.ask
|
||||||
|
let autoAllowSkills = approvals.agent.autoAllowSkills
|
||||||
|
let env = self.sanitizedEnv(request.env)
|
||||||
|
let resolution = ExecCommandResolution.resolve(
|
||||||
|
command: command,
|
||||||
|
rawCommand: request.rawCommand,
|
||||||
|
cwd: request.cwd,
|
||||||
|
env: env)
|
||||||
|
let allowlistMatch = security == .allowlist
|
||||||
|
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
||||||
|
: nil
|
||||||
|
let skillAllow: Bool
|
||||||
|
if autoAllowSkills, let name = resolution?.executableName {
|
||||||
|
let bins = await SkillBinsCache.shared.currentBins()
|
||||||
|
skillAllow = bins.contains(name)
|
||||||
|
} else {
|
||||||
|
skillAllow = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if security == .deny {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny", reason: "security=deny"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let requiresAsk: Bool = {
|
||||||
|
if ask == .always { return true }
|
||||||
|
if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true }
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
|
||||||
|
var approvedByAsk = false
|
||||||
|
if requiresAsk {
|
||||||
|
let decision = ExecApprovalsPromptPresenter.prompt(
|
||||||
|
ExecApprovalPromptRequest(
|
||||||
|
command: displayCommand,
|
||||||
|
cwd: request.cwd,
|
||||||
|
host: "node",
|
||||||
|
security: security.rawValue,
|
||||||
|
ask: ask.rawValue,
|
||||||
|
agentId: trimmedAgent,
|
||||||
|
resolvedPath: resolution?.resolvedPath))
|
||||||
|
|
||||||
|
switch decision {
|
||||||
|
case .deny:
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: user denied", reason: "user-denied"))
|
||||||
|
case .allowAlways:
|
||||||
|
approvedByAsk = true
|
||||||
|
if security == .allowlist {
|
||||||
|
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||||
|
if !pattern.isEmpty {
|
||||||
|
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .allowOnce:
|
||||||
|
approvedByAsk = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss", reason: "allowlist-miss"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let match = allowlistMatch {
|
||||||
|
ExecApprovalsStore.recordAllowlistUse(
|
||||||
|
agentId: trimmedAgent,
|
||||||
|
pattern: match.pattern,
|
||||||
|
command: displayCommand,
|
||||||
|
resolvedPath: resolution?.resolvedPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.needsScreenRecording == true {
|
||||||
|
let authorized = await PermissionManager
|
||||||
|
.status([.screenRecording])[.screenRecording] ?? false
|
||||||
|
if !authorized {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording", reason: "permission:screenRecording"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
|
||||||
|
let result = await Task.detached { () -> ShellExecutor.ShellResult in
|
||||||
|
await ShellExecutor.runDetailed(
|
||||||
|
command: command,
|
||||||
|
cwd: request.cwd,
|
||||||
|
env: env,
|
||||||
|
timeout: timeoutSec)
|
||||||
|
}.value
|
||||||
|
let payload = ExecHostRunResult(
|
||||||
|
exitCode: result.exitCode,
|
||||||
|
timedOut: result.timedOut,
|
||||||
|
success: result.success,
|
||||||
|
stdout: result.stdout,
|
||||||
|
stderr: result.stderr,
|
||||||
|
error: result.errorMessage)
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: UUID().uuidString,
|
||||||
|
ok: true,
|
||||||
|
payload: payload,
|
||||||
|
error: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
|
||||||
|
guard let overrides else { return nil }
|
||||||
|
var merged = ProcessInfo.processInfo.environment
|
||||||
|
for (rawKey, value) in overrides {
|
||||||
|
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !key.isEmpty else { continue }
|
||||||
|
let upper = key.uppercased()
|
||||||
|
if self.blockedEnvKeys.contains(upper) { continue }
|
||||||
|
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
||||||
|
merged[key] = value
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
||||||
|
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
|
||||||
|
private let socketPath: String
|
||||||
|
private let token: String
|
||||||
|
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
|
||||||
|
private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse
|
||||||
|
private var socketFD: Int32 = -1
|
||||||
|
private var acceptTask: Task<Void, Never>?
|
||||||
|
private var isRunning = false
|
||||||
|
|
||||||
|
init(
|
||||||
|
socketPath: String,
|
||||||
|
token: String,
|
||||||
|
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision,
|
||||||
|
onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse)
|
||||||
|
{
|
||||||
|
self.socketPath = socketPath
|
||||||
|
self.token = token
|
||||||
|
self.onPrompt = onPrompt
|
||||||
|
self.onExec = onExec
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !self.isRunning else { return }
|
||||||
|
self.isRunning = true
|
||||||
|
self.acceptTask = Task.detached { [weak self] in
|
||||||
|
await self?.runAcceptLoop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
self.isRunning = false
|
||||||
|
self.acceptTask?.cancel()
|
||||||
|
self.acceptTask = nil
|
||||||
|
if self.socketFD >= 0 {
|
||||||
|
close(self.socketFD)
|
||||||
|
self.socketFD = -1
|
||||||
|
}
|
||||||
|
if !self.socketPath.isEmpty {
|
||||||
|
unlink(self.socketPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runAcceptLoop() async {
|
||||||
|
let fd = self.openSocket()
|
||||||
|
guard fd >= 0 else {
|
||||||
|
self.isRunning = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.socketFD = fd
|
||||||
|
while self.isRunning {
|
||||||
|
var addr = sockaddr_un()
|
||||||
|
var len = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||||
|
let client = withUnsafeMutablePointer(to: &addr) { ptr in
|
||||||
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
|
||||||
|
accept(fd, rebound, &len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if client < 0 {
|
||||||
|
if errno == EINTR { continue }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
await self?.handleClient(fd: client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openSocket() -> Int32 {
|
||||||
|
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||||
|
guard fd >= 0 else {
|
||||||
|
self.logger.error("exec approvals socket create failed")
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
unlink(self.socketPath)
|
||||||
|
var addr = sockaddr_un()
|
||||||
|
addr.sun_family = sa_family_t(AF_UNIX)
|
||||||
|
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
|
||||||
|
if self.socketPath.utf8.count >= maxLen {
|
||||||
|
self.logger.error("exec approvals socket path too long")
|
||||||
|
close(fd)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
self.socketPath.withCString { cstr in
|
||||||
|
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
|
||||||
|
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self)
|
||||||
|
memset(raw, 0, maxLen)
|
||||||
|
strncpy(raw, cstr, maxLen - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let size = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||||
|
let result = withUnsafePointer(to: &addr) { ptr in
|
||||||
|
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
|
||||||
|
bind(fd, rebound, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result != 0 {
|
||||||
|
self.logger.error("exec approvals socket bind failed")
|
||||||
|
close(fd)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if listen(fd, 16) != 0 {
|
||||||
|
self.logger.error("exec approvals socket listen failed")
|
||||||
|
close(fd)
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
chmod(self.socketPath, 0o600)
|
||||||
|
self.logger.info("exec approvals socket listening at \(self.socketPath, privacy: .public)")
|
||||||
|
return fd
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleClient(fd: Int32) async {
|
||||||
|
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||||
|
do {
|
||||||
|
guard self.isAllowedPeer(fd: fd) else {
|
||||||
|
try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||||
|
let data = line.data(using: .utf8)
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard
|
||||||
|
let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let type = envelope["type"] as? String
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if type == "request" {
|
||||||
|
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
|
||||||
|
guard request.token == self.token else {
|
||||||
|
try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let decision = await self.onPrompt(request.request)
|
||||||
|
try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if type == "exec" {
|
||||||
|
let request = try JSONDecoder().decode(ExecHostSocketRequest.self, from: data)
|
||||||
|
let response = await self.handleExecRequest(request)
|
||||||
|
try self.sendExecResponse(handle: handle, response: response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
|
||||||
|
var buffer = Data()
|
||||||
|
while buffer.count < maxBytes {
|
||||||
|
let chunk = try handle.read(upToCount: 4096) ?? Data()
|
||||||
|
if chunk.isEmpty { break }
|
||||||
|
buffer.append(chunk)
|
||||||
|
if buffer.contains(0x0A) { break }
|
||||||
|
}
|
||||||
|
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
|
||||||
|
guard !buffer.isEmpty else { return nil }
|
||||||
|
return String(data: buffer, encoding: .utf8)
|
||||||
|
}
|
||||||
|
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||||
|
return String(data: lineData, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendApprovalResponse(
|
||||||
|
handle: FileHandle,
|
||||||
|
id: String,
|
||||||
|
decision: ExecApprovalDecision) throws
|
||||||
|
{
|
||||||
|
let response = ExecApprovalSocketDecision(type: "decision", id: id, decision: decision)
|
||||||
|
let data = try JSONEncoder().encode(response)
|
||||||
|
var payload = data
|
||||||
|
payload.append(0x0A)
|
||||||
|
try handle.write(contentsOf: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendExecResponse(handle: FileHandle, response: ExecHostResponse) throws {
|
||||||
|
let data = try JSONEncoder().encode(response)
|
||||||
|
var payload = data
|
||||||
|
payload.append(0x0A)
|
||||||
|
try handle.write(contentsOf: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isAllowedPeer(fd: Int32) -> Bool {
|
||||||
|
var uid = uid_t(0)
|
||||||
|
var gid = gid_t(0)
|
||||||
|
if getpeereid(fd, &uid, &gid) != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return uid == geteuid()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleExecRequest(_ request: ExecHostSocketRequest) async -> ExecHostResponse {
|
||||||
|
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||||
|
if abs(nowMs - request.ts) > 10_000 {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: request.id,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl"))
|
||||||
|
}
|
||||||
|
let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson)
|
||||||
|
if expected != request.hmac {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: request.id,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid auth", reason: "hmac"))
|
||||||
|
}
|
||||||
|
guard let requestData = request.requestJson.data(using: .utf8),
|
||||||
|
let payload = try? JSONDecoder().decode(ExecHostRequest.self, from: requestData)
|
||||||
|
else {
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: request.id,
|
||||||
|
ok: false,
|
||||||
|
payload: nil,
|
||||||
|
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid payload", reason: "json"))
|
||||||
|
}
|
||||||
|
let response = await self.onExec(payload)
|
||||||
|
return ExecHostResponse(
|
||||||
|
type: "exec-res",
|
||||||
|
id: request.id,
|
||||||
|
ok: response.ok,
|
||||||
|
payload: response.payload,
|
||||||
|
error: response.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hmacHex(nonce: String, ts: Int, requestJson: String) -> String {
|
||||||
|
let key = SymmetricKey(data: Data(self.token.utf8))
|
||||||
|
let message = "\(nonce):\(ts):\(requestJson)"
|
||||||
|
let mac = HMAC<SHA256>.authenticationCode(for: Data(message.utf8), using: key)
|
||||||
|
return mac.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -249,6 +249,13 @@ actor GatewayConnection {
|
|||||||
return trimmed.isEmpty ? nil : trimmed
|
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?) {
|
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
|
||||||
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
||||||
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|||||||
@ -27,7 +27,11 @@ struct Semver: Comparable, CustomStringConvertible, Sendable {
|
|||||||
else { return nil }
|
else { return nil }
|
||||||
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5")
|
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5")
|
||||||
let patchRaw = String(parts[2])
|
let patchRaw = String(parts[2])
|
||||||
let patchNumeric = patchRaw.split { $0 == "-" || $0 == "+" }.first.flatMap { Int($0) } ?? 0
|
guard let patchToken = patchRaw.split(whereSeparator: { $0 == "-" || $0 == "+" }).first,
|
||||||
|
let patchNumeric = Int(patchToken)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return Semver(major: major, minor: minor, patch: patchNumeric)
|
return Semver(major: major, minor: minor, patch: patchNumeric)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,10 @@ enum GatewayLaunchAgentManager {
|
|||||||
|
|
||||||
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? {
|
||||||
_ = bundlePath
|
_ = bundlePath
|
||||||
|
guard !CommandResolver.connectionModeIsRemote() else {
|
||||||
|
self.logger.info("launchd change skipped (remote mode)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if enabled, self.isLaunchAgentWriteDisabled() {
|
if enabled, self.isLaunchAgentWriteDisabled() {
|
||||||
self.logger.info("launchd enable skipped (disable marker set)")
|
self.logger.info("launchd enable skipped (disable marker set)")
|
||||||
return nil
|
return nil
|
||||||
@ -112,7 +116,9 @@ extension GatewayLaunchAgentManager {
|
|||||||
{
|
{
|
||||||
let command = CommandResolver.clawdbotCommand(
|
let command = CommandResolver.clawdbotCommand(
|
||||||
subcommand: "daemon",
|
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
|
var env = ProcessInfo.processInfo.environment
|
||||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
||||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
||||||
|
|||||||
@ -114,6 +114,9 @@ final class GatewayProcessManager {
|
|||||||
self.lastFailureReason = nil
|
self.lastFailureReason = nil
|
||||||
self.status = .stopped
|
self.status = .stopped
|
||||||
self.logger.info("gateway stop requested")
|
self.logger.info("gateway stop requested")
|
||||||
|
if CommandResolver.connectionModeIsRemote() {
|
||||||
|
return
|
||||||
|
}
|
||||||
let bundlePath = Bundle.main.bundleURL.path
|
let bundlePath = Bundle.main.bundleURL.path
|
||||||
Task {
|
Task {
|
||||||
_ = await GatewayLaunchAgentManager.set(
|
_ = await GatewayLaunchAgentManager.set(
|
||||||
|
|||||||
@ -83,27 +83,7 @@ struct GeneralSettings: View {
|
|||||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||||
binding: self.$cameraEnabled)
|
binding: self.$cameraEnabled)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
SystemRunSettingsView()
|
||||||
Text("Node Run Commands")
|
|
||||||
.font(.body)
|
|
||||||
|
|
||||||
Picker("", selection: self.$state.systemRunPolicy) {
|
|
||||||
ForEach(SystemRunPolicy.allCases) { policy in
|
|
||||||
Text(policy.title).tag(policy)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
|
|
||||||
Text("""
|
|
||||||
Controls remote command execution on this Mac when it is paired as a node. \
|
|
||||||
"Always Ask" prompts on each command; "Always Allow" runs without prompts; \
|
|
||||||
"Never" disables `system.run`.
|
|
||||||
""")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Location Access")
|
Text("Location Access")
|
||||||
|
|||||||
@ -1,81 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
|
|
||||||
enum MacNodeConfigFile {
|
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "mac-node-config")
|
|
||||||
|
|
||||||
static func url() -> URL {
|
|
||||||
ClawdbotPaths.stateDirURL.appendingPathComponent("macos-node.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func loadDict() -> [String: Any] {
|
|
||||||
let url = self.url()
|
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
|
|
||||||
do {
|
|
||||||
let data = try Data(contentsOf: url)
|
|
||||||
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
||||||
self.logger.warning("mac node config JSON root invalid")
|
|
||||||
return [:]
|
|
||||||
}
|
|
||||||
return root
|
|
||||||
} catch {
|
|
||||||
self.logger.warning("mac node config read failed: \(error.localizedDescription, privacy: .public)")
|
|
||||||
return [:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func saveDict(_ dict: [String: Any]) {
|
|
||||||
do {
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
|
||||||
let url = self.url()
|
|
||||||
try FileManager.default.createDirectory(
|
|
||||||
at: url.deletingLastPathComponent(),
|
|
||||||
withIntermediateDirectories: true)
|
|
||||||
try data.write(to: url, options: [.atomic])
|
|
||||||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
|
||||||
} catch {
|
|
||||||
self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func systemRunPolicy() -> SystemRunPolicy? {
|
|
||||||
let root = self.loadDict()
|
|
||||||
let systemRun = root["systemRun"] as? [String: Any]
|
|
||||||
let raw = systemRun?["policy"] as? String
|
|
||||||
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
|
|
||||||
static func setSystemRunPolicy(_ policy: SystemRunPolicy) {
|
|
||||||
var root = self.loadDict()
|
|
||||||
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
|
||||||
systemRun["policy"] = policy.rawValue
|
|
||||||
root["systemRun"] = systemRun
|
|
||||||
self.saveDict(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func systemRunAllowlist() -> [String]? {
|
|
||||||
let root = self.loadDict()
|
|
||||||
let systemRun = root["systemRun"] as? [String: Any]
|
|
||||||
return systemRun?["allowlist"] as? [String]
|
|
||||||
}
|
|
||||||
|
|
||||||
static func setSystemRunAllowlist(_ allowlist: [String]) {
|
|
||||||
let cleaned = allowlist
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
var root = self.loadDict()
|
|
||||||
var systemRun = root["systemRun"] as? [String: Any] ?? [:]
|
|
||||||
if cleaned.isEmpty {
|
|
||||||
systemRun.removeValue(forKey: "allowlist")
|
|
||||||
} else {
|
|
||||||
systemRun["allowlist"] = cleaned
|
|
||||||
}
|
|
||||||
if systemRun.isEmpty {
|
|
||||||
root.removeValue(forKey: "systemRun")
|
|
||||||
} else {
|
|
||||||
root["systemRun"] = systemRun
|
|
||||||
}
|
|
||||||
self.saveDict(root)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -256,6 +256,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
TerminationSignalWatcher.shared.start()
|
TerminationSignalWatcher.shared.start()
|
||||||
NodePairingApprovalPrompter.shared.start()
|
NodePairingApprovalPrompter.shared.start()
|
||||||
|
ExecApprovalsPromptServer.shared.start()
|
||||||
MacNodeModeCoordinator.shared.start()
|
MacNodeModeCoordinator.shared.start()
|
||||||
VoiceWakeGlobalSettingsSync.shared.start()
|
VoiceWakeGlobalSettingsSync.shared.start()
|
||||||
Task { PresenceReporter.shared.start() }
|
Task { PresenceReporter.shared.start() }
|
||||||
@ -280,6 +281,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
PresenceReporter.shared.stop()
|
PresenceReporter.shared.stop()
|
||||||
NodePairingApprovalPrompter.shared.stop()
|
NodePairingApprovalPrompter.shared.stop()
|
||||||
|
ExecApprovalsPromptServer.shared.stop()
|
||||||
MacNodeModeCoordinator.shared.stop()
|
MacNodeModeCoordinator.shared.stop()
|
||||||
TerminationSignalWatcher.shared.stop()
|
TerminationSignalWatcher.shared.stop()
|
||||||
VoiceWakeGlobalSettingsSync.shared.stop()
|
VoiceWakeGlobalSettingsSync.shared.stop()
|
||||||
|
|||||||
@ -31,10 +31,10 @@ struct MenuContent: View {
|
|||||||
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var systemRunPolicyBinding: Binding<SystemRunPolicy> {
|
private var execApprovalModeBinding: Binding<ExecApprovalQuickMode> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { self.state.systemRunPolicy },
|
get: { self.state.execApprovalMode },
|
||||||
set: { self.state.systemRunPolicy = $0 })
|
set: { self.state.execApprovalMode = $0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@ -74,12 +74,12 @@ struct MenuContent: View {
|
|||||||
Toggle(isOn: self.$cameraEnabled) {
|
Toggle(isOn: self.$cameraEnabled) {
|
||||||
Label("Allow Camera", systemImage: "camera")
|
Label("Allow Camera", systemImage: "camera")
|
||||||
}
|
}
|
||||||
Picker(selection: self.systemRunPolicyBinding) {
|
Picker(selection: self.execApprovalModeBinding) {
|
||||||
ForEach(SystemRunPolicy.allCases) { policy in
|
ForEach(ExecApprovalQuickMode.allCases) { mode in
|
||||||
Text(policy.title).tag(policy)
|
Text(mode.title).tag(mode)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Node Run Commands", systemImage: "terminal")
|
Label("Exec Approvals", systemImage: "terminal")
|
||||||
}
|
}
|
||||||
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
|
||||||
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
|
||||||
|
|||||||
@ -14,6 +14,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
private weak var statusItem: NSStatusItem?
|
private weak var statusItem: NSStatusItem?
|
||||||
private var loadTask: Task<Void, Never>?
|
private var loadTask: Task<Void, Never>?
|
||||||
private var nodesLoadTask: Task<Void, Never>?
|
private var nodesLoadTask: Task<Void, Never>?
|
||||||
|
private var previewTasks: [Task<Void, Never>] = []
|
||||||
private var isMenuOpen = false
|
private var isMenuOpen = false
|
||||||
private var lastKnownMenuWidth: CGFloat?
|
private var lastKnownMenuWidth: CGFloat?
|
||||||
private var menuOpenWidth: CGFloat?
|
private var menuOpenWidth: CGFloat?
|
||||||
@ -87,6 +88,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
self.menuOpenWidth = nil
|
self.menuOpenWidth = nil
|
||||||
self.loadTask?.cancel()
|
self.loadTask?.cancel()
|
||||||
self.nodesLoadTask?.cancel()
|
self.nodesLoadTask?.cancel()
|
||||||
|
self.cancelPreviewTasks()
|
||||||
}
|
}
|
||||||
|
|
||||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||||
@ -107,6 +109,7 @@ extension MenuSessionsInjector {
|
|||||||
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
||||||
|
|
||||||
private func inject(into menu: NSMenu) {
|
private func inject(into menu: NSMenu) {
|
||||||
|
self.cancelPreviewTasks()
|
||||||
// Remove any previous injected items.
|
// Remove any previous injected items.
|
||||||
for item in menu.items where item.tag == self.tag {
|
for item in menu.items where item.tag == self.tag {
|
||||||
menu.removeItem(item)
|
menu.removeItem(item)
|
||||||
@ -280,9 +283,7 @@ extension MenuSessionsInjector {
|
|||||||
|
|
||||||
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
||||||
let rows = self.usageRows
|
let rows = self.usageRows
|
||||||
let errorText = self.cachedUsageErrorText
|
if rows.isEmpty {
|
||||||
|
|
||||||
if rows.isEmpty, errorText == nil {
|
|
||||||
return cursor
|
return cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -306,25 +307,6 @@ extension MenuSessionsInjector {
|
|||||||
menu.insertItem(headerItem, at: cursor)
|
menu.insertItem(headerItem, at: cursor)
|
||||||
cursor += 1
|
cursor += 1
|
||||||
|
|
||||||
if let errorText = errorText?.nonEmpty, !rows.isEmpty {
|
|
||||||
menu.insertItem(
|
|
||||||
self.makeMessageItem(
|
|
||||||
text: errorText,
|
|
||||||
symbolName: "exclamationmark.triangle",
|
|
||||||
width: width,
|
|
||||||
maxLines: 2),
|
|
||||||
at: cursor)
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if rows.isEmpty {
|
|
||||||
menu.insertItem(
|
|
||||||
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
|
|
||||||
at: cursor)
|
|
||||||
cursor += 1
|
|
||||||
return cursor
|
|
||||||
}
|
|
||||||
|
|
||||||
if let selectedProvider = self.selectedUsageProviderId,
|
if let selectedProvider = self.selectedUsageProviderId,
|
||||||
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
|
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
|
||||||
rows.count > 1
|
rows.count > 1
|
||||||
@ -440,6 +422,8 @@ extension MenuSessionsInjector {
|
|||||||
displayName: "Gateway",
|
displayName: "Gateway",
|
||||||
platform: platform,
|
platform: platform,
|
||||||
version: nil,
|
version: nil,
|
||||||
|
coreVersion: nil,
|
||||||
|
uiVersion: nil,
|
||||||
deviceFamily: nil,
|
deviceFamily: nil,
|
||||||
modelIdentifier: nil,
|
modelIdentifier: nil,
|
||||||
remoteIp: host,
|
remoteIp: host,
|
||||||
@ -473,15 +457,46 @@ extension MenuSessionsInjector {
|
|||||||
item.tag = self.tag
|
item.tag = self.tag
|
||||||
item.isEnabled = false
|
item.isEnabled = false
|
||||||
let view = AnyView(SessionMenuPreviewView(
|
let view = AnyView(SessionMenuPreviewView(
|
||||||
sessionKey: sessionKey,
|
|
||||||
width: width,
|
width: width,
|
||||||
maxItems: 10,
|
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
title: title))
|
title: title,
|
||||||
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
items: [],
|
||||||
|
status: .loading))
|
||||||
|
let hosting = NSHostingView(rootView: view)
|
||||||
|
hosting.frame.size.width = max(1, width)
|
||||||
|
let size = hosting.fittingSize
|
||||||
|
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||||
|
item.view = hosting
|
||||||
|
|
||||||
|
let task = Task { [weak hosting] in
|
||||||
|
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: sessionKey, maxItems: 10)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await MainActor.run {
|
||||||
|
guard let hosting else { return }
|
||||||
|
let nextView = AnyView(SessionMenuPreviewView(
|
||||||
|
width: width,
|
||||||
|
maxLines: maxLines,
|
||||||
|
title: title,
|
||||||
|
items: snapshot.items,
|
||||||
|
status: snapshot.status))
|
||||||
|
hosting.rootView = nextView
|
||||||
|
hosting.invalidateIntrinsicContentSize()
|
||||||
|
hosting.frame.size.width = max(1, width)
|
||||||
|
let size = hosting.fittingSize
|
||||||
|
hosting.frame.size.height = size.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.previewTasks.append(task)
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func cancelPreviewTasks() {
|
||||||
|
for task in self.previewTasks {
|
||||||
|
task.cancel()
|
||||||
|
}
|
||||||
|
self.previewTasks.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
|
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
|
||||||
let view = AnyView(
|
let view = AnyView(
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
@ -559,14 +574,11 @@ extension MenuSessionsInjector {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
self.cachedUsageSummary = try await UsageLoader.loadSummary()
|
self.cachedUsageSummary = try await UsageLoader.loadSummary()
|
||||||
self.cachedUsageErrorText = nil
|
|
||||||
self.usageCacheUpdatedAt = Date()
|
|
||||||
} catch {
|
} catch {
|
||||||
if self.cachedUsageSummary == nil {
|
self.cachedUsageSummary = nil
|
||||||
self.cachedUsageErrorText = self.compactUsageError(error)
|
self.cachedUsageErrorText = nil
|
||||||
}
|
|
||||||
self.usageCacheUpdatedAt = Date()
|
|
||||||
}
|
}
|
||||||
|
self.usageCacheUpdatedAt = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func compactUsageError(_ error: Error) -> String {
|
private func compactUsageError(_ error: Error) -> String {
|
||||||
@ -747,8 +759,8 @@ extension MenuSessionsInjector {
|
|||||||
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let version = entry.version?.nonEmpty {
|
if let version = NodeMenuEntryFormatter.detailRightVersion(entry)?.nonEmpty {
|
||||||
menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version)))
|
menu.addItem(self.makeNodeCopyItem(label: "Version", value: version))
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
|
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
|
||||||
|
|||||||
@ -95,6 +95,8 @@ actor MacNodeBridgePairingClient {
|
|||||||
displayName: hello.displayName,
|
displayName: hello.displayName,
|
||||||
platform: hello.platform,
|
platform: hello.platform,
|
||||||
version: hello.version,
|
version: hello.version,
|
||||||
|
coreVersion: hello.coreVersion,
|
||||||
|
uiVersion: hello.uiVersion,
|
||||||
deviceFamily: hello.deviceFamily,
|
deviceFamily: hello.deviceFamily,
|
||||||
modelIdentifier: hello.modelIdentifier,
|
modelIdentifier: hello.modelIdentifier,
|
||||||
caps: hello.caps,
|
caps: hello.caps,
|
||||||
|
|||||||
@ -43,7 +43,6 @@ final class MacNodeModeCoordinator {
|
|||||||
private func run() async {
|
private func run() async {
|
||||||
var retryDelay: UInt64 = 1_000_000_000
|
var retryDelay: UInt64 = 1_000_000_000
|
||||||
var lastCameraEnabled: Bool?
|
var lastCameraEnabled: Bool?
|
||||||
var lastSystemRunPolicy: SystemRunPolicy?
|
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
||||||
@ -60,15 +59,6 @@ final class MacNodeModeCoordinator {
|
|||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
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 {
|
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
||||||
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
||||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||||
@ -89,8 +79,13 @@ final class MacNodeModeCoordinator {
|
|||||||
if let mainSessionKey {
|
if let mainSessionKey {
|
||||||
await self?.runtime.updateMainSessionKey(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)
|
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
|
||||||
},
|
},
|
||||||
onInvoke: { [weak self] req in
|
onInvoke: { [weak self] req in
|
||||||
@ -119,12 +114,19 @@ final class MacNodeModeCoordinator {
|
|||||||
let caps = self.currentCaps()
|
let caps = self.currentCaps()
|
||||||
let commands = self.currentCommands(caps: caps)
|
let commands = self.currentCommands(caps: caps)
|
||||||
let permissions = await self.currentPermissions()
|
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(
|
return BridgeHello(
|
||||||
nodeId: Self.nodeId(),
|
nodeId: Self.nodeId(),
|
||||||
displayName: InstanceIdentity.displayName,
|
displayName: InstanceIdentity.displayName,
|
||||||
token: token,
|
token: token,
|
||||||
platform: "macos",
|
platform: "macos",
|
||||||
version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
|
version: uiVersion,
|
||||||
|
coreVersion: coreVersion?.isEmpty == false ? coreVersion : nil,
|
||||||
|
uiVersion: uiVersion,
|
||||||
deviceFamily: "Mac",
|
deviceFamily: "Mac",
|
||||||
modelIdentifier: InstanceIdentity.modelIdentifier,
|
modelIdentifier: InstanceIdentity.modelIdentifier,
|
||||||
caps: caps,
|
caps: caps,
|
||||||
@ -161,13 +163,12 @@ final class MacNodeModeCoordinator {
|
|||||||
ClawdbotCanvasA2UICommand.reset.rawValue,
|
ClawdbotCanvasA2UICommand.reset.rawValue,
|
||||||
MacNodeScreenCommand.record.rawValue,
|
MacNodeScreenCommand.record.rawValue,
|
||||||
ClawdbotSystemCommand.notify.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)
|
let capsSet = Set(caps)
|
||||||
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
|
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
|
||||||
commands.append(ClawdbotCameraCommand.list.rawValue)
|
commands.append(ClawdbotCameraCommand.list.rawValue)
|
||||||
|
|||||||
@ -8,6 +8,7 @@ actor MacNodeRuntime {
|
|||||||
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
|
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
|
||||||
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
|
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
|
||||||
private var mainSessionKey: String = "main"
|
private var mainSessionKey: String = "main"
|
||||||
|
private var eventSender: (@Sendable (String, String?) async -> Void)?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
|
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
|
||||||
@ -23,6 +24,10 @@ actor MacNodeRuntime {
|
|||||||
self.mainSessionKey = trimmed
|
self.mainSessionKey = trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setEventSender(_ sender: (@Sendable (String, String?) async -> Void)?) {
|
||||||
|
self.eventSender = sender
|
||||||
|
}
|
||||||
|
|
||||||
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||||
let command = req.command
|
let command = req.command
|
||||||
if self.isCanvasCommand(command), !Self.canvasEnabled() {
|
if self.isCanvasCommand(command), !Self.canvasEnabled() {
|
||||||
@ -59,6 +64,10 @@ actor MacNodeRuntime {
|
|||||||
return try await self.handleSystemWhich(req)
|
return try await self.handleSystemWhich(req)
|
||||||
case ClawdbotSystemCommand.notify.rawValue:
|
case ClawdbotSystemCommand.notify.rawValue:
|
||||||
return try await self.handleSystemNotify(req)
|
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:
|
default:
|
||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
|
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
|
||||||
}
|
}
|
||||||
@ -427,42 +436,168 @@ actor MacNodeRuntime {
|
|||||||
guard !command.isEmpty else {
|
guard !command.isEmpty else {
|
||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required")
|
||||||
}
|
}
|
||||||
|
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: params.rawCommand)
|
||||||
|
|
||||||
let wasAllowlisted = SystemRunAllowlist.contains(command)
|
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
switch Self.systemRunPolicy() {
|
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
||||||
case .never:
|
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,
|
||||||
|
rawCommand: params.rawCommand,
|
||||||
|
cwd: params.cwd,
|
||||||
|
env: env)
|
||||||
|
let allowlistMatch = security == .allowlist
|
||||||
|
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
||||||
|
: nil
|
||||||
|
let skillAllow: Bool
|
||||||
|
if autoAllowSkills, let name = resolution?.executableName {
|
||||||
|
let bins = await SkillBinsCache.shared.currentBins()
|
||||||
|
skillAllow = bins.contains(name)
|
||||||
|
} else {
|
||||||
|
skillAllow = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if security == .deny {
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: displayCommand,
|
||||||
|
reason: "security=deny"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
code: .unavailable,
|
code: .unavailable,
|
||||||
message: "SYSTEM_RUN_DISABLED: policy=never")
|
message: "SYSTEM_RUN_DISABLED: security=deny")
|
||||||
case .always:
|
}
|
||||||
break
|
|
||||||
case .ask:
|
let requiresAsk: Bool = {
|
||||||
if !wasAllowlisted {
|
if ask == .always { return true }
|
||||||
let services = await self.mainActorServices()
|
if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true }
|
||||||
let decision = await services.confirmSystemRun(
|
return false
|
||||||
command: SystemRunAllowlist.displayString(for: command),
|
}()
|
||||||
cwd: params.cwd)
|
|
||||||
switch decision {
|
var approvedByAsk = false
|
||||||
case .allowOnce:
|
if requiresAsk {
|
||||||
break
|
let decision: ExecApprovalDecision? = await ExecApprovalsPromptPresenter.prompt(
|
||||||
case .allowAlways:
|
ExecApprovalPromptRequest(
|
||||||
SystemRunAllowlist.add(command)
|
command: displayCommand,
|
||||||
case .deny:
|
cwd: params.cwd,
|
||||||
|
host: "node",
|
||||||
|
security: security.rawValue,
|
||||||
|
ask: ask.rawValue,
|
||||||
|
agentId: agentId,
|
||||||
|
resolvedPath: resolution?.resolvedPath))
|
||||||
|
|
||||||
|
switch decision {
|
||||||
|
case .deny?:
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: displayCommand,
|
||||||
|
reason: "user-denied"))
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .unavailable,
|
||||||
|
message: "SYSTEM_RUN_DENIED: user denied")
|
||||||
|
case nil:
|
||||||
|
if askFallback == .full {
|
||||||
|
approvedByAsk = true
|
||||||
|
} else if askFallback == .allowlist {
|
||||||
|
if allowlistMatch != nil || skillAllow {
|
||||||
|
approvedByAsk = true
|
||||||
|
} else {
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: displayCommand,
|
||||||
|
reason: "approval-required"))
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .unavailable,
|
||||||
|
message: "SYSTEM_RUN_DENIED: approval required")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: displayCommand,
|
||||||
|
reason: "approval-required"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
code: .unavailable,
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let env = Self.sanitizedEnv(params.env)
|
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: displayCommand,
|
||||||
|
reason: "allowlist-miss"))
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .unavailable,
|
||||||
|
message: "SYSTEM_RUN_DENIED: allowlist miss")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let match = allowlistMatch {
|
||||||
|
ExecApprovalsStore.recordAllowlistUse(
|
||||||
|
agentId: agentId,
|
||||||
|
pattern: match.pattern,
|
||||||
|
command: displayCommand,
|
||||||
|
resolvedPath: resolution?.resolvedPath)
|
||||||
|
}
|
||||||
|
|
||||||
if params.needsScreenRecording == true {
|
if params.needsScreenRecording == true {
|
||||||
let authorized = await PermissionManager
|
let authorized = await PermissionManager
|
||||||
.status([.screenRecording])[.screenRecording] ?? false
|
.status([.screenRecording])[.screenRecording] ?? false
|
||||||
if !authorized {
|
if !authorized {
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: displayCommand,
|
||||||
|
reason: "permission:screenRecording"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
code: .unavailable,
|
code: .unavailable,
|
||||||
@ -471,11 +606,33 @@ actor MacNodeRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
|
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(
|
let result = await ShellExecutor.runDetailed(
|
||||||
command: command,
|
command: command,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
env: env,
|
env: env,
|
||||||
timeout: timeoutSec)
|
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 {
|
struct RunPayload: Encodable {
|
||||||
var exitCode: Int?
|
var exitCode: Int?
|
||||||
@ -523,6 +680,82 @@ actor MacNodeRuntime {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
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 {
|
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
||||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@ -589,10 +822,6 @@ actor MacNodeRuntime {
|
|||||||
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
private nonisolated static func systemRunPolicy() -> SystemRunPolicy {
|
|
||||||
SystemRunPolicy.load()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let blockedEnvKeys: Set<String> = [
|
private static let blockedEnvKeys: Set<String> = [
|
||||||
"PATH",
|
"PATH",
|
||||||
"NODE_OPTIONS",
|
"NODE_OPTIONS",
|
||||||
|
|||||||
@ -1,14 +1,7 @@
|
|||||||
import AppKit
|
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum SystemRunDecision: Sendable {
|
|
||||||
case allowOnce
|
|
||||||
case allowAlways
|
|
||||||
case deny
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol MacNodeRuntimeMainActorServices: Sendable {
|
protocol MacNodeRuntimeMainActorServices: Sendable {
|
||||||
func recordScreen(
|
func recordScreen(
|
||||||
@ -24,8 +17,6 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
|
|||||||
desiredAccuracy: ClawdbotLocationAccuracy,
|
desiredAccuracy: ClawdbotLocationAccuracy,
|
||||||
maxAgeMs: Int?,
|
maxAgeMs: Int?,
|
||||||
timeoutMs: Int?) async throws -> CLLocation
|
timeoutMs: Int?) async throws -> CLLocation
|
||||||
|
|
||||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@ -67,30 +58,4 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
|
|||||||
timeoutMs: timeoutMs)
|
timeoutMs: timeoutMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
|
||||||
let alert = NSAlert()
|
|
||||||
alert.alertStyle = .warning
|
|
||||||
alert.messageText = "Allow this command?"
|
|
||||||
|
|
||||||
var details = "Clawdbot wants to run:\n\n\(command)"
|
|
||||||
let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if !trimmedCwd.isEmpty {
|
|
||||||
details += "\n\nWorking directory:\n\(trimmedCwd)"
|
|
||||||
}
|
|
||||||
details += "\n\nThis runs on this Mac via node mode."
|
|
||||||
alert.informativeText = details
|
|
||||||
|
|
||||||
alert.addButton(withTitle: "Allow Once")
|
|
||||||
alert.addButton(withTitle: "Always Allow")
|
|
||||||
alert.addButton(withTitle: "Don't Allow")
|
|
||||||
|
|
||||||
switch alert.runModal() {
|
|
||||||
case .alertFirstButtonReturn:
|
|
||||||
return .allowOnce
|
|
||||||
case .alertSecondButtonReturn:
|
|
||||||
return .allowAlways
|
|
||||||
default:
|
|
||||||
return .deny
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,8 +35,9 @@ struct NodeMenuEntryFormatter {
|
|||||||
if let platform = self.platformText(entry) {
|
if let platform = self.platformText(entry) {
|
||||||
parts.append("platform \(platform)")
|
parts.append("platform \(platform)")
|
||||||
}
|
}
|
||||||
if let version = entry.version?.nonEmpty {
|
let versionLabels = self.versionLabels(entry)
|
||||||
parts.append("app \(self.compactVersion(version))")
|
if !versionLabels.isEmpty {
|
||||||
|
parts.append(versionLabels.joined(separator: " · "))
|
||||||
}
|
}
|
||||||
parts.append("status \(self.roleText(entry))")
|
parts.append("status \(self.roleText(entry))")
|
||||||
return parts.joined(separator: " · ")
|
return parts.joined(separator: " · ")
|
||||||
@ -60,8 +61,9 @@ struct NodeMenuEntryFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func detailRightVersion(_ entry: NodeInfo) -> String? {
|
static func detailRightVersion(_ entry: NodeInfo) -> String? {
|
||||||
guard let version = entry.version?.nonEmpty else { return nil }
|
let labels = self.versionLabels(entry, compact: false)
|
||||||
return self.shortVersionLabel(version)
|
if labels.isEmpty { return nil }
|
||||||
|
return labels.joined(separator: " · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func platformText(_ entry: NodeInfo) -> String? {
|
static func platformText(_ entry: NodeInfo) -> String? {
|
||||||
@ -127,6 +129,39 @@ struct NodeMenuEntryFormatter {
|
|||||||
return compact
|
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 {
|
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
||||||
if self.isGateway(entry) {
|
if self.isGateway(entry) {
|
||||||
return self.safeSystemSymbol(
|
return self.safeSystemSymbol(
|
||||||
|
|||||||
@ -7,6 +7,8 @@ struct NodeInfo: Identifiable, Codable {
|
|||||||
let displayName: String?
|
let displayName: String?
|
||||||
let platform: String?
|
let platform: String?
|
||||||
let version: String?
|
let version: String?
|
||||||
|
let coreVersion: String?
|
||||||
|
let uiVersion: String?
|
||||||
let deviceFamily: String?
|
let deviceFamily: String?
|
||||||
let modelIdentifier: String?
|
let modelIdentifier: String?
|
||||||
let remoteIp: String?
|
let remoteIp: String?
|
||||||
|
|||||||
@ -3,13 +3,13 @@ import ClawdbotKit
|
|||||||
import OSLog
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
private struct SessionPreviewItem: Identifiable, Sendable {
|
struct SessionPreviewItem: Identifiable, Sendable {
|
||||||
let id: String
|
let id: String
|
||||||
let role: PreviewRole
|
let role: PreviewRole
|
||||||
let text: String
|
let text: String
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum PreviewRole: String, Sendable {
|
enum PreviewRole: String, Sendable {
|
||||||
case user
|
case user
|
||||||
case assistant
|
case assistant
|
||||||
case tool
|
case tool
|
||||||
@ -27,7 +27,7 @@ private enum PreviewRole: String, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private actor SessionPreviewCache {
|
actor SessionPreviewCache {
|
||||||
static let shared = SessionPreviewCache()
|
static let shared = SessionPreviewCache()
|
||||||
|
|
||||||
private struct CacheEntry {
|
private struct CacheEntry {
|
||||||
@ -52,25 +52,33 @@ private actor SessionPreviewCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SessionMenuPreviewView: View {
|
#if DEBUG
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
extension SessionPreviewCache {
|
||||||
private static let previewTimeoutSeconds: Double = 4
|
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
||||||
|
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
|
||||||
let sessionKey: String
|
|
||||||
let width: CGFloat
|
|
||||||
let maxItems: Int
|
|
||||||
let maxLines: Int
|
|
||||||
let title: String
|
|
||||||
|
|
||||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
|
||||||
@State private var items: [SessionPreviewItem] = []
|
|
||||||
@State private var status: LoadStatus = .loading
|
|
||||||
|
|
||||||
private struct PreviewTimeoutError: LocalizedError {
|
|
||||||
var errorDescription: String? { "preview timeout" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum LoadStatus: Equatable {
|
func _testReset() {
|
||||||
|
self.entries = [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
struct SessionMenuPreviewSnapshot: Sendable {
|
||||||
|
let items: [SessionPreviewItem]
|
||||||
|
let status: SessionMenuPreviewView.LoadStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SessionMenuPreviewView: View {
|
||||||
|
let width: CGFloat
|
||||||
|
let maxLines: Int
|
||||||
|
let title: String
|
||||||
|
let items: [SessionPreviewItem]
|
||||||
|
let status: LoadStatus
|
||||||
|
|
||||||
|
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||||
|
|
||||||
|
enum LoadStatus: Equatable {
|
||||||
case loading
|
case loading
|
||||||
case ready
|
case ready
|
||||||
case empty
|
case empty
|
||||||
@ -85,10 +93,6 @@ struct SessionMenuPreviewView: View {
|
|||||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
||||||
}
|
}
|
||||||
|
|
||||||
private var previewLimit: Int {
|
|
||||||
min(max(self.maxItems * 3, 20), 120)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
@ -123,9 +127,6 @@ struct SessionMenuPreviewView: View {
|
|||||||
.padding(.leading, 16)
|
.padding(.leading, 16)
|
||||||
.padding(.trailing, 11)
|
.padding(.trailing, 11)
|
||||||
.frame(width: max(1, self.width), alignment: .leading)
|
.frame(width: max(1, self.width), alignment: .leading)
|
||||||
.task(id: self.sessionKey) {
|
|
||||||
await self.loadPreview()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@ -157,55 +158,59 @@ struct SessionMenuPreviewView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadPreview() async {
|
}
|
||||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: self.sessionKey, maxAge: 12) {
|
|
||||||
await MainActor.run {
|
|
||||||
self.items = cached
|
|
||||||
self.status = cached.isEmpty ? .empty : .ready
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await MainActor.run {
|
enum SessionMenuPreviewLoader {
|
||||||
self.status = .loading
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||||
|
private static let previewTimeoutSeconds: Double = 4
|
||||||
|
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
||||||
|
|
||||||
|
private struct PreviewTimeoutError: LocalizedError {
|
||||||
|
var errorDescription: String? { "preview timeout" }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
||||||
|
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
|
||||||
|
return Self.snapshot(from: cached)
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
|
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||||
let payload = try await AsyncTimeout.withTimeout(
|
let payload = try await AsyncTimeout.withTimeout(
|
||||||
seconds: Self.previewTimeoutSeconds,
|
seconds: self.previewTimeoutSeconds,
|
||||||
onTimeout: { PreviewTimeoutError() },
|
onTimeout: { PreviewTimeoutError() },
|
||||||
operation: {
|
operation: {
|
||||||
try await GatewayConnection.shared.chatHistory(
|
try await GatewayConnection.shared.chatHistory(
|
||||||
sessionKey: self.sessionKey,
|
sessionKey: sessionKey,
|
||||||
limit: self.previewLimit,
|
limit: self.previewLimit(for: maxItems),
|
||||||
timeoutMs: timeoutMs)
|
timeoutMs: timeoutMs)
|
||||||
})
|
})
|
||||||
let built = Self.previewItems(from: payload, maxItems: self.maxItems)
|
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
||||||
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
|
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
||||||
await MainActor.run {
|
return Self.snapshot(from: built)
|
||||||
self.items = built
|
|
||||||
self.status = built.isEmpty ? .empty : .ready
|
|
||||||
}
|
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return
|
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
||||||
} catch {
|
} catch {
|
||||||
let fallback = await SessionPreviewCache.shared.lastItems(for: self.sessionKey)
|
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
|
||||||
await MainActor.run {
|
if let fallback {
|
||||||
if let fallback {
|
return Self.snapshot(from: fallback)
|
||||||
self.items = fallback
|
|
||||||
self.status = fallback.isEmpty ? .empty : .ready
|
|
||||||
} else {
|
|
||||||
self.status = .error("Preview unavailable")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let errorDescription = String(describing: error)
|
let errorDescription = String(describing: error)
|
||||||
Self.logger.warning(
|
Self.logger.warning(
|
||||||
"Session preview failed session=\(self.sessionKey, privacy: .public) " +
|
"Session preview failed session=\(sessionKey, privacy: .public) " +
|
||||||
"error=\(errorDescription, privacy: .public)")
|
"error=\(errorDescription, privacy: .public)")
|
||||||
|
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
||||||
|
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func previewLimit(for maxItems: Int) -> Int {
|
||||||
|
min(max(maxItems * 3, 20), 120)
|
||||||
|
}
|
||||||
|
|
||||||
private static func previewItems(
|
private static func previewItems(
|
||||||
from payload: ClawdbotChatHistoryPayload,
|
from payload: ClawdbotChatHistoryPayload,
|
||||||
maxItems: Int) -> [SessionPreviewItem]
|
maxItems: Int) -> [SessionPreviewItem]
|
||||||
|
|||||||
@ -1,89 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum SystemRunPolicy: String, CaseIterable, Identifiable {
|
|
||||||
case never
|
|
||||||
case ask
|
|
||||||
case always
|
|
||||||
|
|
||||||
var id: String { self.rawValue }
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .never:
|
|
||||||
"Never"
|
|
||||||
case .ask:
|
|
||||||
"Always Ask"
|
|
||||||
case .always:
|
|
||||||
"Always Allow"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static func load(from defaults: UserDefaults = .standard) -> SystemRunPolicy {
|
|
||||||
if let policy = MacNodeConfigFile.systemRunPolicy() {
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
if let raw = defaults.string(forKey: systemRunPolicyKey),
|
|
||||||
let policy = SystemRunPolicy(rawValue: raw)
|
|
||||||
{
|
|
||||||
MacNodeConfigFile.setSystemRunPolicy(policy)
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
if let legacy = defaults.object(forKey: systemRunEnabledKey) as? Bool {
|
|
||||||
let policy: SystemRunPolicy = legacy ? .ask : .never
|
|
||||||
MacNodeConfigFile.setSystemRunPolicy(policy)
|
|
||||||
return policy
|
|
||||||
}
|
|
||||||
let fallback: SystemRunPolicy = .ask
|
|
||||||
MacNodeConfigFile.setSystemRunPolicy(fallback)
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SystemRunAllowlist {
|
|
||||||
static func key(for argv: [String]) -> String {
|
|
||||||
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
guard !trimmed.isEmpty else { return "" }
|
|
||||||
if let data = try? JSONEncoder().encode(trimmed),
|
|
||||||
let json = String(data: data, encoding: .utf8)
|
|
||||||
{
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
return trimmed.joined(separator: " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func displayString(for argv: [String]) -> String {
|
|
||||||
argv.map { arg in
|
|
||||||
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { return "\"\"" }
|
|
||||||
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
|
|
||||||
if !needsQuotes { return trimmed }
|
|
||||||
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
|
|
||||||
return "\"\(escaped)\""
|
|
||||||
}.joined(separator: " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func load(from defaults: UserDefaults = .standard) -> Set<String> {
|
|
||||||
if let allowlist = MacNodeConfigFile.systemRunAllowlist() {
|
|
||||||
return Set(allowlist)
|
|
||||||
}
|
|
||||||
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
|
|
||||||
MacNodeConfigFile.setSystemRunAllowlist(legacy)
|
|
||||||
return Set(legacy)
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
static func contains(_ argv: [String], defaults: UserDefaults = .standard) -> Bool {
|
|
||||||
let key = key(for: argv)
|
|
||||||
return self.load(from: defaults).contains(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func add(_ argv: [String], defaults: UserDefaults = .standard) {
|
|
||||||
let key = key(for: argv)
|
|
||||||
guard !key.isEmpty else { return }
|
|
||||||
var allowlist = self.load(from: defaults)
|
|
||||||
if allowlist.insert(key).inserted {
|
|
||||||
MacNodeConfigFile.setSystemRunAllowlist(Array(allowlist).sorted())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
401
apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift
Normal file
401
apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SystemRunSettingsView: View {
|
||||||
|
@State private var model = ExecApprovalsSettingsModel()
|
||||||
|
@State private var tab: ExecApprovalsSettingsTab = .policy
|
||||||
|
@State private var newPattern: String = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(alignment: .center, spacing: 12) {
|
||||||
|
Text("Exec approvals")
|
||||||
|
.font(.body)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Picker("Agent", selection: Binding(
|
||||||
|
get: { self.model.selectedAgentId },
|
||||||
|
set: { self.model.selectAgent($0) }))
|
||||||
|
{
|
||||||
|
ForEach(self.model.agentPickerIds, id: \.self) { id in
|
||||||
|
Text(self.model.label(for: id)).tag(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.frame(width: 180, alignment: .trailing)
|
||||||
|
}
|
||||||
|
|
||||||
|
Picker("", selection: self.$tab) {
|
||||||
|
ForEach(ExecApprovalsSettingsTab.allCases) { tab in
|
||||||
|
Text(tab.title).tag(tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(width: 320)
|
||||||
|
|
||||||
|
if self.tab == .policy {
|
||||||
|
self.policyView
|
||||||
|
} else {
|
||||||
|
self.allowlistView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await self.model.refresh() }
|
||||||
|
.onChange(of: self.tab) { _, _ in
|
||||||
|
Task { await self.model.refreshSkillBins() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var policyView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Picker("", selection: Binding(
|
||||||
|
get: { self.model.security },
|
||||||
|
set: { self.model.setSecurity($0) }))
|
||||||
|
{
|
||||||
|
ForEach(ExecSecurity.allCases) { security in
|
||||||
|
Text(security.title).tag(security)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
Picker("", selection: Binding(
|
||||||
|
get: { self.model.ask },
|
||||||
|
set: { self.model.setAsk($0) }))
|
||||||
|
{
|
||||||
|
ForEach(ExecAsk.allCases) { ask in
|
||||||
|
Text(ask.title).tag(ask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
Picker("", selection: Binding(
|
||||||
|
get: { self.model.askFallback },
|
||||||
|
set: { self.model.setAskFallback($0) }))
|
||||||
|
{
|
||||||
|
ForEach(ExecSecurity.allCases) { mode in
|
||||||
|
Text("Fallback: \(mode.title)").tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
Text(self.model.isDefaultsScope
|
||||||
|
? "Defaults apply when an agent has no overrides. Ask controls prompt behavior; fallback is used when no companion UI is reachable."
|
||||||
|
: "Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var allowlistView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Toggle("Auto-allow skill CLIs", isOn: Binding(
|
||||||
|
get: { self.model.autoAllowSkills },
|
||||||
|
set: { self.model.setAutoAllowSkills($0) }))
|
||||||
|
|
||||||
|
if self.model.autoAllowSkills, !self.model.skillBins.isEmpty {
|
||||||
|
Text("Skill CLIs: \(self.model.skillBins.joined(separator: ", "))")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.model.isDefaultsScope {
|
||||||
|
Text("Allowlists are per-agent. Select an agent to edit its allowlist.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
Button("Add") {
|
||||||
|
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !pattern.isEmpty else { return }
|
||||||
|
self.model.addEntry(pattern)
|
||||||
|
self.newPattern = ""
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.disabled(self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.model.entries.isEmpty {
|
||||||
|
Text("No allowlisted commands yet.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
||||||
|
ExecAllowlistRow(
|
||||||
|
entry: Binding(
|
||||||
|
get: { self.model.entries[index] },
|
||||||
|
set: { self.model.updateEntry($0, at: index) }),
|
||||||
|
onRemove: { self.model.removeEntry(at: index) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ExecApprovalsSettingsTab: String, CaseIterable, Identifiable {
|
||||||
|
case policy
|
||||||
|
case allowlist
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .policy: "Access"
|
||||||
|
case .allowlist: "Allowlist"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExecAllowlistRow: View {
|
||||||
|
@Binding var entry: ExecAllowlistEntry
|
||||||
|
let onRemove: () -> Void
|
||||||
|
@State private var draftPattern: String = ""
|
||||||
|
|
||||||
|
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||||
|
let formatter = RelativeDateTimeFormatter()
|
||||||
|
formatter.unitsStyle = .short
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
TextField("Pattern", text: self.patternBinding)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
self.onRemove()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastUsedAt = self.entry.lastUsedAt {
|
||||||
|
let date = Date(timeIntervalSince1970: lastUsedAt / 1000.0)
|
||||||
|
Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
|
||||||
|
Text("Last command: \(lastUsedCommand)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastResolvedPath = self.entry.lastResolvedPath, !lastResolvedPath.isEmpty {
|
||||||
|
Text("Resolved path: \(lastResolvedPath)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
self.draftPattern = self.entry.pattern
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var patternBinding: Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: { self.draftPattern.isEmpty ? self.entry.pattern : self.draftPattern },
|
||||||
|
set: { newValue in
|
||||||
|
self.draftPattern = newValue
|
||||||
|
self.entry.pattern = newValue
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class ExecApprovalsSettingsModel {
|
||||||
|
private static let defaultsScopeId = "__defaults__"
|
||||||
|
var agentIds: [String] = []
|
||||||
|
var selectedAgentId: String = "main"
|
||||||
|
var defaultAgentId: String = "main"
|
||||||
|
var security: ExecSecurity = .deny
|
||||||
|
var ask: ExecAsk = .onMiss
|
||||||
|
var askFallback: ExecSecurity = .deny
|
||||||
|
var autoAllowSkills = false
|
||||||
|
var entries: [ExecAllowlistEntry] = []
|
||||||
|
var skillBins: [String] = []
|
||||||
|
|
||||||
|
var agentPickerIds: [String] {
|
||||||
|
[Self.defaultsScopeId] + self.agentIds
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDefaultsScope: Bool {
|
||||||
|
self.selectedAgentId == Self.defaultsScopeId
|
||||||
|
}
|
||||||
|
|
||||||
|
func label(for id: String) -> String {
|
||||||
|
if id == Self.defaultsScopeId { return "Defaults" }
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() async {
|
||||||
|
await self.refreshAgents()
|
||||||
|
self.loadSettings(for: self.selectedAgentId)
|
||||||
|
await self.refreshSkillBins()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshAgents() async {
|
||||||
|
let root = await ConfigStore.load()
|
||||||
|
let agents = root["agents"] as? [String: Any]
|
||||||
|
let list = agents?["list"] as? [[String: Any]] ?? []
|
||||||
|
var ids: [String] = []
|
||||||
|
var seen = Set<String>()
|
||||||
|
var defaultId: String?
|
||||||
|
for entry in list {
|
||||||
|
guard let raw = entry["id"] as? String else { continue }
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { continue }
|
||||||
|
if !seen.insert(trimmed).inserted { continue }
|
||||||
|
ids.append(trimmed)
|
||||||
|
if (entry["default"] as? Bool) == true, defaultId == nil {
|
||||||
|
defaultId = trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ids.isEmpty {
|
||||||
|
ids = ["main"]
|
||||||
|
defaultId = "main"
|
||||||
|
} else if defaultId == nil {
|
||||||
|
defaultId = ids.first
|
||||||
|
}
|
||||||
|
self.agentIds = ids
|
||||||
|
self.defaultAgentId = defaultId ?? "main"
|
||||||
|
if self.selectedAgentId == Self.defaultsScopeId {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !self.agentIds.contains(self.selectedAgentId) {
|
||||||
|
self.selectedAgentId = self.defaultAgentId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectAgent(_ id: String) {
|
||||||
|
self.selectedAgentId = id
|
||||||
|
self.loadSettings(for: id)
|
||||||
|
Task { await self.refreshSkillBins() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSettings(for agentId: String) {
|
||||||
|
if agentId == Self.defaultsScopeId {
|
||||||
|
let defaults = ExecApprovalsStore.resolveDefaults()
|
||||||
|
self.security = defaults.security
|
||||||
|
self.ask = defaults.ask
|
||||||
|
self.askFallback = defaults.askFallback
|
||||||
|
self.autoAllowSkills = defaults.autoAllowSkills
|
||||||
|
self.entries = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
|
||||||
|
self.security = resolved.agent.security
|
||||||
|
self.ask = resolved.agent.ask
|
||||||
|
self.askFallback = resolved.agent.askFallback
|
||||||
|
self.autoAllowSkills = resolved.agent.autoAllowSkills
|
||||||
|
self.entries = resolved.allowlist
|
||||||
|
.sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending }
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSecurity(_ security: ExecSecurity) {
|
||||||
|
self.security = security
|
||||||
|
if self.isDefaultsScope {
|
||||||
|
ExecApprovalsStore.updateDefaults { defaults in
|
||||||
|
defaults.security = security
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
|
entry.security = security
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.syncQuickMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAsk(_ ask: ExecAsk) {
|
||||||
|
self.ask = ask
|
||||||
|
if self.isDefaultsScope {
|
||||||
|
ExecApprovalsStore.updateDefaults { defaults in
|
||||||
|
defaults.ask = ask
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
|
entry.ask = ask
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.syncQuickMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAskFallback(_ mode: ExecSecurity) {
|
||||||
|
self.askFallback = mode
|
||||||
|
if self.isDefaultsScope {
|
||||||
|
ExecApprovalsStore.updateDefaults { defaults in
|
||||||
|
defaults.askFallback = mode
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
|
entry.askFallback = mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAutoAllowSkills(_ enabled: Bool) {
|
||||||
|
self.autoAllowSkills = enabled
|
||||||
|
if self.isDefaultsScope {
|
||||||
|
ExecApprovalsStore.updateDefaults { defaults in
|
||||||
|
defaults.autoAllowSkills = enabled
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
|
entry.autoAllowSkills = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Task { await self.refreshSkillBins(force: enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func addEntry(_ pattern: String) {
|
||||||
|
guard !self.isDefaultsScope else { return }
|
||||||
|
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return }
|
||||||
|
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
|
||||||
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
|
||||||
|
guard !self.isDefaultsScope else { return }
|
||||||
|
guard self.entries.indices.contains(index) else { return }
|
||||||
|
self.entries[index] = entry
|
||||||
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeEntry(at index: Int) {
|
||||||
|
guard !self.isDefaultsScope else { return }
|
||||||
|
guard self.entries.indices.contains(index) else { return }
|
||||||
|
self.entries.remove(at: index)
|
||||||
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshSkillBins(force: Bool = false) async {
|
||||||
|
guard self.autoAllowSkills else {
|
||||||
|
self.skillBins = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let bins = await SkillBinsCache.shared.currentBins(force: force)
|
||||||
|
self.skillBins = bins.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncQuickMode() {
|
||||||
|
if self.isDefaultsScope {
|
||||||
|
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
|
||||||
|
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -530,6 +530,8 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
public let displayname: String?
|
public let displayname: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
|
public let coreversion: String?
|
||||||
|
public let uiversion: String?
|
||||||
public let devicefamily: String?
|
public let devicefamily: String?
|
||||||
public let modelidentifier: String?
|
public let modelidentifier: String?
|
||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
@ -542,6 +544,8 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
displayname: String?,
|
displayname: String?,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
version: String?,
|
version: String?,
|
||||||
|
coreversion: String?,
|
||||||
|
uiversion: String?,
|
||||||
devicefamily: String?,
|
devicefamily: String?,
|
||||||
modelidentifier: String?,
|
modelidentifier: String?,
|
||||||
caps: [String]?,
|
caps: [String]?,
|
||||||
@ -553,6 +557,8 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
self.displayname = displayname
|
self.displayname = displayname
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.version = version
|
self.version = version
|
||||||
|
self.coreversion = coreversion
|
||||||
|
self.uiversion = uiversion
|
||||||
self.devicefamily = devicefamily
|
self.devicefamily = devicefamily
|
||||||
self.modelidentifier = modelidentifier
|
self.modelidentifier = modelidentifier
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
@ -565,6 +571,8 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
case displayname = "displayName"
|
case displayname = "displayName"
|
||||||
case platform
|
case platform
|
||||||
case version
|
case version
|
||||||
|
case coreversion = "coreVersion"
|
||||||
|
case uiversion = "uiVersion"
|
||||||
case devicefamily = "deviceFamily"
|
case devicefamily = "deviceFamily"
|
||||||
case modelidentifier = "modelIdentifier"
|
case modelidentifier = "modelIdentifier"
|
||||||
case caps
|
case caps
|
||||||
@ -760,6 +768,10 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
public let reasoninglevel: AnyCodable?
|
public let reasoninglevel: AnyCodable?
|
||||||
public let responseusage: AnyCodable?
|
public let responseusage: AnyCodable?
|
||||||
public let elevatedlevel: 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 model: AnyCodable?
|
||||||
public let spawnedby: AnyCodable?
|
public let spawnedby: AnyCodable?
|
||||||
public let sendpolicy: AnyCodable?
|
public let sendpolicy: AnyCodable?
|
||||||
@ -773,6 +785,10 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
reasoninglevel: AnyCodable?,
|
reasoninglevel: AnyCodable?,
|
||||||
responseusage: AnyCodable?,
|
responseusage: AnyCodable?,
|
||||||
elevatedlevel: AnyCodable?,
|
elevatedlevel: AnyCodable?,
|
||||||
|
exechost: AnyCodable?,
|
||||||
|
execsecurity: AnyCodable?,
|
||||||
|
execask: AnyCodable?,
|
||||||
|
execnode: AnyCodable?,
|
||||||
model: AnyCodable?,
|
model: AnyCodable?,
|
||||||
spawnedby: AnyCodable?,
|
spawnedby: AnyCodable?,
|
||||||
sendpolicy: AnyCodable?,
|
sendpolicy: AnyCodable?,
|
||||||
@ -785,6 +801,10 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
self.reasoninglevel = reasoninglevel
|
self.reasoninglevel = reasoninglevel
|
||||||
self.responseusage = responseusage
|
self.responseusage = responseusage
|
||||||
self.elevatedlevel = elevatedlevel
|
self.elevatedlevel = elevatedlevel
|
||||||
|
self.exechost = exechost
|
||||||
|
self.execsecurity = execsecurity
|
||||||
|
self.execask = execask
|
||||||
|
self.execnode = execnode
|
||||||
self.model = model
|
self.model = model
|
||||||
self.spawnedby = spawnedby
|
self.spawnedby = spawnedby
|
||||||
self.sendpolicy = sendpolicy
|
self.sendpolicy = sendpolicy
|
||||||
@ -798,6 +818,10 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||||||
case reasoninglevel = "reasoningLevel"
|
case reasoninglevel = "reasoningLevel"
|
||||||
case responseusage = "responseUsage"
|
case responseusage = "responseUsage"
|
||||||
case elevatedlevel = "elevatedLevel"
|
case elevatedlevel = "elevatedLevel"
|
||||||
|
case exechost = "execHost"
|
||||||
|
case execsecurity = "execSecurity"
|
||||||
|
case execask = "execAsk"
|
||||||
|
case execnode = "execNode"
|
||||||
case model
|
case model
|
||||||
case spawnedby = "spawnedBy"
|
case spawnedby = "spawnedBy"
|
||||||
case sendpolicy = "sendPolicy"
|
case sendpolicy = "sendPolicy"
|
||||||
@ -1616,6 +1640,85 @@ public struct LogsTailResult: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ExecApprovalsGetParams: Codable, Sendable {
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ExecApprovalsSetParams: Codable, Sendable {
|
||||||
|
public let file: [String: AnyCodable]
|
||||||
|
public let basehash: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
file: [String: AnyCodable],
|
||||||
|
basehash: String?
|
||||||
|
) {
|
||||||
|
self.file = file
|
||||||
|
self.basehash = basehash
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case file
|
||||||
|
case basehash = "baseHash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ExecApprovalsNodeGetParams: Codable, Sendable {
|
||||||
|
public let nodeid: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
nodeid: String
|
||||||
|
) {
|
||||||
|
self.nodeid = nodeid
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case nodeid = "nodeId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ExecApprovalsNodeSetParams: Codable, Sendable {
|
||||||
|
public let nodeid: String
|
||||||
|
public let file: [String: AnyCodable]
|
||||||
|
public let basehash: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
nodeid: String,
|
||||||
|
file: [String: AnyCodable],
|
||||||
|
basehash: String?
|
||||||
|
) {
|
||||||
|
self.nodeid = nodeid
|
||||||
|
self.file = file
|
||||||
|
self.basehash = basehash
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case nodeid = "nodeId"
|
||||||
|
case file
|
||||||
|
case basehash = "baseHash"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||||
|
public let path: String
|
||||||
|
public let exists: Bool
|
||||||
|
public let hash: String
|
||||||
|
public let file: [String: AnyCodable]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
path: String,
|
||||||
|
exists: Bool,
|
||||||
|
hash: String,
|
||||||
|
file: [String: AnyCodable]
|
||||||
|
) {
|
||||||
|
self.path = path
|
||||||
|
self.exists = exists
|
||||||
|
self.hash = hash
|
||||||
|
self.file = file
|
||||||
|
}
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case path
|
||||||
|
case exists
|
||||||
|
case hash
|
||||||
|
case file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct ChatHistoryParams: Codable, Sendable {
|
public struct ChatHistoryParams: Codable, Sendable {
|
||||||
public let sessionkey: String
|
public let sessionkey: String
|
||||||
public let limit: Int?
|
public let limit: Int?
|
||||||
|
|||||||
@ -34,7 +34,7 @@ import Testing
|
|||||||
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
|
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
|
||||||
try self.makeExec(at: clawdbotPath)
|
try self.makeExec(at: clawdbotPath)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults)
|
let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults, configRoot: [:])
|
||||||
#expect(cmd.prefix(2).elementsEqual([clawdbotPath.path, "gateway"]))
|
#expect(cmd.prefix(2).elementsEqual([clawdbotPath.path, "gateway"]))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,6 +55,7 @@ import Testing
|
|||||||
let cmd = CommandResolver.clawdbotCommand(
|
let cmd = CommandResolver.clawdbotCommand(
|
||||||
subcommand: "rpc",
|
subcommand: "rpc",
|
||||||
defaults: defaults,
|
defaults: defaults,
|
||||||
|
configRoot: [:],
|
||||||
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path])
|
searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path])
|
||||||
|
|
||||||
#expect(cmd.count >= 3)
|
#expect(cmd.count >= 3)
|
||||||
@ -75,7 +76,7 @@ import Testing
|
|||||||
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm")
|
||||||
try self.makeExec(at: pnpmPath)
|
try self.makeExec(at: pnpmPath)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults)
|
let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults, configRoot: [:])
|
||||||
|
|
||||||
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "rpc"]))
|
#expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "rpc"]))
|
||||||
}
|
}
|
||||||
@ -93,7 +94,8 @@ import Testing
|
|||||||
let cmd = CommandResolver.clawdbotCommand(
|
let cmd = CommandResolver.clawdbotCommand(
|
||||||
subcommand: "health",
|
subcommand: "health",
|
||||||
extraArgs: ["--json", "--timeout", "5"],
|
extraArgs: ["--json", "--timeout", "5"],
|
||||||
defaults: defaults)
|
defaults: defaults,
|
||||||
|
configRoot: [:])
|
||||||
|
|
||||||
#expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "health", "--json"]))
|
#expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "health", "--json"]))
|
||||||
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
|
#expect(cmd.suffix(2).elementsEqual(["--timeout", "5"]))
|
||||||
@ -114,7 +116,11 @@ import Testing
|
|||||||
defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey)
|
defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey)
|
||||||
defaults.set("/srv/clawdbot", forKey: remoteProjectRootKey)
|
defaults.set("/srv/clawdbot", forKey: remoteProjectRootKey)
|
||||||
|
|
||||||
let cmd = CommandResolver.clawdbotCommand(subcommand: "status", extraArgs: ["--json"], defaults: defaults)
|
let cmd = CommandResolver.clawdbotCommand(
|
||||||
|
subcommand: "status",
|
||||||
|
extraArgs: ["--json"],
|
||||||
|
defaults: defaults,
|
||||||
|
configRoot: [:])
|
||||||
|
|
||||||
#expect(cmd.first == "/usr/bin/ssh")
|
#expect(cmd.first == "/usr/bin/ssh")
|
||||||
#expect(cmd.contains("clawd@example.com"))
|
#expect(cmd.contains("clawd@example.com"))
|
||||||
@ -128,4 +134,27 @@ import Testing
|
|||||||
#expect(script.contains("CLI="))
|
#expect(script.contains("CLI="))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func configRootLocalOverridesRemoteDefaults() async throws {
|
||||||
|
let defaults = self.makeDefaults()
|
||||||
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||||
|
defaults.set("clawd@example.com:2222", forKey: remoteTargetKey)
|
||||||
|
|
||||||
|
let tmp = try makeTempDir()
|
||||||
|
CommandResolver.setProjectRoot(tmp.path)
|
||||||
|
|
||||||
|
let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot")
|
||||||
|
try self.makeExec(at: clawdbotPath)
|
||||||
|
|
||||||
|
let cmd = CommandResolver.clawdbotCommand(
|
||||||
|
subcommand: "daemon",
|
||||||
|
defaults: defaults,
|
||||||
|
configRoot: ["gateway": ["mode": "local"]])
|
||||||
|
|
||||||
|
#expect(cmd.first == clawdbotPath.path)
|
||||||
|
#expect(cmd.count >= 2)
|
||||||
|
if cmd.count >= 2 {
|
||||||
|
#expect(cmd[1] == "daemon")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
apps/macos/Tests/ClawdbotIPCTests/ExecAllowlistTests.swift
Normal file
49
apps/macos/Tests/ClawdbotIPCTests/ExecAllowlistTests.swift
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
struct ExecAllowlistTests {
|
||||||
|
@Test func matchUsesResolvedPath() {
|
||||||
|
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
|
||||||
|
let resolution = ExecCommandResolution(
|
||||||
|
rawExecutable: "rg",
|
||||||
|
resolvedPath: "/opt/homebrew/bin/rg",
|
||||||
|
executableName: "rg",
|
||||||
|
cwd: nil)
|
||||||
|
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||||
|
#expect(match?.pattern == entry.pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func matchUsesBasenameForSimplePattern() {
|
||||||
|
let entry = ExecAllowlistEntry(pattern: "rg")
|
||||||
|
let resolution = ExecCommandResolution(
|
||||||
|
rawExecutable: "rg",
|
||||||
|
resolvedPath: "/opt/homebrew/bin/rg",
|
||||||
|
executableName: "rg",
|
||||||
|
cwd: nil)
|
||||||
|
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||||
|
#expect(match?.pattern == entry.pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func matchIsCaseInsensitive() {
|
||||||
|
let entry = ExecAllowlistEntry(pattern: "RG")
|
||||||
|
let resolution = ExecCommandResolution(
|
||||||
|
rawExecutable: "rg",
|
||||||
|
resolvedPath: "/opt/homebrew/bin/rg",
|
||||||
|
executableName: "rg",
|
||||||
|
cwd: nil)
|
||||||
|
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||||
|
#expect(match?.pattern == entry.pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func matchSupportsGlobStar() {
|
||||||
|
let entry = ExecAllowlistEntry(pattern: "/opt/**/rg")
|
||||||
|
let resolution = ExecCommandResolution(
|
||||||
|
rawExecutable: "rg",
|
||||||
|
resolvedPath: "/opt/homebrew/bin/rg",
|
||||||
|
executableName: "rg",
|
||||||
|
cwd: nil)
|
||||||
|
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
|
||||||
|
#expect(match?.pattern == entry.pattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -74,10 +74,6 @@ struct MacNodeRuntimeTests {
|
|||||||
{
|
{
|
||||||
CLLocation(latitude: 0, longitude: 0)
|
CLLocation(latitude: 0, longitude: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
|
||||||
.allowOnce
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let services = await MainActor.run { FakeMainActorServices() }
|
let services = await MainActor.run { FakeMainActorServices() }
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Clawdbot
|
||||||
|
|
||||||
|
@Suite(.serialized)
|
||||||
|
struct SessionMenuPreviewTests {
|
||||||
|
@Test func loaderReturnsCachedItems() async {
|
||||||
|
await SessionPreviewCache.shared._testReset()
|
||||||
|
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
||||||
|
await SessionPreviewCache.shared._testSet(items: items, for: "main")
|
||||||
|
|
||||||
|
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||||
|
#expect(snapshot.status == .ready)
|
||||||
|
#expect(snapshot.items.count == 1)
|
||||||
|
#expect(snapshot.items.first?.text == "Hi")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
||||||
|
await SessionPreviewCache.shared._testReset()
|
||||||
|
await SessionPreviewCache.shared._testSet(items: [], for: "main")
|
||||||
|
|
||||||
|
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||||
|
#expect(snapshot.status == .empty)
|
||||||
|
#expect(snapshot.items.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -37,7 +37,7 @@ import Testing
|
|||||||
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
|
||||||
defaults.set("ssh alice@example.com", forKey: remoteTargetKey)
|
defaults.set("ssh alice@example.com", forKey: remoteTargetKey)
|
||||||
|
|
||||||
let settings = CommandResolver.connectionSettings(defaults: defaults)
|
let settings = CommandResolver.connectionSettings(defaults: defaults, configRoot: [:])
|
||||||
#expect(settings.mode == .remote)
|
#expect(settings.mode == .remote)
|
||||||
#expect(settings.target == "alice@example.com")
|
#expect(settings.target == "alice@example.com")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,6 +63,8 @@ public struct BridgeHello: Codable, Sendable {
|
|||||||
public let token: String?
|
public let token: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
|
public let coreVersion: String?
|
||||||
|
public let uiVersion: String?
|
||||||
public let deviceFamily: String?
|
public let deviceFamily: String?
|
||||||
public let modelIdentifier: String?
|
public let modelIdentifier: String?
|
||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
@ -76,6 +78,8 @@ public struct BridgeHello: Codable, Sendable {
|
|||||||
token: String?,
|
token: String?,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
version: String?,
|
version: String?,
|
||||||
|
coreVersion: String? = nil,
|
||||||
|
uiVersion: String? = nil,
|
||||||
deviceFamily: String? = nil,
|
deviceFamily: String? = nil,
|
||||||
modelIdentifier: String? = nil,
|
modelIdentifier: String? = nil,
|
||||||
caps: [String]? = nil,
|
caps: [String]? = nil,
|
||||||
@ -88,6 +92,8 @@ public struct BridgeHello: Codable, Sendable {
|
|||||||
self.token = token
|
self.token = token
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.version = version
|
self.version = version
|
||||||
|
self.coreVersion = coreVersion
|
||||||
|
self.uiVersion = uiVersion
|
||||||
self.deviceFamily = deviceFamily
|
self.deviceFamily = deviceFamily
|
||||||
self.modelIdentifier = modelIdentifier
|
self.modelIdentifier = modelIdentifier
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
@ -121,6 +127,8 @@ public struct BridgePairRequest: Codable, Sendable {
|
|||||||
public let displayName: String?
|
public let displayName: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
|
public let coreVersion: String?
|
||||||
|
public let uiVersion: String?
|
||||||
public let deviceFamily: String?
|
public let deviceFamily: String?
|
||||||
public let modelIdentifier: String?
|
public let modelIdentifier: String?
|
||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
@ -135,6 +143,8 @@ public struct BridgePairRequest: Codable, Sendable {
|
|||||||
displayName: String?,
|
displayName: String?,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
version: String?,
|
version: String?,
|
||||||
|
coreVersion: String? = nil,
|
||||||
|
uiVersion: String? = nil,
|
||||||
deviceFamily: String? = nil,
|
deviceFamily: String? = nil,
|
||||||
modelIdentifier: String? = nil,
|
modelIdentifier: String? = nil,
|
||||||
caps: [String]? = nil,
|
caps: [String]? = nil,
|
||||||
@ -148,6 +158,8 @@ public struct BridgePairRequest: Codable, Sendable {
|
|||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.version = version
|
self.version = version
|
||||||
|
self.coreVersion = coreVersion
|
||||||
|
self.uiVersion = uiVersion
|
||||||
self.deviceFamily = deviceFamily
|
self.deviceFamily = deviceFamily
|
||||||
self.modelIdentifier = modelIdentifier
|
self.modelIdentifier = modelIdentifier
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
|
|||||||
@ -4,6 +4,8 @@ public enum ClawdbotSystemCommand: String, Codable, Sendable {
|
|||||||
case run = "system.run"
|
case run = "system.run"
|
||||||
case which = "system.which"
|
case which = "system.which"
|
||||||
case notify = "system.notify"
|
case notify = "system.notify"
|
||||||
|
case execApprovalsGet = "system.execApprovals.get"
|
||||||
|
case execApprovalsSet = "system.execApprovals.set"
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ClawdbotNotificationPriority: String, Codable, Sendable {
|
public enum ClawdbotNotificationPriority: String, Codable, Sendable {
|
||||||
@ -20,23 +22,32 @@ public enum ClawdbotNotificationDelivery: String, Codable, Sendable {
|
|||||||
|
|
||||||
public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||||
public var command: [String]
|
public var command: [String]
|
||||||
|
public var rawCommand: String?
|
||||||
public var cwd: String?
|
public var cwd: String?
|
||||||
public var env: [String: String]?
|
public var env: [String: String]?
|
||||||
public var timeoutMs: Int?
|
public var timeoutMs: Int?
|
||||||
public var needsScreenRecording: Bool?
|
public var needsScreenRecording: Bool?
|
||||||
|
public var agentId: String?
|
||||||
|
public var sessionKey: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
command: [String],
|
command: [String],
|
||||||
|
rawCommand: String? = nil,
|
||||||
cwd: String? = nil,
|
cwd: String? = nil,
|
||||||
env: [String: String]? = nil,
|
env: [String: String]? = nil,
|
||||||
timeoutMs: Int? = nil,
|
timeoutMs: Int? = nil,
|
||||||
needsScreenRecording: Bool? = nil)
|
needsScreenRecording: Bool? = nil,
|
||||||
|
agentId: String? = nil,
|
||||||
|
sessionKey: String? = nil)
|
||||||
{
|
{
|
||||||
self.command = command
|
self.command = command
|
||||||
|
self.rawCommand = rawCommand
|
||||||
self.cwd = cwd
|
self.cwd = cwd
|
||||||
self.env = env
|
self.env = env
|
||||||
self.timeoutMs = timeoutMs
|
self.timeoutMs = timeoutMs
|
||||||
self.needsScreenRecording = needsScreenRecording
|
self.needsScreenRecording = needsScreenRecording
|
||||||
|
self.agentId = agentId
|
||||||
|
self.sessionKey = sessionKey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
194
docs.acp.md
Normal file
194
docs.acp.md
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
# Clawdbot ACP Bridge
|
||||||
|
|
||||||
|
This document describes how the Clawdbot ACP (Agent Client Protocol) bridge works,
|
||||||
|
how it maps ACP sessions to Gateway sessions, and how IDEs should invoke it.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`clawdbot acp` exposes an ACP agent over stdio and forwards prompts to a running
|
||||||
|
Clawdbot Gateway over WebSocket. It keeps ACP session ids mapped to Gateway
|
||||||
|
session keys so IDEs can reconnect to the same agent transcript or reset it on
|
||||||
|
request.
|
||||||
|
|
||||||
|
Key goals:
|
||||||
|
|
||||||
|
- Minimal ACP surface area (stdio, NDJSON).
|
||||||
|
- Stable session mapping across reconnects.
|
||||||
|
- Works with existing Gateway session store (list/resolve/reset).
|
||||||
|
- Safe defaults (isolated ACP session keys by default).
|
||||||
|
|
||||||
|
## How can I use this
|
||||||
|
|
||||||
|
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
|
||||||
|
drive a Clawdbot Gateway session.
|
||||||
|
|
||||||
|
Quick steps:
|
||||||
|
|
||||||
|
1. Run a Gateway (local or remote).
|
||||||
|
2. Configure the Gateway target (`gateway.remote.url` + auth) or pass flags.
|
||||||
|
3. Point the IDE to run `clawdbot acp` over stdio.
|
||||||
|
|
||||||
|
Example config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config set gateway.remote.url wss://gateway-host:18789
|
||||||
|
clawdbot config set gateway.remote.token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Selecting agents
|
||||||
|
|
||||||
|
ACP does not pick agents directly. It routes by the Gateway session key.
|
||||||
|
|
||||||
|
Use agent-scoped session keys to target a specific agent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp --session agent:main:main
|
||||||
|
clawdbot acp --session agent:design:main
|
||||||
|
clawdbot acp --session agent:qa:bug-123
|
||||||
|
```
|
||||||
|
|
||||||
|
Each ACP session maps to a single Gateway session key. One agent can have many
|
||||||
|
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
|
||||||
|
the key or label.
|
||||||
|
|
||||||
|
## Zed editor setup
|
||||||
|
|
||||||
|
Add a custom ACP agent in `~/.config/zed/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_servers": {
|
||||||
|
"Clawdbot ACP": {
|
||||||
|
"type": "custom",
|
||||||
|
"command": "clawdbot",
|
||||||
|
"args": ["acp"],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To target a specific Gateway or agent:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_servers": {
|
||||||
|
"Clawdbot ACP": {
|
||||||
|
"type": "custom",
|
||||||
|
"command": "clawdbot",
|
||||||
|
"args": [
|
||||||
|
"acp",
|
||||||
|
"--url", "wss://gateway-host:18789",
|
||||||
|
"--token", "<token>",
|
||||||
|
"--session", "agent:design:main"
|
||||||
|
],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
|
||||||
|
|
||||||
|
## Execution Model
|
||||||
|
|
||||||
|
- ACP client spawns `clawdbot acp` and speaks ACP messages over stdio.
|
||||||
|
- The bridge connects to the Gateway using existing auth config (or CLI flags).
|
||||||
|
- ACP `prompt` translates to Gateway `chat.send`.
|
||||||
|
- Gateway streaming events are translated back into ACP streaming events.
|
||||||
|
- ACP `cancel` maps to Gateway `chat.abort` for the active run.
|
||||||
|
|
||||||
|
## Session Mapping
|
||||||
|
|
||||||
|
By default each ACP session is mapped to a dedicated Gateway session key:
|
||||||
|
|
||||||
|
- `acp:<uuid>` unless overridden.
|
||||||
|
|
||||||
|
You can override or reuse sessions in two ways:
|
||||||
|
|
||||||
|
1) CLI defaults
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp --session agent:main:main
|
||||||
|
clawdbot acp --session-label "support inbox"
|
||||||
|
clawdbot acp --reset-session
|
||||||
|
```
|
||||||
|
|
||||||
|
2) ACP metadata per session
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"sessionKey": "agent:main:main",
|
||||||
|
"sessionLabel": "support inbox",
|
||||||
|
"resetSession": true,
|
||||||
|
"requireExisting": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- `sessionKey`: direct Gateway session key.
|
||||||
|
- `sessionLabel`: resolve an existing session by label.
|
||||||
|
- `resetSession`: mint a new transcript for the key before first use.
|
||||||
|
- `requireExisting`: fail if the key/label does not exist.
|
||||||
|
|
||||||
|
### Session Listing
|
||||||
|
|
||||||
|
ACP `listSessions` maps to Gateway `sessions.list` and returns a filtered
|
||||||
|
summary suitable for IDE session pickers. `_meta.limit` can cap the number of
|
||||||
|
sessions returned.
|
||||||
|
|
||||||
|
## Prompt Translation
|
||||||
|
|
||||||
|
ACP prompt inputs are converted into a Gateway `chat.send`:
|
||||||
|
|
||||||
|
- `text` and `resource` blocks become prompt text.
|
||||||
|
- `resource_link` with image mime types become attachments.
|
||||||
|
- The working directory can be prefixed into the prompt (default on, can be
|
||||||
|
disabled with `--no-prefix-cwd`).
|
||||||
|
|
||||||
|
Gateway streaming events are translated into ACP `message` and `tool_call`
|
||||||
|
updates. Terminal Gateway states map to ACP `done` with stop reasons:
|
||||||
|
|
||||||
|
- `complete` -> `stop`
|
||||||
|
- `aborted` -> `cancel`
|
||||||
|
- `error` -> `error`
|
||||||
|
|
||||||
|
## Auth + Gateway Discovery
|
||||||
|
|
||||||
|
`clawdbot acp` resolves the Gateway URL and auth from CLI flags or config:
|
||||||
|
|
||||||
|
- `--url` / `--token` / `--password` take precedence.
|
||||||
|
- Otherwise use configured `gateway.remote.*` settings.
|
||||||
|
|
||||||
|
## Operational Notes
|
||||||
|
|
||||||
|
- ACP sessions are stored in memory for the bridge process lifetime.
|
||||||
|
- Gateway session state is persisted by the Gateway itself.
|
||||||
|
- `--verbose` logs ACP/Gateway bridge events to stderr (never stdout).
|
||||||
|
- ACP runs can be canceled and the active run id is tracked per session.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x).
|
||||||
|
- Works with ACP clients that implement `initialize`, `newSession`,
|
||||||
|
`loadSession`, `prompt`, `cancel`, and `listSessions`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit: `src/acp/session.test.ts` covers run id lifecycle.
|
||||||
|
- Full gate: `pnpm lint && pnpm build && pnpm test && pnpm docs:build`.
|
||||||
|
|
||||||
|
## Related Docs
|
||||||
|
|
||||||
|
- CLI usage: `docs/cli/acp.md`
|
||||||
|
- Session model: `docs/concepts/session.md`
|
||||||
|
- Session management internals: `docs/reference/session-management-compaction.md`
|
||||||
40
docs/brave-search.md
Normal file
40
docs/brave-search.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
summary: "Brave Search API setup for web_search"
|
||||||
|
read_when:
|
||||||
|
- You want to use Brave Search for web_search
|
||||||
|
- You need a BRAVE_API_KEY or plan details
|
||||||
|
---
|
||||||
|
|
||||||
|
# Brave Search API
|
||||||
|
|
||||||
|
Clawdbot uses Brave Search as the default provider for `web_search`.
|
||||||
|
|
||||||
|
## Get an API key
|
||||||
|
|
||||||
|
1) Create a Brave Search API account at https://brave.com/search/api/
|
||||||
|
2) In the dashboard, choose the **Data for Search** plan and generate an API key.
|
||||||
|
3) Store the key in config (recommended) or set `BRAVE_API_KEY` in the Gateway environment.
|
||||||
|
|
||||||
|
## Config example
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
tools: {
|
||||||
|
web: {
|
||||||
|
search: {
|
||||||
|
provider: "brave",
|
||||||
|
apiKey: "BRAVE_API_KEY_HERE",
|
||||||
|
maxResults: 5,
|
||||||
|
timeoutSeconds: 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The Data for AI plan is **not** compatible with `web_search`.
|
||||||
|
- Brave provides a free tier plus paid plans; check the Brave API portal for current limits.
|
||||||
|
|
||||||
|
See [Web tools](/tools/web) for the full web_search configuration.
|
||||||
64
docs/channels/bluebubbles.md
Normal file
64
docs/channels/bluebubbles.md
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing)."
|
||||||
|
read_when:
|
||||||
|
- Setting up BlueBubbles channel
|
||||||
|
- Troubleshooting webhook pairing
|
||||||
|
---
|
||||||
|
# BlueBubbles (macOS REST)
|
||||||
|
|
||||||
|
Status: bundled plugin (disabled by default) that talks to the BlueBubbles macOS server over HTTP.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
- Runs on macOS via the BlueBubbles helper app (`https://bluebubbles.app`).
|
||||||
|
- Clawdbot talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`).
|
||||||
|
- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls.
|
||||||
|
- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible).
|
||||||
|
- Pairing/allowlist works the same way as other channels (`/start/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes.
|
||||||
|
- Reactions are surfaced as system events just like Slack/Telegram so agents can “mention” them before replying.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
1. Install the BlueBubbles server on your Mac (follows the app store instructions at `https://bluebubbles.app/install`).
|
||||||
|
2. In the BlueBubbles config, enable the web API and set a password for `guid`/`password`.
|
||||||
|
3. Configure Clawdbot:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
bluebubbles: {
|
||||||
|
enabled: true,
|
||||||
|
serverUrl: "http://bluebubbles-host:1234",
|
||||||
|
password: "example-password",
|
||||||
|
webhookPath: "/bluebubbles-webhook",
|
||||||
|
actions: { reactions: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. Point BlueBubbles webhooks to your gateway (example: `http://your-gateway-host/bluebubbles-webhook?password=<password>`).
|
||||||
|
5. Start the gateway; it will register the webhook handler and start pairing.
|
||||||
|
|
||||||
|
## Configuration notes
|
||||||
|
- `channels.bluebubbles.serverUrl`: base URL of the BlueBubbles REST API.
|
||||||
|
- `channels.bluebubbles.password`: password that BlueBubbles expects on every request (`?password=...` or header).
|
||||||
|
- `channels.bluebubbles.webhookPath`: HTTP path the gateway exposes for BlueBubbles webhooks.
|
||||||
|
- `channels.bluebubbles.dmPolicy` / `groupPolicy` + `allowFrom`/`groupAllowFrom` behave like other channels; pairing/allowlist info is stored in `/pairing`.
|
||||||
|
- `channels.bluebubbles.actions.reactions` toggles whether the gateway enqueues system events for reactions/tapbacks.
|
||||||
|
- `channels.bluebubbles.textChunkLimit` overrides the default 4k limit.
|
||||||
|
- `channels.bluebubbles.mediaMaxMb` controls the max size of inbound attachments saved for analysis (default 8MB).
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
- Outbound replies: `sendMessageBlueBubbles` resolves a chat GUID via `/api/v1/chat/query` and posts to `/api/v1/message/text`. Typing (`/api/v1/chat/<guid>/typing`) and read receipts (`/api/v1/chat/<guid>/read`) are sent before/after responses.
|
||||||
|
- Webhooks: BlueBubbles POSTs JSON payloads with `type` and `data`. The plugin ignores non-message events (typing indicator, read status) and extracts `chatGuid` from `data.chats[0].guid`.
|
||||||
|
- Reactions/tapbacks generate `BlueBubbles reaction added/removed` system events so agents can mention them. Agents can also trigger tapbacks via the `react` action with `messageId`, `emoji`, and a `to`/`chatGuid`.
|
||||||
|
- Attachments are downloaded via the REST API and stored in the inbound media cache; text-less messages are converted into `<media:...>` placeholders so the agent knows something was sent.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
|
||||||
|
- Keep the API password and webhook endpoint secret (treat them like credentials).
|
||||||
|
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- If Voice/typing events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`.
|
||||||
|
- Pairing codes expire after one hour; use `clawdbot pairing list bluebubbles` and `clawdbot pairing approve bluebubbles <code>`.
|
||||||
|
- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it.
|
||||||
|
|
||||||
|
For general channel workflow reference, see [/channels/index] and the [[plugins|/plugin]] guide.
|
||||||
@ -58,7 +58,7 @@ Minimal config:
|
|||||||
- The `discord` tool is only exposed when the current channel is Discord.
|
- The `discord` tool is only exposed when the current channel is Discord.
|
||||||
13. Native commands use isolated session keys (`agent:<agentId>:discord:slash:<userId>`) rather than the shared `main` session.
|
13. Native commands use isolated session keys (`agent:<agentId>:discord:slash:<userId>`) rather than the shared `main` session.
|
||||||
|
|
||||||
Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets.
|
Note: Name → id resolution uses guild member search and requires Server Members Intent; if the bot can’t search members, use ids or `<@id>` mentions.
|
||||||
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
|
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
|
||||||
Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy.
|
Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy.
|
||||||
|
|
||||||
@ -175,6 +175,7 @@ Notes:
|
|||||||
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
|
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
|
||||||
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
|
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
|
||||||
- If `channels` is present, any channel not listed is denied by default.
|
- If `channels` is present, any channel not listed is denied by default.
|
||||||
|
- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread channel id explicitly.
|
||||||
- Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered).
|
- Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered).
|
||||||
- Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
|
- Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
|
||||||
|
|
||||||
@ -192,8 +193,11 @@ Notes:
|
|||||||
- Your config requires mentions and you didn’t mention it, or
|
- Your config requires mentions and you didn’t mention it, or
|
||||||
- Your guild/channel allowlist denies the channel/user.
|
- Your guild/channel allowlist denies the channel/user.
|
||||||
- **`requireMention: false` but still no replies**:
|
- **`requireMention: false` but still no replies**:
|
||||||
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
|
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
|
||||||
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
|
- If you only set `DISCORD_BOT_TOKEN` and never create a `channels.discord` section, the runtime
|
||||||
|
defaults `groupPolicy` to `open`. Add `channels.discord.groupPolicy`,
|
||||||
|
`channels.defaults.groupPolicy`, or a guild/channel allowlist to lock it down.
|
||||||
|
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
|
||||||
- **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions.
|
- **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions.
|
||||||
- **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`).
|
- **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`).
|
||||||
|
|
||||||
@ -361,6 +365,10 @@ Allowlist matching notes:
|
|||||||
- Use `*` to allow any sender/channel.
|
- Use `*` to allow any sender/channel.
|
||||||
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
|
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
|
||||||
- When `guilds.<id>.channels` is omitted, all channels in the allowlisted guild are allowed.
|
- When `guilds.<id>.channels` is omitted, all channels in the allowlisted guild are allowed.
|
||||||
|
- To allow **no channels**, set `channels.discord.groupPolicy: "disabled"` (or keep an empty allowlist).
|
||||||
|
- The configure wizard accepts `Guild/Channel` names (public + private) and resolves them to IDs when possible.
|
||||||
|
- On startup, Clawdbot resolves channel/user names in allowlists to IDs (when the bot can search members)
|
||||||
|
and logs the mapping; unresolved entries are kept as typed.
|
||||||
|
|
||||||
Native command notes:
|
Native command notes:
|
||||||
- The registered commands mirror Clawdbot’s chat commands.
|
- The registered commands mirror Clawdbot’s chat commands.
|
||||||
|
|||||||
@ -17,6 +17,7 @@ Text is supported everywhere; media and reactions vary by channel.
|
|||||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||||
- [iMessage](/channels/imessage) — macOS only; native integration.
|
- [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).
|
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||||
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
|
||||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||||
|
|||||||
@ -70,9 +70,10 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
|
|||||||
- `clawdbot pairing list matrix`
|
- `clawdbot pairing list matrix`
|
||||||
- `clawdbot pairing approve matrix <CODE>`
|
- `clawdbot pairing approve matrix <CODE>`
|
||||||
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
|
- Public DMs: `channels.matrix.dm.policy="open"` plus `channels.matrix.dm.allowFrom=["*"]`.
|
||||||
|
- `channels.matrix.dm.allowFrom` accepts user IDs or display names (resolved at startup when directory search is available).
|
||||||
|
|
||||||
## Rooms (groups)
|
## Rooms (groups)
|
||||||
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated).
|
- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||||
- Allowlist rooms with `channels.matrix.rooms`:
|
- Allowlist rooms with `channels.matrix.rooms`:
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
@ -86,6 +87,9 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
- `requireMention: false` enables auto-reply in that room.
|
- `requireMention: false` enables auto-reply in that room.
|
||||||
|
- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names when possible.
|
||||||
|
- On startup, Clawdbot resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
|
||||||
|
- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist).
|
||||||
|
|
||||||
## Threads
|
## Threads
|
||||||
- Reply threading is supported.
|
- Reply threading is supported.
|
||||||
|
|||||||
@ -76,12 +76,13 @@ Disable with:
|
|||||||
|
|
||||||
**DM access**
|
**DM access**
|
||||||
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
|
- Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved.
|
||||||
- `channels.msteams.allowFrom` accepts AAD object IDs or UPNs.
|
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names (resolved at startup when Graph allows).
|
||||||
|
|
||||||
**Group access**
|
**Group access**
|
||||||
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`).
|
- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||||
- `channels.msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`).
|
- `channels.msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`).
|
||||||
- Set `groupPolicy: "open"` to allow any member (still mention‑gated by default).
|
- Set `groupPolicy: "open"` to allow any member (still mention‑gated by default).
|
||||||
|
- To allow **no channels**, set `channels.msteams.groupPolicy: "disabled"`.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```json5
|
```json5
|
||||||
@ -95,6 +96,32 @@ Example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Teams + channel allowlist**
|
||||||
|
- Scope group/channel replies by listing teams and channels under `channels.msteams.teams`.
|
||||||
|
- Keys can be team IDs or names; channel keys can be conversation IDs or names.
|
||||||
|
- When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mention‑gated).
|
||||||
|
- The configure wizard accepts `Team/Channel` entries and stores them for you.
|
||||||
|
- On startup, Clawdbot resolves team/channel and user allowlist names to IDs (when Graph permissions allow)
|
||||||
|
and logs the mapping; unresolved entries are kept as typed.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
msteams: {
|
||||||
|
groupPolicy: "allowlist",
|
||||||
|
teams: {
|
||||||
|
"My Team": {
|
||||||
|
channels: {
|
||||||
|
"General": { requireMention: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
1. Install the Microsoft Teams plugin.
|
1. Install the Microsoft Teams plugin.
|
||||||
2. Create an **Azure Bot** (App ID + secret + tenant ID).
|
2. Create an **Azure Bot** (App ID + secret + tenant ID).
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
---
|
---
|
||||||
summary: "Slack socket mode setup and Clawdbot config"
|
summary: "Slack setup for socket or HTTP webhook mode"
|
||||||
read_when: "Setting up Slack or debugging Slack socket 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**.
|
1) Create a Slack app and enable **Socket Mode**.
|
||||||
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
|
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
|
||||||
3) Set tokens for Clawdbot and start the gateway.
|
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.
|
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-...`).
|
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-...`).
|
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.
|
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):
|
Set tokens via env vars (recommended):
|
||||||
- `SLACK_APP_TOKEN=xapp-...`
|
- `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,
|
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
|
||||||
pins, reactions, emoji, member info). By default this stays read-only: reads
|
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
|
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,
|
- Read operations (history, reactions list, pins list, emoji list, member info,
|
||||||
search) prefer the user token when configured, otherwise the bot token.
|
search) prefer the user token when configured, otherwise the bot token.
|
||||||
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
|
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
|
||||||
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
|
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
|
||||||
no bot token is available, Clawdbot falls back to the user token.
|
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.
|
- `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).
|
- 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
|
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.
|
user scopes if you plan to configure a user token.
|
||||||
|
|
||||||
@ -343,10 +378,19 @@ For fine-grained control, use these tags in agent responses:
|
|||||||
- Default: `channels.slack.dm.policy="pairing"` — unknown DM senders get a pairing code (expires after 1 hour).
|
- Default: `channels.slack.dm.policy="pairing"` — unknown DM senders get a pairing code (expires after 1 hour).
|
||||||
- Approve via: `clawdbot pairing approve slack <code>`.
|
- Approve via: `clawdbot pairing approve slack <code>`.
|
||||||
- To allow anyone: set `channels.slack.dm.policy="open"` and `channels.slack.dm.allowFrom=["*"]`.
|
- To allow anyone: set `channels.slack.dm.policy="open"` and `channels.slack.dm.allowFrom=["*"]`.
|
||||||
|
- `channels.slack.dm.allowFrom` accepts user IDs, @handles, or emails (resolved at startup when tokens allow).
|
||||||
|
|
||||||
## Group policy
|
## Group policy
|
||||||
- `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
|
- `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
|
||||||
- `allowlist` requires channels to be listed in `channels.slack.channels`.
|
- `allowlist` requires channels to be listed in `channels.slack.channels`.
|
||||||
|
- If you only set `SLACK_BOT_TOKEN`/`SLACK_APP_TOKEN` and never create a `channels.slack` section,
|
||||||
|
the runtime defaults `groupPolicy` to `open`. Add `channels.slack.groupPolicy`,
|
||||||
|
`channels.defaults.groupPolicy`, or a channel allowlist to lock it down.
|
||||||
|
- The configure wizard accepts `#channel` names and resolves them to IDs when possible
|
||||||
|
(public + private); if multiple matches exist, it prefers the active channel.
|
||||||
|
- On startup, Clawdbot resolves channel/user names in allowlists to IDs (when tokens allow)
|
||||||
|
and logs the mapping; unresolved entries are kept as typed.
|
||||||
|
- To allow **no channels**, set `channels.slack.groupPolicy: "disabled"` (or keep an empty allowlist).
|
||||||
|
|
||||||
Channel options (`channels.slack.channels.<id>` or `channels.slack.channels.<name>`):
|
Channel options (`channels.slack.channels.<id>` or `channels.slack.channels.<name>`):
|
||||||
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
|
||||||
|
|||||||
@ -152,6 +152,7 @@ By default, the bot only responds to mentions in groups (`@botname` or patterns
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Important:** Setting `channels.telegram.groups` creates an **allowlist** - only listed groups (or `"*"`) will be accepted.
|
**Important:** Setting `channels.telegram.groups` creates an **allowlist** - only listed groups (or `"*"`) will be accepted.
|
||||||
|
Forum topics inherit their parent group config (allowFrom, requireMention, skills, prompts) unless you add per-topic overrides under `channels.telegram.groups.<groupId>.topics.<topicId>`.
|
||||||
|
|
||||||
To allow all groups with always-respond:
|
To allow all groups with always-respond:
|
||||||
```json5
|
```json5
|
||||||
@ -216,6 +217,7 @@ Telegram forum topics include a `message_thread_id` per message. Clawdbot:
|
|||||||
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
|
- General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it.
|
||||||
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
|
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
|
||||||
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
|
- Topic-specific configuration is available under `channels.telegram.groups.<chatId>.topics.<threadId>` (skills, allowlists, auto-reply, system prompts, disable).
|
||||||
|
- Topic configs inherit group settings (requireMention, allowlists, skills, prompts, enabled) unless overridden per topic.
|
||||||
|
|
||||||
Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps the DM session key unchanged, but still uses the thread id for replies/draft streaming when it is present.
|
Private chats can include `message_thread_id` in some edge cases. Clawdbot keeps the DM session key unchanged, but still uses the thread id for replies/draft streaming when it is present.
|
||||||
|
|
||||||
|
|||||||
@ -66,11 +66,36 @@ clawdbot directory groups list --channel zalouser --query "work"
|
|||||||
|
|
||||||
## Access control (DMs)
|
## Access control (DMs)
|
||||||
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
|
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
|
||||||
|
`channels.zalouser.allowFrom` accepts user IDs or names (resolved at startup when available).
|
||||||
|
|
||||||
Approve via:
|
Approve via:
|
||||||
- `clawdbot pairing list zalouser`
|
- `clawdbot pairing list zalouser`
|
||||||
- `clawdbot pairing approve zalouser <code>`
|
- `clawdbot pairing approve zalouser <code>`
|
||||||
|
|
||||||
|
## Group access (optional)
|
||||||
|
- Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||||
|
- Restrict to an allowlist with:
|
||||||
|
- `channels.zalouser.groupPolicy = "allowlist"`
|
||||||
|
- `channels.zalouser.groups` (keys are group IDs or names)
|
||||||
|
- Block all groups: `channels.zalouser.groupPolicy = "disabled"`.
|
||||||
|
- The configure wizard can prompt for group allowlists.
|
||||||
|
- On startup, Clawdbot resolves group/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
zalouser: {
|
||||||
|
groupPolicy: "allowlist",
|
||||||
|
groups: {
|
||||||
|
"123456789": { allow: true },
|
||||||
|
"Work Chat": { allow: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Multi-account
|
## Multi-account
|
||||||
Accounts map to zca profiles. Example:
|
Accounts map to zca profiles. Example:
|
||||||
|
|
||||||
|
|||||||
166
docs/cli/acp.md
Normal file
166
docs/cli/acp.md
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
---
|
||||||
|
summary: "Run the ACP bridge for IDE integrations"
|
||||||
|
read_when:
|
||||||
|
- Setting up ACP-based IDE integrations
|
||||||
|
- Debugging ACP session routing to the Gateway
|
||||||
|
---
|
||||||
|
|
||||||
|
# acp
|
||||||
|
|
||||||
|
Run the ACP (Agent Client Protocol) bridge that talks to a Clawdbot Gateway.
|
||||||
|
|
||||||
|
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
|
||||||
|
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp
|
||||||
|
|
||||||
|
# Remote Gateway
|
||||||
|
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
||||||
|
|
||||||
|
# Attach to an existing session key
|
||||||
|
clawdbot acp --session agent:main:main
|
||||||
|
|
||||||
|
# Attach by label (must already exist)
|
||||||
|
clawdbot acp --session-label "support inbox"
|
||||||
|
|
||||||
|
# Reset the session key before the first prompt
|
||||||
|
clawdbot acp --session agent:main:main --reset-session
|
||||||
|
```
|
||||||
|
|
||||||
|
## ACP client (debug)
|
||||||
|
|
||||||
|
Use the built-in ACP client to sanity-check the bridge without an IDE.
|
||||||
|
It spawns the ACP bridge and lets you type prompts interactively.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp client
|
||||||
|
|
||||||
|
# Point the spawned bridge at a remote Gateway
|
||||||
|
clawdbot acp client --server-args --url wss://gateway-host:18789 --token <token>
|
||||||
|
|
||||||
|
# Override the server command (default: clawdbot)
|
||||||
|
clawdbot acp client --server "node" --server-args dist/entry.js acp --url ws://127.0.0.1:19001
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to use this
|
||||||
|
|
||||||
|
Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want
|
||||||
|
it to drive a Clawdbot Gateway session.
|
||||||
|
|
||||||
|
1. Ensure the Gateway is running (local or remote).
|
||||||
|
2. Configure the Gateway target (config or flags).
|
||||||
|
3. Point your IDE to run `clawdbot acp` over stdio.
|
||||||
|
|
||||||
|
Example config (persisted):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot config set gateway.remote.url wss://gateway-host:18789
|
||||||
|
clawdbot config set gateway.remote.token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example direct run (no config write):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp --url wss://gateway-host:18789 --token <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Selecting agents
|
||||||
|
|
||||||
|
ACP does not pick agents directly. It routes by the Gateway session key.
|
||||||
|
|
||||||
|
Use agent-scoped session keys to target a specific agent:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot acp --session agent:main:main
|
||||||
|
clawdbot acp --session agent:design:main
|
||||||
|
clawdbot acp --session agent:qa:bug-123
|
||||||
|
```
|
||||||
|
|
||||||
|
Each ACP session maps to a single Gateway session key. One agent can have many
|
||||||
|
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
|
||||||
|
the key or label.
|
||||||
|
|
||||||
|
## Zed editor setup
|
||||||
|
|
||||||
|
Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zed’s Settings UI):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_servers": {
|
||||||
|
"Clawdbot ACP": {
|
||||||
|
"type": "custom",
|
||||||
|
"command": "clawdbot",
|
||||||
|
"args": ["acp"],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
To target a specific Gateway or agent:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agent_servers": {
|
||||||
|
"Clawdbot ACP": {
|
||||||
|
"type": "custom",
|
||||||
|
"command": "clawdbot",
|
||||||
|
"args": [
|
||||||
|
"acp",
|
||||||
|
"--url", "wss://gateway-host:18789",
|
||||||
|
"--token", "<token>",
|
||||||
|
"--session", "agent:design:main"
|
||||||
|
],
|
||||||
|
"env": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
|
||||||
|
|
||||||
|
## Session mapping
|
||||||
|
|
||||||
|
By default, ACP sessions get an isolated Gateway session key with an `acp:` prefix.
|
||||||
|
To reuse a known session, pass a session key or label:
|
||||||
|
|
||||||
|
- `--session <key>`: use a specific Gateway session key.
|
||||||
|
- `--session-label <label>`: resolve an existing session by label.
|
||||||
|
- `--reset-session`: mint a fresh session id for that key (same key, new transcript).
|
||||||
|
|
||||||
|
If your ACP client supports metadata, you can override per session:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"sessionKey": "agent:main:main",
|
||||||
|
"sessionLabel": "support inbox",
|
||||||
|
"resetSession": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Learn more about session keys at [/concepts/session](/concepts/session).
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
- `--url <url>`: Gateway WebSocket URL (defaults to gateway.remote.url when configured).
|
||||||
|
- `--token <token>`: Gateway auth token.
|
||||||
|
- `--password <password>`: Gateway auth password.
|
||||||
|
- `--session <key>`: default session key.
|
||||||
|
- `--session-label <label>`: default session label to resolve.
|
||||||
|
- `--require-existing`: fail if the session key/label does not exist.
|
||||||
|
- `--reset-session`: reset the session key before first use.
|
||||||
|
- `--no-prefix-cwd`: do not prefix prompts with the working directory.
|
||||||
|
- `--verbose, -v`: verbose logging to stderr.
|
||||||
|
|
||||||
|
### `acp client` options
|
||||||
|
|
||||||
|
- `--cwd <dir>`: working directory for the ACP session.
|
||||||
|
- `--server <command>`: ACP server command (default: `clawdbot`).
|
||||||
|
- `--server-args <args...>`: extra arguments passed to the ACP server.
|
||||||
|
- `--server-verbose`: enable verbose logging on the ACP server.
|
||||||
|
- `--verbose, -v`: verbose client logging.
|
||||||
44
docs/cli/approvals.md
Normal file
44
docs/cli/approvals.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
summary: "CLI reference for `clawdbot approvals` (exec approvals for gateway or node hosts)"
|
||||||
|
read_when:
|
||||||
|
- You want to edit exec approvals from the CLI
|
||||||
|
- You need to manage allowlists on gateway or node hosts
|
||||||
|
---
|
||||||
|
|
||||||
|
# `clawdbot approvals`
|
||||||
|
|
||||||
|
Manage exec approvals for the **gateway host** or a **node host**.
|
||||||
|
By default, commands target the gateway. Use `--node` to edit a node’s approvals.
|
||||||
|
|
||||||
|
Related:
|
||||||
|
- Exec approvals: [Exec approvals](/tools/exec-approvals)
|
||||||
|
- Nodes: [Nodes](/nodes)
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot approvals get
|
||||||
|
clawdbot approvals get --node <id|name|ip>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Replace approvals from a file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot approvals set --file ./exec-approvals.json
|
||||||
|
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Allowlist helpers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot approvals allowlist add "~/Projects/**/bin/rg"
|
||||||
|
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"
|
||||||
|
|
||||||
|
clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix).
|
||||||
|
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
|
||||||
|
- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`.
|
||||||
@ -20,6 +20,7 @@ clawdbot channels list
|
|||||||
clawdbot channels status
|
clawdbot channels status
|
||||||
clawdbot channels capabilities
|
clawdbot channels capabilities
|
||||||
clawdbot channels capabilities --channel discord --target channel:123
|
clawdbot channels capabilities --channel discord --target channel:123
|
||||||
|
clawdbot channels resolve --channel slack "#general" "@jane"
|
||||||
clawdbot channels logs --channel all
|
clawdbot channels logs --channel all
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -57,3 +58,17 @@ Notes:
|
|||||||
- `--channel` is optional; omit it to list every channel (including extensions).
|
- `--channel` is optional; omit it to list every channel (including extensions).
|
||||||
- `--target` accepts `channel:<id>` or a raw numeric channel id and only applies to Discord.
|
- `--target` accepts `channel:<id>` or a raw numeric channel id and only applies to Discord.
|
||||||
- Probes are provider-specific: Discord intents + optional channel permissions; Slack bot + user scopes; Telegram bot flags + webhook; Signal daemon version; MS Teams app token + Graph roles/scopes (annotated where known). Channels without probes report `Probe: unavailable`.
|
- Probes are provider-specific: Discord intents + optional channel permissions; Slack bot + user scopes; Telegram bot flags + webhook; Signal daemon version; MS Teams app token + Graph roles/scopes (annotated where known). Channels without probes report `Probe: unavailable`.
|
||||||
|
|
||||||
|
## Resolve names to IDs
|
||||||
|
|
||||||
|
Resolve channel/user names to IDs using the provider directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot channels resolve --channel slack "#general" "@jane"
|
||||||
|
clawdbot channels resolve --channel discord "My Server/#support" "@someone"
|
||||||
|
clawdbot channels resolve --channel matrix "Project Room"
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Use `--kind user|group|auto` to force the target type.
|
||||||
|
- Resolution prefers active matches when multiple entries share the same name.
|
||||||
|
|||||||
@ -15,6 +15,7 @@ the configure wizard (same as `clawdbot configure`).
|
|||||||
clawdbot config get browser.executablePath
|
clawdbot config get browser.executablePath
|
||||||
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
|
clawdbot config set browser.executablePath "/usr/bin/google-chrome"
|
||||||
clawdbot config set agents.defaults.heartbeat.every "2h"
|
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
|
clawdbot config unset tools.web.search.apiKey
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -27,6 +28,13 @@ clawdbot config get agents.defaults.workspace
|
|||||||
clawdbot config get agents.list[0].id
|
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
|
||||||
|
|
||||||
Values are parsed as JSON5 when possible; otherwise they are treated as strings.
|
Values are parsed as JSON5 when possible; otherwise they are treated as strings.
|
||||||
|
|||||||
@ -17,6 +17,7 @@ Related:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
- Choosing where the Gateway runs always updates `gateway.mode`. You can select "Continue" without other sections if that is all you need.
|
||||||
|
- Channel-oriented services (Slack/Discord/Matrix/Microsoft Teams) prompt for channel/room allowlists during setup. You can enter names or IDs; the wizard resolves names to IDs when possible.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,9 @@ read_when:
|
|||||||
|
|
||||||
Manage the Gateway daemon (background service).
|
Manage the Gateway daemon (background service).
|
||||||
|
|
||||||
|
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains
|
||||||
|
as a legacy alias for compatibility.
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Gateway CLI: [Gateway](/cli/gateway)
|
- Gateway CLI: [Gateway](/cli/gateway)
|
||||||
- macOS platform notes: [macOS](/platforms/macos)
|
- macOS platform notes: [macOS](/platforms/macos)
|
||||||
|
|||||||
@ -7,10 +7,11 @@ read_when:
|
|||||||
|
|
||||||
# `clawdbot hooks`
|
# `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:
|
Related:
|
||||||
- Hooks: [Hooks](/hooks)
|
- Hooks: [Hooks](/hooks)
|
||||||
|
- Plugin hooks: [Plugins](/plugin#plugin-hooks)
|
||||||
|
|
||||||
## List All Hooks
|
## List All Hooks
|
||||||
|
|
||||||
@ -28,11 +29,13 @@ List all discovered hooks from workspace, managed, and bundled directories.
|
|||||||
**Example output:**
|
**Example output:**
|
||||||
|
|
||||||
```
|
```
|
||||||
Hooks (2/2 ready)
|
Hooks (4/4 ready)
|
||||||
|
|
||||||
Ready:
|
Ready:
|
||||||
|
🚀 boot-md ✓ - Run BOOT.md on gateway startup
|
||||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
📝 command-logger ✓ - Log all command events to a centralized audit file
|
||||||
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
💾 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):**
|
**Example (verbose):**
|
||||||
@ -105,8 +108,8 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
|
|||||||
```
|
```
|
||||||
Hooks Status
|
Hooks Status
|
||||||
|
|
||||||
Total hooks: 2
|
Total hooks: 4
|
||||||
Ready: 2
|
Ready: 4
|
||||||
Not ready: 0
|
Not ready: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -118,6 +121,9 @@ clawdbot hooks enable <name>
|
|||||||
|
|
||||||
Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
|
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:**
|
**Arguments:**
|
||||||
- `<name>`: Hook name (e.g., `session-memory`)
|
- `<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)
|
**See:** [command-logger documentation](/hooks#command-logger)
|
||||||
|
|
||||||
|
### soul-evil
|
||||||
|
|
||||||
|
Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance.
|
||||||
|
|
||||||
|
**Enable:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot hooks enable soul-evil
|
||||||
|
```
|
||||||
|
|
||||||
|
**See:** [SOUL Evil Hook](/hooks/soul-evil)
|
||||||
|
|
||||||
|
### boot-md
|
||||||
|
|
||||||
|
Runs `BOOT.md` when the gateway starts (after channels start).
|
||||||
|
|
||||||
|
**Events**: `gateway:startup`
|
||||||
|
|
||||||
|
**Enable**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot hooks enable boot-md
|
||||||
|
```
|
||||||
|
|
||||||
|
**See:** [boot-md documentation](/hooks#boot-md)
|
||||||
|
|||||||
@ -23,15 +23,19 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`message`](/cli/message)
|
- [`message`](/cli/message)
|
||||||
- [`agent`](/cli/agent)
|
- [`agent`](/cli/agent)
|
||||||
- [`agents`](/cli/agents)
|
- [`agents`](/cli/agents)
|
||||||
|
- [`acp`](/cli/acp)
|
||||||
- [`status`](/cli/status)
|
- [`status`](/cli/status)
|
||||||
- [`health`](/cli/health)
|
- [`health`](/cli/health)
|
||||||
- [`sessions`](/cli/sessions)
|
- [`sessions`](/cli/sessions)
|
||||||
- [`gateway`](/cli/gateway)
|
- [`gateway`](/cli/gateway)
|
||||||
- [`daemon`](/cli/daemon)
|
- [`daemon`](/cli/daemon)
|
||||||
|
- [`service`](/cli/service)
|
||||||
- [`logs`](/cli/logs)
|
- [`logs`](/cli/logs)
|
||||||
- [`models`](/cli/models)
|
- [`models`](/cli/models)
|
||||||
- [`memory`](/cli/memory)
|
- [`memory`](/cli/memory)
|
||||||
- [`nodes`](/cli/nodes)
|
- [`nodes`](/cli/nodes)
|
||||||
|
- [`node`](/cli/node)
|
||||||
|
- [`approvals`](/cli/approvals)
|
||||||
- [`sandbox`](/cli/sandbox)
|
- [`sandbox`](/cli/sandbox)
|
||||||
- [`tui`](/cli/tui)
|
- [`tui`](/cli/tui)
|
||||||
- [`browser`](/cli/browser)
|
- [`browser`](/cli/browser)
|
||||||
@ -125,6 +129,7 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
list
|
list
|
||||||
add
|
add
|
||||||
delete
|
delete
|
||||||
|
acp
|
||||||
status
|
status
|
||||||
health
|
health
|
||||||
sessions
|
sessions
|
||||||
@ -140,6 +145,21 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
start
|
start
|
||||||
stop
|
stop
|
||||||
restart
|
restart
|
||||||
|
service
|
||||||
|
gateway
|
||||||
|
status
|
||||||
|
install
|
||||||
|
uninstall
|
||||||
|
start
|
||||||
|
stop
|
||||||
|
restart
|
||||||
|
node
|
||||||
|
status
|
||||||
|
install
|
||||||
|
uninstall
|
||||||
|
start
|
||||||
|
stop
|
||||||
|
restart
|
||||||
logs
|
logs
|
||||||
models
|
models
|
||||||
list
|
list
|
||||||
@ -168,21 +188,19 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
runs
|
runs
|
||||||
run
|
run
|
||||||
nodes
|
nodes
|
||||||
status
|
node
|
||||||
describe
|
start
|
||||||
list
|
daemon
|
||||||
pending
|
status
|
||||||
approve
|
install
|
||||||
reject
|
uninstall
|
||||||
rename
|
start
|
||||||
invoke
|
stop
|
||||||
run
|
restart
|
||||||
notify
|
approvals
|
||||||
camera list|snap|clip
|
get
|
||||||
canvas snapshot|present|hide|navigate|eval
|
set
|
||||||
canvas a2ui push|reset
|
allowlist add|remove
|
||||||
screen record
|
|
||||||
location get
|
|
||||||
browser
|
browser
|
||||||
status
|
status
|
||||||
start
|
start
|
||||||
@ -506,6 +524,11 @@ Options:
|
|||||||
- `--force`
|
- `--force`
|
||||||
- `--json`
|
- `--json`
|
||||||
|
|
||||||
|
### `acp`
|
||||||
|
Run the ACP bridge that connects IDEs to the Gateway.
|
||||||
|
|
||||||
|
See [`acp`](/cli/acp) for full options and examples.
|
||||||
|
|
||||||
### `status`
|
### `status`
|
||||||
Show linked session health and recent recipients.
|
Show linked session health and recent recipients.
|
||||||
|
|
||||||
@ -518,11 +541,14 @@ Options:
|
|||||||
- `--verbose`
|
- `--verbose`
|
||||||
- `--debug` (alias for `--verbose`)
|
- `--debug` (alias for `--verbose`)
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Overview includes Gateway + Node service status when available.
|
||||||
|
|
||||||
### Usage tracking
|
### Usage tracking
|
||||||
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
||||||
|
|
||||||
Surfaces:
|
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)
|
- `clawdbot status --usage` (prints full provider breakdown)
|
||||||
- macOS menu bar (Usage section under Context)
|
- macOS menu bar (Usage section under Context)
|
||||||
|
|
||||||
@ -772,6 +798,23 @@ Subcommands:
|
|||||||
|
|
||||||
All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
|
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
|
||||||
|
|
||||||
`nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).
|
`nodes` talks to the Gateway and targets paired nodes. See [/nodes](/nodes).
|
||||||
@ -788,7 +831,7 @@ Subcommands:
|
|||||||
- `nodes reject <requestId>`
|
- `nodes reject <requestId>`
|
||||||
- `nodes rename --node <id|name|ip> --name <displayName>`
|
- `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 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)
|
- `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:
|
Camera:
|
||||||
|
|||||||
@ -7,10 +7,12 @@ read_when:
|
|||||||
|
|
||||||
# `clawdbot memory`
|
# `clawdbot memory`
|
||||||
|
|
||||||
Memory search tools (semantic memory status/index/search).
|
Manage semantic memory indexing and search.
|
||||||
|
Provided by the active memory plugin (default: `memory-core`; set `plugins.slots.memory = "none"` to disable).
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Memory concept: [Memory](/concepts/memory)
|
- Memory concept: [Memory](/concepts/memory)
|
||||||
|
- Plugins: [Plugins](/plugins)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
@ -18,6 +20,22 @@ Related:
|
|||||||
clawdbot memory status
|
clawdbot memory status
|
||||||
clawdbot memory status --deep
|
clawdbot memory status --deep
|
||||||
clawdbot memory status --deep --index
|
clawdbot memory status --deep --index
|
||||||
|
clawdbot memory status --deep --index --verbose
|
||||||
clawdbot memory index
|
clawdbot memory index
|
||||||
|
clawdbot memory index --verbose
|
||||||
clawdbot memory search "release checklist"
|
clawdbot memory search "release checklist"
|
||||||
|
clawdbot memory status --agent main
|
||||||
|
clawdbot memory index --agent main --verbose
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
Common:
|
||||||
|
|
||||||
|
- `--agent <id>`: scope to a single agent (default: all configured agents).
|
||||||
|
- `--verbose`: emit detailed logs during probes and indexing.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `memory status --deep` probes vector + embedding availability.
|
||||||
|
- `memory status --deep --index` runs a reindex if the store is dirty.
|
||||||
|
- `memory index --verbose` prints per-phase details (provider, model, sources, batch activity).
|
||||||
|
|||||||
96
docs/cli/node.md
Normal file
96
docs/cli/node.md
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
---
|
||||||
|
summary: "CLI reference for `clawdbot node` (headless node host)"
|
||||||
|
read_when:
|
||||||
|
- Running the headless node host
|
||||||
|
- Pairing a non-macOS node for system.run
|
||||||
|
---
|
||||||
|
|
||||||
|
# `clawdbot node`
|
||||||
|
|
||||||
|
Run a **headless node host** that connects to the Gateway bridge and exposes
|
||||||
|
`system.run` / `system.which` on this machine.
|
||||||
|
|
||||||
|
## Why use a node host?
|
||||||
|
|
||||||
|
Use a node host when you want agents to **run commands on other machines** in your
|
||||||
|
network without installing a full macOS companion app there.
|
||||||
|
|
||||||
|
Common use cases:
|
||||||
|
- Run commands on remote Linux/Windows boxes (build servers, lab machines, NAS).
|
||||||
|
- Keep exec **sandboxed** on the gateway, but delegate approved runs to other hosts.
|
||||||
|
- Provide a lightweight, headless execution target for automation or CI nodes.
|
||||||
|
|
||||||
|
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
||||||
|
node host, so you can keep command access scoped and explicit.
|
||||||
|
|
||||||
|
## Start (foreground)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node start --host <gateway-host> --port 18790
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
||||||
|
- `--port <port>`: Gateway bridge port (default: `18790`)
|
||||||
|
- `--tls`: Use TLS for the bridge connection
|
||||||
|
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
||||||
|
- `--node-id <id>`: Override node id (clears pairing token)
|
||||||
|
- `--display-name <name>`: Override the node display name
|
||||||
|
|
||||||
|
## Service (background)
|
||||||
|
|
||||||
|
Install a headless node host as a user service.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node service install --host <gateway-host> --port 18790
|
||||||
|
# or
|
||||||
|
clawdbot service node install --host <gateway-host> --port 18790
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--host <host>`: Gateway bridge host (default: `127.0.0.1`)
|
||||||
|
- `--port <port>`: Gateway bridge port (default: `18790`)
|
||||||
|
- `--tls`: Use TLS for the bridge connection
|
||||||
|
- `--tls-fingerprint <sha256>`: Pin the bridge certificate fingerprint
|
||||||
|
- `--node-id <id>`: Override node id (clears pairing token)
|
||||||
|
- `--display-name <name>`: Override the node display name
|
||||||
|
- `--runtime <runtime>`: Service runtime (`node` or `bun`)
|
||||||
|
- `--force`: Reinstall/overwrite if already installed
|
||||||
|
|
||||||
|
Manage the service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node status
|
||||||
|
clawdbot service node status
|
||||||
|
clawdbot node service status
|
||||||
|
clawdbot node service start
|
||||||
|
clawdbot node service stop
|
||||||
|
clawdbot node service restart
|
||||||
|
clawdbot node service uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy alias:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node daemon status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pairing
|
||||||
|
|
||||||
|
The first connection creates a pending node pair request on the Gateway.
|
||||||
|
Approve it via:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot nodes pending
|
||||||
|
clawdbot nodes approve <requestId>
|
||||||
|
```
|
||||||
|
|
||||||
|
The node host stores its node id + token in `~/.clawdbot/node.json`.
|
||||||
|
|
||||||
|
## Exec approvals
|
||||||
|
|
||||||
|
`system.run` is gated by local exec approvals:
|
||||||
|
|
||||||
|
- `~/.clawdbot/exec-approvals.json`
|
||||||
|
- [Exec approvals](/tools/exec-approvals)
|
||||||
|
- `clawdbot approvals --node <id|name|ip>` (edit from the Gateway)
|
||||||
51
docs/cli/service.md
Normal file
51
docs/cli/service.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
summary: "CLI reference for `clawdbot service` (manage gateway + node services)"
|
||||||
|
read_when:
|
||||||
|
- You want to manage Gateway or node services cross-platform
|
||||||
|
- You want a single surface for start/stop/install/uninstall
|
||||||
|
---
|
||||||
|
|
||||||
|
# `clawdbot service`
|
||||||
|
|
||||||
|
Manage the **Gateway** service and **node host** services.
|
||||||
|
|
||||||
|
Related:
|
||||||
|
- Gateway daemon (legacy alias): [Daemon](/cli/daemon)
|
||||||
|
- Node host: [Node](/cli/node)
|
||||||
|
|
||||||
|
## Gateway service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot service gateway status
|
||||||
|
clawdbot service gateway install --port 18789
|
||||||
|
clawdbot service gateway start
|
||||||
|
clawdbot service gateway stop
|
||||||
|
clawdbot service gateway restart
|
||||||
|
clawdbot service gateway uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `service gateway status` supports `--json` and `--deep` for system checks.
|
||||||
|
- `service gateway install` supports `--runtime node|bun` and `--token`.
|
||||||
|
|
||||||
|
## Node host service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot service node status
|
||||||
|
clawdbot service node install --host <gateway-host> --port 18790
|
||||||
|
clawdbot service node start
|
||||||
|
clawdbot service node stop
|
||||||
|
clawdbot service node restart
|
||||||
|
clawdbot service node uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `service node install` supports `--runtime node|bun`, `--node-id`, `--display-name`,
|
||||||
|
and TLS options (`--tls`, `--tls-fingerprint`).
|
||||||
|
|
||||||
|
## Aliases
|
||||||
|
|
||||||
|
- `clawdbot daemon …` → `clawdbot service gateway …`
|
||||||
|
- `clawdbot node service …` → `clawdbot service node …`
|
||||||
|
- `clawdbot node status` → `clawdbot service node status`
|
||||||
|
- `clawdbot node daemon …` → `clawdbot service node …` (legacy)
|
||||||
@ -19,4 +19,5 @@ clawdbot status --usage
|
|||||||
Notes:
|
Notes:
|
||||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||||
- Output includes per-agent session stores when multiple agents are configured.
|
- 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)).
|
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
||||||
|
|||||||
@ -5,13 +5,19 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Agent Loop (Clawdbot)
|
# 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
|
## Entry points
|
||||||
- Gateway RPC: `agent` and `agent.wait`.
|
- Gateway RPC: `agent` and `agent.wait`.
|
||||||
- CLI: `agent` command.
|
- 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.
|
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
|
||||||
2) `agentCommand` runs the agent:
|
2) `agentCommand` runs the agent:
|
||||||
- resolves model + thinking/verbose defaults
|
- resolves model + thinking/verbose defaults
|
||||||
@ -19,8 +25,9 @@ Short, exact flow of one agent run.
|
|||||||
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
|
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
|
||||||
- emits **lifecycle end/error** if the embedded loop does not emit one
|
- emits **lifecycle end/error** if the embedded loop does not emit one
|
||||||
3) `runEmbeddedPiAgent`:
|
3) `runEmbeddedPiAgent`:
|
||||||
- builds `AgentSession` and subscribes to pi events
|
- serializes runs via per-session + global queues
|
||||||
- streams assistant deltas + tool events
|
- resolves model + auth profile and builds the pi session
|
||||||
|
- subscribes to pi events and streams assistant/tool deltas
|
||||||
- enforces timeout -> aborts run if exceeded
|
- enforces timeout -> aborts run if exceeded
|
||||||
- returns payloads + usage metadata
|
- returns payloads + usage metadata
|
||||||
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream:
|
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`
|
- waits for **lifecycle end/error** for `runId`
|
||||||
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
|
- 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)
|
## Event streams (today)
|
||||||
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
|
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
|
||||||
- `assistant`: streamed deltas from pi-agent-core
|
- `assistant`: streamed deltas from pi-agent-core
|
||||||
|
|||||||
@ -86,6 +86,10 @@ These are the standard files Clawdbot expects inside the workspace:
|
|||||||
- Optional tiny checklist for heartbeat runs.
|
- Optional tiny checklist for heartbeat runs.
|
||||||
- Keep it short to avoid token burn.
|
- 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`
|
- `BOOTSTRAP.md`
|
||||||
- One-time first-run ritual.
|
- One-time first-run ritual.
|
||||||
- Only created for a brand-new workspace.
|
- Only created for a brand-new workspace.
|
||||||
|
|||||||
@ -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.
|
- `/status` → quick “how full is my window?” view + session settings.
|
||||||
- `/context list` → what’s injected + rough sizes (per file + totals).
|
- `/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.
|
- `/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.
|
- `/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).
|
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).
|
- `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.
|
Either way, it reports sizes and top contributors; it does **not** dump the full system prompt or tool schemas.
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,9 @@ read_when:
|
|||||||
Clawdbot memory is **plain Markdown in the agent workspace**. The files are the
|
Clawdbot memory is **plain Markdown in the agent workspace**. The files are the
|
||||||
source of truth; the model only "remembers" what gets written to disk.
|
source of truth; the model only "remembers" what gets written to disk.
|
||||||
|
|
||||||
|
Memory search tools are provided by the active memory plugin (default:
|
||||||
|
`memory-core`). Disable memory plugins with `plugins.slots.memory = "none"`.
|
||||||
|
|
||||||
## Memory files (Markdown)
|
## Memory files (Markdown)
|
||||||
|
|
||||||
The default workspace layout uses two memory layers:
|
The default workspace layout uses two memory layers:
|
||||||
@ -76,18 +79,46 @@ semantic queries can find related notes even when wording differs.
|
|||||||
Defaults:
|
Defaults:
|
||||||
- Enabled by default.
|
- Enabled by default.
|
||||||
- Watches memory files for changes (debounced).
|
- Watches memory files for changes (debounced).
|
||||||
- Uses remote embeddings (OpenAI) unless configured for local.
|
- Uses remote embeddings by default. If `memorySearch.provider` is not set, Clawdbot auto-selects:
|
||||||
|
1. `local` if a `memorySearch.local.modelPath` is configured and the file exists.
|
||||||
|
2. `openai` if an OpenAI key can be resolved.
|
||||||
|
3. `gemini` if a Gemini key can be resolved.
|
||||||
|
4. Otherwise memory search stays disabled until configured.
|
||||||
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
||||||
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
|
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
|
||||||
|
|
||||||
Remote embeddings **require** an API key for the embedding provider. By default
|
Remote embeddings **require** an API key for the embedding provider. Clawdbot
|
||||||
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
|
resolves keys from auth profiles, `models.providers.*.apiKey`, or environment
|
||||||
OAuth only covers chat/completions and does **not** satisfy embeddings for
|
variables. Codex OAuth only covers chat/completions and does **not** satisfy
|
||||||
memory search. When using a custom OpenAI-compatible endpoint, set
|
embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or
|
||||||
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
`models.providers.google.apiKey`. When using a custom OpenAI-compatible endpoint,
|
||||||
|
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
||||||
|
|
||||||
If you want to use a **custom OpenAI-compatible endpoint** (like Gemini, OpenRouter, or a proxy),
|
### Gemini embeddings (native)
|
||||||
you can use the `remote` configuration:
|
|
||||||
|
Set the provider to `gemini` to use the Gemini embeddings API directly:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
memorySearch: {
|
||||||
|
provider: "gemini",
|
||||||
|
model: "gemini-embedding-001",
|
||||||
|
remote: {
|
||||||
|
apiKey: "YOUR_GEMINI_API_KEY"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `remote.baseUrl` is optional (defaults to the Gemini API base URL).
|
||||||
|
- `remote.headers` lets you add extra headers if needed.
|
||||||
|
- Default model: `gemini-embedding-001`.
|
||||||
|
|
||||||
|
If you want to use a **custom OpenAI-compatible endpoint** (OpenRouter, vLLM, or a proxy),
|
||||||
|
you can use the `remote` configuration with the OpenAI provider:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
agents: {
|
agents: {
|
||||||
@ -96,8 +127,8 @@ agents: {
|
|||||||
provider: "openai",
|
provider: "openai",
|
||||||
model: "text-embedding-3-small",
|
model: "text-embedding-3-small",
|
||||||
remote: {
|
remote: {
|
||||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
baseUrl: "https://api.example.com/v1/",
|
||||||
apiKey: "YOUR_GEMINI_API_KEY",
|
apiKey: "YOUR_OPENAI_COMPAT_API_KEY",
|
||||||
headers: { "X-Custom-Header": "value" }
|
headers: { "X-Custom-Header": "value" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -108,6 +139,24 @@ agents: {
|
|||||||
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
||||||
`memorySearch.fallback = "none"`.
|
`memorySearch.fallback = "none"`.
|
||||||
|
|
||||||
|
Fallbacks:
|
||||||
|
- `memorySearch.fallback` can be `openai`, `gemini`, `local`, or `none`.
|
||||||
|
- The fallback provider is only used when the primary embedding provider fails.
|
||||||
|
|
||||||
|
Batch indexing (OpenAI + Gemini):
|
||||||
|
- Enabled by default for OpenAI and Gemini embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
|
||||||
|
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
|
||||||
|
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
|
||||||
|
- Batch mode applies when `memorySearch.provider = "openai"` or `"gemini"` and uses the corresponding API key.
|
||||||
|
- Gemini batch jobs use the async embeddings batch endpoint and require Gemini Batch API availability.
|
||||||
|
|
||||||
|
Why OpenAI batch is fast + cheap:
|
||||||
|
- For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously.
|
||||||
|
- OpenAI offers discounted pricing for Batch API workloads, so large indexing runs are usually cheaper than sending the same requests synchronously.
|
||||||
|
- See the OpenAI Batch API docs and pricing for details:
|
||||||
|
- https://platform.openai.com/docs/api-reference/batch
|
||||||
|
- https://platform.openai.com/pricing
|
||||||
|
|
||||||
Config example:
|
Config example:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@ -117,6 +166,9 @@ agents: {
|
|||||||
provider: "openai",
|
provider: "openai",
|
||||||
model: "text-embedding-3-small",
|
model: "text-embedding-3-small",
|
||||||
fallback: "openai",
|
fallback: "openai",
|
||||||
|
remote: {
|
||||||
|
batch: { enabled: true, concurrency: 2 }
|
||||||
|
},
|
||||||
sync: { watch: true }
|
sync: { watch: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -141,8 +193,93 @@ Local mode:
|
|||||||
### What gets indexed (and when)
|
### What gets indexed (and when)
|
||||||
|
|
||||||
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
|
- File type: Markdown only (`MEMORY.md`, `memory/**/*.md`).
|
||||||
- Index storage: per-agent SQLite at `~/.clawdbot/state/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
- Index storage: per-agent SQLite at `~/.clawdbot/memory/<agentId>.sqlite` (configurable via `agents.defaults.memorySearch.store.path`, supports `{agentId}` token).
|
||||||
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval. Reindex triggers when embedding model/provider or chunk sizes change.
|
- Freshness: watcher on `MEMORY.md` + `memory/` marks the index dirty (debounce 1.5s). Sync runs on session start, on first search when dirty, and optionally on an interval.
|
||||||
|
- Reindex triggers: the index stores the embedding **provider/model + endpoint fingerprint + chunking params**. If any of those change, Clawdbot automatically resets and reindexes the entire store.
|
||||||
|
|
||||||
|
### Hybrid search (BM25 + vector)
|
||||||
|
|
||||||
|
When enabled, Clawdbot combines:
|
||||||
|
- **Vector similarity** (semantic match, wording can differ)
|
||||||
|
- **BM25 keyword relevance** (exact tokens like IDs, env vars, code symbols)
|
||||||
|
|
||||||
|
If full-text search is unavailable on your platform, Clawdbot falls back to vector-only search.
|
||||||
|
|
||||||
|
#### Why hybrid?
|
||||||
|
|
||||||
|
Vector search is great at “this means the same thing”:
|
||||||
|
- “Mac Studio gateway host” vs “the machine running the gateway”
|
||||||
|
- “debounce file updates” vs “avoid indexing on every write”
|
||||||
|
|
||||||
|
But it can be weak at exact, high-signal tokens:
|
||||||
|
- IDs (`a828e60`, `b3b9895a…`)
|
||||||
|
- code symbols (`memorySearch.query.hybrid`)
|
||||||
|
- error strings (“sqlite-vec unavailable”)
|
||||||
|
|
||||||
|
BM25 (full-text) is the opposite: strong at exact tokens, weaker at paraphrases.
|
||||||
|
Hybrid search is the pragmatic middle ground: **use both retrieval signals** so you get
|
||||||
|
good results for both “natural language” queries and “needle in a haystack” queries.
|
||||||
|
|
||||||
|
#### How we merge results (the current design)
|
||||||
|
|
||||||
|
Implementation sketch:
|
||||||
|
|
||||||
|
1) Retrieve a candidate pool from both sides:
|
||||||
|
- **Vector**: top `maxResults * candidateMultiplier` by cosine similarity.
|
||||||
|
- **BM25**: top `maxResults * candidateMultiplier` by FTS5 BM25 rank (lower is better).
|
||||||
|
|
||||||
|
2) Convert BM25 rank into a 0..1-ish score:
|
||||||
|
- `textScore = 1 / (1 + max(0, bm25Rank))`
|
||||||
|
|
||||||
|
3) Union candidates by chunk id and compute a weighted score:
|
||||||
|
- `finalScore = vectorWeight * vectorScore + textWeight * textScore`
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `vectorWeight` + `textWeight` is normalized to 1.0 in config resolution, so weights behave as percentages.
|
||||||
|
- If embeddings are unavailable (or the provider returns a zero-vector), we still run BM25 and return keyword matches.
|
||||||
|
- If FTS5 can’t be created, we keep vector-only search (no hard failure).
|
||||||
|
|
||||||
|
This isn’t “IR-theory perfect”, but it’s simple, fast, and tends to improve recall/precision on real notes.
|
||||||
|
If we want to get fancier later, common next steps are Reciprocal Rank Fusion (RRF) or score normalization
|
||||||
|
(min/max or z-score) before mixing.
|
||||||
|
|
||||||
|
Config:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
memorySearch: {
|
||||||
|
query: {
|
||||||
|
hybrid: {
|
||||||
|
enabled: true,
|
||||||
|
vectorWeight: 0.7,
|
||||||
|
textWeight: 0.3,
|
||||||
|
candidateMultiplier: 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Embedding cache
|
||||||
|
|
||||||
|
Clawdbot can cache **chunk embeddings** in SQLite so reindexing and frequent updates (especially session transcripts) don't re-embed unchanged text.
|
||||||
|
|
||||||
|
Config:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
agents: {
|
||||||
|
defaults: {
|
||||||
|
memorySearch: {
|
||||||
|
cache: {
|
||||||
|
enabled: true,
|
||||||
|
maxEntries: 50000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Session memory search (experimental)
|
### Session memory search (experimental)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
Manual selection via `/model …@<profileId>` sets a **user override** for that session
|
||||||
and is not auto‑rotated until a new session starts.
|
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”
|
### 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:
|
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:
|
||||||
|
|||||||
@ -183,6 +183,22 @@ Kimi Code uses a dedicated endpoint and key (separate from Moonshot):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Qwen OAuth (free tier)
|
||||||
|
|
||||||
|
Qwen provides OAuth access to Qwen Coder + Vision via a device-code flow.
|
||||||
|
Enable the bundled plugin, then log in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins enable qwen-portal-auth
|
||||||
|
clawdbot models auth login --provider qwen-portal --set-default
|
||||||
|
```
|
||||||
|
|
||||||
|
Model refs:
|
||||||
|
- `qwen-portal/coder-model`
|
||||||
|
- `qwen-portal/vision-model`
|
||||||
|
|
||||||
|
See [/providers/qwen](/providers/qwen) for setup details and notes.
|
||||||
|
|
||||||
### Synthetic
|
### Synthetic
|
||||||
|
|
||||||
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
|
Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
|
||||||
|
|||||||
@ -25,6 +25,7 @@ All session state is **owned by the gateway** (the “master” Clawdbot). UI cl
|
|||||||
- Transcripts: `~/.clawdbot/agents/<agentId>/sessions/<SessionId>.jsonl` (Telegram topic sessions use `.../<SessionId>-topic-<threadId>.jsonl`).
|
- Transcripts: `~/.clawdbot/agents/<agentId>/sessions/<SessionId>.jsonl` (Telegram topic sessions use `.../<SessionId>-topic-<threadId>.jsonl`).
|
||||||
- The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand.
|
- The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand.
|
||||||
- Group entries may include `displayName`, `channel`, `subject`, `room`, and `space` to label sessions in UIs.
|
- Group entries may include `displayName`, `channel`, `subject`, `room`, and `space` to label sessions in UIs.
|
||||||
|
- Session entries include `origin` metadata (label + routing hints) so UIs can explain where a session came from.
|
||||||
- Clawdbot does **not** read legacy Pi/Tau session folders.
|
- Clawdbot does **not** read legacy Pi/Tau session folders.
|
||||||
|
|
||||||
## Session pruning
|
## Session pruning
|
||||||
@ -53,8 +54,12 @@ the workspace is writable. See [Memory](/concepts/memory) and
|
|||||||
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
|
- Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
|
||||||
- Node bridge runs: `node-<nodeId>`
|
- Node bridge runs: `node-<nodeId>`
|
||||||
|
|
||||||
## Lifecyle
|
## Lifecycle
|
||||||
- Idle expiry: `session.idleMinutes` (default 60). After the timeout a new `sessionId` is minted on the next message.
|
- 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.
|
- 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.
|
- 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).
|
- Isolated cron jobs always mint a fresh `sessionId` per run (no idle reuse).
|
||||||
@ -92,7 +97,18 @@ Send these as standalone messages so they register.
|
|||||||
identityLinks: {
|
identityLinks: {
|
||||||
alice: ["telegram:123456789", "discord:987654321012345678"]
|
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"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
|
||||||
mainKey: "main",
|
mainKey: "main",
|
||||||
@ -113,3 +129,18 @@ Send these as standalone messages so they register.
|
|||||||
## Tips
|
## Tips
|
||||||
- Keep the primary key dedicated to 1:1 traffic; let groups keep their own keys.
|
- Keep the primary key dedicated to 1:1 traffic; let groups keep their own keys.
|
||||||
- When automating cleanup, delete individual keys instead of the whole store to preserve context elsewhere.
|
- When automating cleanup, delete individual keys instead of the whole store to preserve context elsewhere.
|
||||||
|
|
||||||
|
## Session origin metadata
|
||||||
|
Each session entry records where it came from (best-effort) in `origin`:
|
||||||
|
- `label`: human label (resolved from conversation label + group subject/channel)
|
||||||
|
- `provider`: normalized channel id (including extensions)
|
||||||
|
- `from`/`to`: raw routing ids from the inbound envelope
|
||||||
|
- `accountId`: provider account id (when multi-account)
|
||||||
|
- `threadId`: thread/topic id when the channel supports it
|
||||||
|
The origin fields are populated for direct messages, channels, and groups. If a
|
||||||
|
connector only updates delivery routing (for example, to keep a DM main session
|
||||||
|
fresh), it should still provide inbound context so the session keeps its
|
||||||
|
explainer metadata. Extensions can do this by sending `ConversationLabel`,
|
||||||
|
`GroupSubject`, `GroupChannel`, `GroupSpace`, and `SenderName` in the inbound
|
||||||
|
context and calling `recordSessionMetaFromInbound` (or passing the same context
|
||||||
|
to `updateLastRoute`).
|
||||||
|
|||||||
@ -18,6 +18,7 @@ The prompt is intentionally compact and uses fixed sections:
|
|||||||
- **Skills** (when available): tells the model how to load skill instructions on demand.
|
- **Skills** (when available): tells the model how to load skill instructions on demand.
|
||||||
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
|
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
|
||||||
- **Workspace**: working directory (`agents.defaults.workspace`).
|
- **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.
|
- **Workspace Files (injected)**: indicates bootstrap files are included below.
|
||||||
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
|
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
|
||||||
- **Current Date & Time**: user-local time, timezone, and time format.
|
- **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
|
`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
|
||||||
short missing-file marker.
|
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).
|
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
|
## Time handling
|
||||||
@ -95,3 +99,12 @@ Skills section is omitted.
|
|||||||
```
|
```
|
||||||
|
|
||||||
This keeps the base prompt small while still enabling targeted skill usage.
|
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).
|
||||||
|
|||||||
@ -12,7 +12,7 @@ read_when:
|
|||||||
|
|
||||||
## Where it shows up
|
## 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.
|
- `/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 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).
|
- 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).
|
- macOS menu bar: “Usage” section under Context (only if available).
|
||||||
|
|||||||
@ -77,6 +77,7 @@ What this does:
|
|||||||
- Seeds the workspace files if missing:
|
- Seeds the workspace files if missing:
|
||||||
`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`.
|
`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`.
|
||||||
- Default identity: **C3‑PO** (protocol droid).
|
- Default identity: **C3‑PO** (protocol droid).
|
||||||
|
- Skips channel providers in dev mode (`CLAWDBOT_SKIP_CHANNELS=1`).
|
||||||
|
|
||||||
Reset flow (fresh start):
|
Reset flow (fresh start):
|
||||||
|
|
||||||
|
|||||||
@ -657,6 +657,10 @@
|
|||||||
"source": "/templates/AGENTS",
|
"source": "/templates/AGENTS",
|
||||||
"destination": "/reference/templates/AGENTS"
|
"destination": "/reference/templates/AGENTS"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"source": "/templates/BOOT",
|
||||||
|
"destination": "/reference/templates/BOOT"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"source": "/templates/BOOTSTRAP",
|
"source": "/templates/BOOTSTRAP",
|
||||||
"destination": "/reference/templates/BOOTSTRAP"
|
"destination": "/reference/templates/BOOTSTRAP"
|
||||||
@ -822,8 +826,10 @@
|
|||||||
"cli/models",
|
"cli/models",
|
||||||
"cli/logs",
|
"cli/logs",
|
||||||
"cli/nodes",
|
"cli/nodes",
|
||||||
|
"cli/approvals",
|
||||||
"cli/gateway",
|
"cli/gateway",
|
||||||
"cli/daemon",
|
"cli/daemon",
|
||||||
|
"cli/service",
|
||||||
"cli/tui",
|
"cli/tui",
|
||||||
"cli/voicecall",
|
"cli/voicecall",
|
||||||
"cli/wake",
|
"cli/wake",
|
||||||
@ -956,6 +962,8 @@
|
|||||||
{
|
{
|
||||||
"group": "Automation & Hooks",
|
"group": "Automation & Hooks",
|
||||||
"pages": [
|
"pages": [
|
||||||
|
"hooks",
|
||||||
|
"hooks/soul-evil",
|
||||||
"automation/auth-monitoring",
|
"automation/auth-monitoring",
|
||||||
"automation/webhook",
|
"automation/webhook",
|
||||||
"automation/gmail-pubsub",
|
"automation/gmail-pubsub",
|
||||||
@ -1049,6 +1057,7 @@
|
|||||||
"reference/RELEASING",
|
"reference/RELEASING",
|
||||||
"reference/AGENTS.default",
|
"reference/AGENTS.default",
|
||||||
"reference/templates/AGENTS",
|
"reference/templates/AGENTS",
|
||||||
|
"reference/templates/BOOT",
|
||||||
"reference/templates/BOOTSTRAP",
|
"reference/templates/BOOTSTRAP",
|
||||||
"reference/templates/HEARTBEAT",
|
"reference/templates/HEARTBEAT",
|
||||||
"reference/templates/IDENTITY",
|
"reference/templates/IDENTITY",
|
||||||
|
|||||||
@ -46,8 +46,8 @@ When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
|
|||||||
## Frames
|
## Frames
|
||||||
|
|
||||||
Client → Gateway:
|
Client → Gateway:
|
||||||
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake)
|
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake, skills.bins)
|
||||||
- `event`: node signals (voice transcript, agent request, chat subscribe)
|
- `event`: node signals (voice transcript, agent request, chat subscribe, exec lifecycle)
|
||||||
|
|
||||||
Gateway → Client:
|
Gateway → Client:
|
||||||
- `invoke` / `invoke-res`: node commands (`canvas.*`, `camera.*`, `screen.record`,
|
- `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`.
|
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
|
## Tailnet usage
|
||||||
|
|
||||||
- Bind the bridge to a tailnet IP: `bridge.bind: "tailnet"` in
|
- Bind the bridge to a tailnet IP: `bridge.bind: "tailnet"` in
|
||||||
|
|||||||
@ -146,7 +146,11 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
|||||||
// Session behavior
|
// Session behavior
|
||||||
session: {
|
session: {
|
||||||
scope: "per-sender",
|
scope: "per-sender",
|
||||||
idleMinutes: 60,
|
reset: {
|
||||||
|
mode: "daily",
|
||||||
|
atHour: 4,
|
||||||
|
idleMinutes: 60
|
||||||
|
},
|
||||||
heartbeatIdleMinutes: 120,
|
heartbeatIdleMinutes: 120,
|
||||||
resetTriggers: ["/new", "/reset"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
store: "~/.clawdbot/agents/default/sessions/sessions.json",
|
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
|
ackMaxChars: 300
|
||||||
},
|
},
|
||||||
memorySearch: {
|
memorySearch: {
|
||||||
provider: "openai",
|
provider: "gemini",
|
||||||
model: "text-embedding-004",
|
model: "gemini-embedding-001",
|
||||||
remote: {
|
remote: {
|
||||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
|
||||||
apiKey: "${GEMINI_API_KEY}"
|
apiKey: "${GEMINI_API_KEY}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -678,10 +678,11 @@ Notes:
|
|||||||
- `"open"`: groups bypass allowlists; mention-gating still applies.
|
- `"open"`: groups bypass allowlists; mention-gating still applies.
|
||||||
- `"disabled"`: block all group/room messages.
|
- `"disabled"`: block all group/room messages.
|
||||||
- `"allowlist"`: only allow groups/rooms that match the configured allowlist.
|
- `"allowlist"`: only allow groups/rooms that match the configured allowlist.
|
||||||
|
- `channels.defaults.groupPolicy` sets the default when a provider’s `groupPolicy` is unset.
|
||||||
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams use `groupAllowFrom` (fallback: explicit `allowFrom`).
|
- WhatsApp/Telegram/Signal/iMessage/Microsoft Teams use `groupAllowFrom` (fallback: explicit `allowFrom`).
|
||||||
- Discord/Slack use channel allowlists (`channels.discord.guilds.*.channels`, `channels.slack.channels`).
|
- Discord/Slack use channel allowlists (`channels.discord.guilds.*.channels`, `channels.slack.channels`).
|
||||||
- Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`.
|
- Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`.
|
||||||
- Default is `groupPolicy: "allowlist"`; if no allowlist is configured, group messages are blocked.
|
- Default is `groupPolicy: "allowlist"` (unless overridden by `channels.defaults.groupPolicy`); if no allowlist is configured, group messages are blocked.
|
||||||
|
|
||||||
### Multi-agent routing (`agents.list` + `bindings`)
|
### Multi-agent routing (`agents.list` + `bindings`)
|
||||||
|
|
||||||
@ -2415,7 +2416,7 @@ Notes:
|
|||||||
|
|
||||||
### `session`
|
### `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
|
```json5
|
||||||
{
|
{
|
||||||
@ -2425,7 +2426,16 @@ Controls session scoping, idle expiry, reset triggers, and where the session sto
|
|||||||
identityLinks: {
|
identityLinks: {
|
||||||
alice: ["telegram:123456789", "discord:987654321012345678"]
|
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"],
|
resetTriggers: ["/new", "/reset"],
|
||||||
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
||||||
// You can override with {agentId} templating:
|
// You can override with {agentId} templating:
|
||||||
@ -2436,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).
|
// Max ping-pong reply turns between requester/target (0–5).
|
||||||
maxPingPongTurns: 5
|
maxPingPongTurns: 5
|
||||||
},
|
},
|
||||||
sendPolicy: {
|
sendPolicy: {
|
||||||
rules: [
|
rules: [
|
||||||
{ action: "deny", match: { channel: "discord", chatType: "group" } }
|
{ action: "deny", match: { channel: "discord", chatType: "group" } }
|
||||||
],
|
],
|
||||||
default: "allow"
|
default: "allow"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -2455,6 +2465,13 @@ Fields:
|
|||||||
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
|
- `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`.
|
- `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"]`.
|
- 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).
|
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
||||||
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
- `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.
|
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
||||||
|
|||||||
@ -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:
|
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).
|
- Requires node pairing (approval + token).
|
||||||
- Controlled on the Mac via **Settings → "Node Run Commands"**: "Always Ask" (default), "Always Allow", or "Never".
|
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
|
||||||
- If you don’t want remote execution, set the policy to "Never" and remove node pairing for that Mac.
|
- If you don’t want remote execution, set security to **deny** and remove node pairing for that Mac.
|
||||||
|
|
||||||
## Dynamic skills (watcher / remote nodes)
|
## Dynamic skills (watcher / remote nodes)
|
||||||
|
|
||||||
|
|||||||
@ -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/
|
ls -la ~/.clawdbot/agents/<agentId>/sessions/
|
||||||
```
|
```
|
||||||
|
|
||||||
**Check 2:** Is `idleMinutes` too short?
|
**Check 2:** Is the reset window too short?
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"session": {
|
"session": {
|
||||||
"idleMinutes": 10080 // 7 days
|
"reset": {
|
||||||
|
"mode": "daily",
|
||||||
|
"atHour": 4,
|
||||||
|
"idleMinutes": 10080 // 7 days
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -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.
|
- **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.
|
- **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:
|
Common uses:
|
||||||
- Save a memory snapshot when you reset a session
|
- Save a memory snapshot when you reset a session
|
||||||
@ -35,10 +37,12 @@ The hooks system allows you to:
|
|||||||
|
|
||||||
### Bundled Hooks
|
### 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`
|
- **💾 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`
|
- **📝 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:
|
List available hooks:
|
||||||
|
|
||||||
@ -192,7 +196,7 @@ Each event includes:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
type: 'command' | 'session' | 'agent',
|
type: 'command' | 'session' | 'agent' | 'gateway',
|
||||||
action: string, // e.g., 'new', 'reset', 'stop'
|
action: string, // e.g., 'new', 'reset', 'stop'
|
||||||
sessionKey: string, // Session identifier
|
sessionKey: string, // Session identifier
|
||||||
timestamp: Date, // When the event occurred
|
timestamp: Date, // When the event occurred
|
||||||
@ -203,6 +207,8 @@ Each event includes:
|
|||||||
sessionFile?: string,
|
sessionFile?: string,
|
||||||
commandSource?: string, // e.g., 'whatsapp', 'telegram'
|
commandSource?: string, // e.g., 'whatsapp', 'telegram'
|
||||||
senderId?: string,
|
senderId?: string,
|
||||||
|
workspaceDir?: string,
|
||||||
|
bootstrapFiles?: WorkspaceBootstrapFile[],
|
||||||
cfg?: ClawdbotConfig
|
cfg?: ClawdbotConfig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -219,6 +225,16 @@ Triggered when agent commands are issued:
|
|||||||
- **`command:reset`**: When `/reset` command is issued
|
- **`command:reset`**: When `/reset` command is issued
|
||||||
- **`command:stop`**: When `/stop` 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
|
### Future Events
|
||||||
|
|
||||||
Planned event types:
|
Planned event types:
|
||||||
@ -497,6 +513,62 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
|
|||||||
clawdbot hooks enable command-logger
|
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
|
## Best Practices
|
||||||
|
|
||||||
### Keep Handlers Fast
|
### Keep Handlers Fast
|
||||||
@ -569,6 +641,7 @@ The gateway logs hook loading at startup:
|
|||||||
```
|
```
|
||||||
Registered hook: session-memory -> command:new
|
Registered hook: session-memory -> command:new
|
||||||
Registered hook: command-logger -> command
|
Registered hook: command-logger -> command
|
||||||
|
Registered hook: boot-md -> gateway:startup
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Discovery
|
### Check Discovery
|
||||||
|
|||||||
68
docs/hooks/soul-evil.md
Normal file
68
docs/hooks/soul-evil.md
Normal 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)
|
||||||
@ -12,6 +12,7 @@ Clawdbot ships two installer scripts (served from `clawd.bot`):
|
|||||||
|
|
||||||
- `https://clawd.bot/install.sh` — “recommended” installer (global npm install by default; can also install from a GitHub checkout)
|
- `https://clawd.bot/install.sh` — “recommended” installer (global npm install by default; can also install from a GitHub checkout)
|
||||||
- `https://clawd.bot/install-cli.sh` — non-root-friendly CLI installer (installs into a prefix with its own Node)
|
- `https://clawd.bot/install-cli.sh` — non-root-friendly CLI installer (installs into a prefix with its own Node)
|
||||||
|
- `https://clawd.bot/install.ps1` — Windows PowerShell installer (npm by default; optional git install)
|
||||||
|
|
||||||
To see the current flags/behavior, run:
|
To see the current flags/behavior, run:
|
||||||
|
|
||||||
@ -19,6 +20,12 @@ To see the current flags/behavior, run:
|
|||||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --help
|
curl -fsSL https://clawd.bot/install.sh | bash -s -- --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Windows (PowerShell) help:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& ([scriptblock]::Create((iwr -useb https://clawd.bot/install.ps1))) -?
|
||||||
|
```
|
||||||
|
|
||||||
If the installer completes but `clawdbot` is not found in a new terminal, it’s usually a Node/npm PATH issue. See: [Install](/install#nodejs--npm-path-sanity).
|
If the installer completes but `clawdbot` is not found in a new terminal, it’s usually a Node/npm PATH issue. See: [Install](/install#nodejs--npm-path-sanity).
|
||||||
|
|
||||||
## install.sh (recommended)
|
## install.sh (recommended)
|
||||||
@ -73,3 +80,37 @@ Help:
|
|||||||
```bash
|
```bash
|
||||||
curl -fsSL https://clawd.bot/install-cli.sh | bash -s -- --help
|
curl -fsSL https://clawd.bot/install-cli.sh | bash -s -- --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## install.ps1 (Windows PowerShell)
|
||||||
|
|
||||||
|
What it does (high level):
|
||||||
|
|
||||||
|
- Ensure Node.js **22+** (winget/Chocolatey/Scoop or manual).
|
||||||
|
- Choose install method:
|
||||||
|
- `npm` (default): `npm install -g clawdbot@latest`
|
||||||
|
- `git`: clone/build a source checkout and install a wrapper script
|
||||||
|
- Runs `clawdbot doctor --non-interactive` on upgrades and git installs (best effort).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
iwr -useb https://clawd.bot/install.ps1 | iex
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
iwr -useb https://clawd.bot/install.ps1 | iex -InstallMethod git
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
iwr -useb https://clawd.bot/install.ps1 | iex -InstallMethod git -GitDir "C:\\clawdbot"
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
|
||||||
|
- `CLAWDBOT_INSTALL_METHOD=git|npm`
|
||||||
|
- `CLAWDBOT_GIT_DIR=...`
|
||||||
|
|
||||||
|
Git requirement:
|
||||||
|
|
||||||
|
If you choose `-InstallMethod git` and Git is missing, the installer will print the
|
||||||
|
Git for Windows link (`https://git-scm.com/download/win`) and exit.
|
||||||
|
|||||||
@ -147,9 +147,10 @@ Notes:
|
|||||||
- The permission prompt must be accepted on the Android device before the capability is advertised.
|
- 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`.
|
- 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:
|
Examples:
|
||||||
|
|
||||||
@ -163,12 +164,58 @@ Notes:
|
|||||||
- `system.notify` respects notification permission state on the macOS app.
|
- `system.notify` respects notification permission state on the macOS app.
|
||||||
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
|
- `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.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
|
## 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).
|
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
|
## Mac node mode
|
||||||
|
|
||||||
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
|
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
|
||||||
|
|||||||
@ -104,6 +104,29 @@ Rules:
|
|||||||
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
||||||
**active reply model** when its provider supports the capability.
|
**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)
|
## Capabilities (optional)
|
||||||
If you set `capabilities`, the entry only runs for those media types. For shared
|
If you set `capabilities`, the entry only runs for those media types. For shared
|
||||||
lists, Clawdbot can infer defaults:
|
lists, Clawdbot can infer defaults:
|
||||||
|
|||||||
76
docs/perplexity.md
Normal file
76
docs/perplexity.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
summary: "Perplexity Sonar setup for web_search"
|
||||||
|
read_when:
|
||||||
|
- You want to use Perplexity Sonar for web search
|
||||||
|
- You need PERPLEXITY_API_KEY or OpenRouter setup
|
||||||
|
---
|
||||||
|
|
||||||
|
# Perplexity Sonar
|
||||||
|
|
||||||
|
Clawdbot can use Perplexity Sonar for the `web_search` tool. You can connect
|
||||||
|
through Perplexity’s direct API or via OpenRouter.
|
||||||
|
|
||||||
|
## API options
|
||||||
|
|
||||||
|
### Perplexity (direct)
|
||||||
|
|
||||||
|
- Base URL: https://api.perplexity.ai
|
||||||
|
- Environment variable: `PERPLEXITY_API_KEY`
|
||||||
|
|
||||||
|
### OpenRouter (alternative)
|
||||||
|
|
||||||
|
- Base URL: https://openrouter.ai/api/v1
|
||||||
|
- Environment variable: `OPENROUTER_API_KEY`
|
||||||
|
- Supports prepaid/crypto credits.
|
||||||
|
|
||||||
|
## Config example
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
tools: {
|
||||||
|
web: {
|
||||||
|
search: {
|
||||||
|
provider: "perplexity",
|
||||||
|
perplexity: {
|
||||||
|
apiKey: "pplx-...",
|
||||||
|
baseUrl: "https://api.perplexity.ai",
|
||||||
|
model: "perplexity/sonar-pro"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Switching from Brave
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
tools: {
|
||||||
|
web: {
|
||||||
|
search: {
|
||||||
|
provider: "perplexity",
|
||||||
|
perplexity: {
|
||||||
|
apiKey: "pplx-...",
|
||||||
|
baseUrl: "https://api.perplexity.ai"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If both `PERPLEXITY_API_KEY` and `OPENROUTER_API_KEY` are set, set
|
||||||
|
`tools.web.search.perplexity.baseUrl` (or `tools.web.search.perplexity.apiKey`)
|
||||||
|
to disambiguate.
|
||||||
|
|
||||||
|
If `PERPLEXITY_API_KEY` is used from the environment and no base URL is set,
|
||||||
|
Clawdbot defaults to the direct Perplexity endpoint. Set `baseUrl` to override.
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
- `perplexity/sonar` — fast Q&A with web search
|
||||||
|
- `perplexity/sonar-pro` (default) — multi-step reasoning + web search
|
||||||
|
- `perplexity/sonar-reasoning-pro` — deep research
|
||||||
|
|
||||||
|
See [Web tools](/tools/web) for the full web_search configuration.
|
||||||
@ -9,8 +9,9 @@ read_when:
|
|||||||
# Gateway on macOS (external launchd)
|
# Gateway on macOS (external launchd)
|
||||||
|
|
||||||
Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app
|
Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app
|
||||||
expects an **external** `clawdbot` CLI install and manages a per‑user launchd
|
expects an **external** `clawdbot` CLI install, does not spawn the Gateway as a
|
||||||
service to keep the Gateway running.
|
child process, and manages a per‑user launchd service to keep the Gateway
|
||||||
|
running (or attaches to an existing local Gateway if one is already running).
|
||||||
|
|
||||||
## Install the CLI (required for local mode)
|
## Install the CLI (required for local mode)
|
||||||
|
|
||||||
@ -38,6 +39,8 @@ Manager:
|
|||||||
Behavior:
|
Behavior:
|
||||||
- “Clawdbot Active” enables/disables the LaunchAgent.
|
- “Clawdbot Active” enables/disables the LaunchAgent.
|
||||||
- App quit does **not** stop the gateway (launchd keeps it alive).
|
- App quit does **not** stop the gateway (launchd keeps it alive).
|
||||||
|
- If a Gateway is already running on the configured port, the app attaches to
|
||||||
|
it instead of starting a new one.
|
||||||
|
|
||||||
Logging:
|
Logging:
|
||||||
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
|
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
|
||||||
|
|||||||
@ -5,9 +5,11 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Gateway lifecycle on macOS
|
# Gateway lifecycle on macOS
|
||||||
|
|
||||||
The macOS app **manages the Gateway via launchd** by default. The launchd job
|
The macOS app **manages the Gateway via launchd** by default and does not spawn
|
||||||
uses the external `clawdbot` CLI (no embedded runtime). This gives you reliable
|
the Gateway as a child process. It first tries to attach to an already‑running
|
||||||
auto‑start at login and restart on crashes.
|
Gateway on the configured port; if none is reachable, it enables the launchd
|
||||||
|
service via the external `clawdbot` CLI (no embedded runtime). This gives you
|
||||||
|
reliable auto‑start at login and restart on crashes.
|
||||||
|
|
||||||
Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
|
Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
|
||||||
If you need tighter coupling to the UI, run the Gateway manually in a terminal.
|
If you need tighter coupling to the UI, run the Gateway manually in a terminal.
|
||||||
|
|||||||
@ -14,15 +14,7 @@ Before building the app, ensure you have the following installed:
|
|||||||
1. **Xcode 26.2+**: Required for Swift development.
|
1. **Xcode 26.2+**: Required for Swift development.
|
||||||
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
|
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
|
||||||
|
|
||||||
## 1. Initialize Submodules
|
## 1. Install Dependencies
|
||||||
|
|
||||||
Clawdbot depends on several submodules (like `Peekaboo`). You must initialize these recursively:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git submodule update --init --recursive
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Install Dependencies
|
|
||||||
|
|
||||||
Install the project-wide dependencies:
|
Install the project-wide dependencies:
|
||||||
|
|
||||||
@ -30,7 +22,7 @@ Install the project-wide dependencies:
|
|||||||
pnpm install
|
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:
|
To build the macOS app and package it into `dist/Clawdbot.app`, run:
|
||||||
|
|
||||||
@ -40,9 +32,12 @@ To build the macOS app and package it into `dist/Clawdbot.app`, run:
|
|||||||
|
|
||||||
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
|
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
|
||||||
|
|
||||||
|
For dev run modes, signing flags, and Team ID troubleshooting, see the macOS app README:
|
||||||
|
https://github.com/clawdbot/clawdbot/blob/main/apps/macos/README.md
|
||||||
|
|
||||||
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
> **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.
|
The macOS app expects a global `clawdbot` CLI install to manage background tasks.
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
summary: "PeekabooBridge integration for macOS UI automation"
|
summary: "PeekabooBridge integration for macOS UI automation"
|
||||||
read_when:
|
read_when:
|
||||||
- Hosting PeekabooBridge in Clawdbot.app
|
- Hosting PeekabooBridge in Clawdbot.app
|
||||||
- Integrating Peekaboo as a submodule
|
- Integrating Peekaboo via Swift Package Manager
|
||||||
- Changing PeekabooBridge protocol/paths
|
- Changing PeekabooBridge protocol/paths
|
||||||
---
|
---
|
||||||
# Peekaboo Bridge (macOS UI automation)
|
# Peekaboo Bridge (macOS UI automation)
|
||||||
|
|||||||
@ -14,6 +14,7 @@ This app is usually built from [`scripts/package-mac-app.sh`](https://github.com
|
|||||||
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
|
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
|
||||||
- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build.
|
- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build.
|
||||||
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
|
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
|
||||||
|
- runs a Team ID audit after signing and fails if any Mach-O inside the app bundle is signed by a different Team ID. Set `SKIP_TEAM_ID_CHECK=1` to bypass.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@ -23,6 +24,7 @@ scripts/package-mac-app.sh # auto-selects identity; errors if none
|
|||||||
SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert
|
SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert
|
||||||
ALLOW_ADHOC_SIGNING=1 scripts/package-mac-app.sh # ad-hoc (permissions will not stick)
|
ALLOW_ADHOC_SIGNING=1 scripts/package-mac-app.sh # ad-hoc (permissions will not stick)
|
||||||
SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same caveat)
|
SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same caveat)
|
||||||
|
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh # dev-only Sparkle Team ID mismatch workaround
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ad-hoc Signing Note
|
### Ad-hoc Signing Note
|
||||||
|
|||||||
@ -7,6 +7,8 @@ read_when:
|
|||||||
|
|
||||||
**Current model:** there is **no local control socket** and no `clawdbot-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge.
|
**Current model:** there is **no local control socket** and no `clawdbot-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge.
|
||||||
|
|
||||||
|
**Planned model:** add a local Unix socket between the **node service** and the **macOS app**. The app owns `system.run` (UI/TCC context); the node service forwards exec requests over IPC.
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
||||||
- A small surface for automation: Gateway + node commands, plus PeekabooBridge for UI automation.
|
- A small surface for automation: Gateway + node commands, plus PeekabooBridge for UI automation.
|
||||||
@ -17,6 +19,19 @@ read_when:
|
|||||||
- The app runs the Gateway (local mode) and connects to it as a node.
|
- The app runs the Gateway (local mode) and connects to it as a node.
|
||||||
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
||||||
|
|
||||||
|
### Node service + app IPC (planned)
|
||||||
|
- A headless node service connects to the Gateway bridge.
|
||||||
|
- `system.run` requests are forwarded to the macOS app over a local Unix socket.
|
||||||
|
- The app performs the exec in UI context, prompts if needed, and returns output.
|
||||||
|
|
||||||
|
Diagram (SCI):
|
||||||
|
```
|
||||||
|
Agent -> Gateway -> Bridge -> Node Service (TS)
|
||||||
|
| IPC (UDS + token + HMAC + TTL)
|
||||||
|
v
|
||||||
|
Mac App (UI + TCC + system.run)
|
||||||
|
```
|
||||||
|
|
||||||
### PeekabooBridge (UI automation)
|
### PeekabooBridge (UI automation)
|
||||||
- UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol.
|
- UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol.
|
||||||
- Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution.
|
- Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution.
|
||||||
@ -25,6 +40,7 @@ read_when:
|
|||||||
|
|
||||||
### Mach/XPC
|
### Mach/XPC
|
||||||
- Not required for automation; `node.invoke` + PeekabooBridge cover current needs.
|
- Not required for automation; `node.invoke` + PeekabooBridge cover current needs.
|
||||||
|
- Planned IPC keeps Unix sockets (no XPC helper).
|
||||||
|
|
||||||
## Operational flows
|
## Operational flows
|
||||||
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh`
|
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh`
|
||||||
@ -38,3 +54,4 @@ read_when:
|
|||||||
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
|
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
|
||||||
- All communication remains local-only; no network sockets are exposed.
|
- All communication remains local-only; no network sockets are exposed.
|
||||||
- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
|
- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
|
||||||
|
- Planned IPC hardening: socket mode `0600`, token, peer-UID checks, HMAC challenge/response, short TTL.
|
||||||
|
|||||||
@ -7,8 +7,8 @@ read_when:
|
|||||||
# Clawdbot macOS Companion (menu bar + gateway broker)
|
# Clawdbot macOS Companion (menu bar + gateway broker)
|
||||||
|
|
||||||
The macOS app is the **menu‑bar companion** for Clawdbot. It owns permissions,
|
The macOS app is the **menu‑bar companion** for Clawdbot. It owns permissions,
|
||||||
manages the Gateway locally, and exposes macOS capabilities to the agent as a
|
manages/attaches to the Gateway locally (launchd or manual), and exposes macOS
|
||||||
node.
|
capabilities to the agent as a node.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
@ -20,13 +20,17 @@ node.
|
|||||||
- Optionally hosts **PeekabooBridge** for UI automation.
|
- Optionally hosts **PeekabooBridge** for UI automation.
|
||||||
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime).
|
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime).
|
||||||
|
|
||||||
|
Planned:
|
||||||
|
- Run a headless **node service** locally (launchd).
|
||||||
|
- Keep `system.run` in the app (UI/TCC context), with the node service forwarding via IPC.
|
||||||
|
|
||||||
## Local vs remote mode
|
## Local vs remote mode
|
||||||
|
|
||||||
- **Local** (default): the app ensures a local Gateway is running via launchd.
|
- **Local** (default): the app attaches to a running local Gateway if present;
|
||||||
|
otherwise it enables the launchd service via `clawdbot daemon`.
|
||||||
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
||||||
a local process.
|
a local process.
|
||||||
- **Attach‑only** (debug): the app connects to an already‑running local Gateway
|
The app does not spawn the Gateway as a child process.
|
||||||
and never spawns its own.
|
|
||||||
|
|
||||||
## Launchd control
|
## Launchd control
|
||||||
|
|
||||||
@ -54,29 +58,44 @@ The macOS app presents itself as a node. Common commands:
|
|||||||
|
|
||||||
The node reports a `permissions` map so agents can decide what’s allowed.
|
The node reports a `permissions` map so agents can decide what’s allowed.
|
||||||
|
|
||||||
## Node run policy + allowlist
|
Planned split:
|
||||||
|
- Node service advertises the node surface to the Gateway.
|
||||||
`system.run` is controlled by the macOS app **Node Run Commands** policy:
|
- macOS app performs `system.run` in UI context over IPC.
|
||||||
|
|
||||||
- `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:
|
|
||||||
|
|
||||||
|
Diagram (SCI):
|
||||||
```
|
```
|
||||||
~/.clawdbot/macos-node.json
|
Gateway -> Bridge -> Node Service (TS)
|
||||||
|
| IPC (UDS + token + HMAC + TTL)
|
||||||
|
v
|
||||||
|
Mac App (UI + TCC + system.run)
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema:
|
## Exec approvals (system.run)
|
||||||
|
|
||||||
|
`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/exec-approvals.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"systemRun": {
|
"version": 1,
|
||||||
"policy": "ask",
|
"defaults": {
|
||||||
"allowlist": [
|
"security": "deny",
|
||||||
"[\"/bin/echo\",\"hello\"]"
|
"ask": "on-miss"
|
||||||
]
|
},
|
||||||
|
"agents": {
|
||||||
|
"main": {
|
||||||
|
"security": "allowlist",
|
||||||
|
"ask": "on-miss",
|
||||||
|
"allowlist": [
|
||||||
|
{ "pattern": "/opt/homebrew/bin/rg" }
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@ -36,6 +36,8 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
|||||||
## Available plugins (official)
|
## Available plugins (official)
|
||||||
|
|
||||||
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
- 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`
|
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||||
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||||
@ -43,7 +45,8 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
|||||||
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
|
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
|
||||||
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
- 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)
|
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
|
||||||
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
|
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
||||||
|
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
|
||||||
|
|
||||||
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
||||||
register:
|
register:
|
||||||
@ -56,6 +59,7 @@ register:
|
|||||||
- Optional config validation
|
- Optional config validation
|
||||||
|
|
||||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||||
|
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||||
|
|
||||||
## Discovery & precedence
|
## Discovery & precedence
|
||||||
|
|
||||||
@ -136,6 +140,24 @@ Fields:
|
|||||||
|
|
||||||
Config changes **require a gateway restart**.
|
Config changes **require a gateway restart**.
|
||||||
|
|
||||||
|
## Plugin slots (exclusive categories)
|
||||||
|
|
||||||
|
Some plugin categories are **exclusive** (only one active at a time). Use
|
||||||
|
`plugins.slots` to select which plugin owns the slot:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
slots: {
|
||||||
|
memory: "memory-core" // or "none" to disable memory plugins
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If multiple plugins declare `kind: "memory"`, only the selected one loads. Others
|
||||||
|
are disabled with diagnostics.
|
||||||
|
|
||||||
## Control UI (schema + labels)
|
## Control UI (schema + labels)
|
||||||
|
|
||||||
The Control UI uses `config.schema` (JSON Schema + `uiHints`) to render better forms.
|
The Control UI uses `config.schema` (JSON Schema + `uiHints`) to render better forms.
|
||||||
@ -194,6 +216,27 @@ Plugins export either:
|
|||||||
- A function: `(api) => { ... }`
|
- A function: `(api) => { ... }`
|
||||||
- An object: `{ id, name, configSchema, register(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)
|
## Provider plugins (model auth)
|
||||||
|
|
||||||
Plugins can register **model provider auth** flows so users can run OAuth or
|
Plugins can register **model provider auth** flows so users can run OAuth or
|
||||||
@ -359,24 +402,9 @@ export default function (api) {
|
|||||||
Load the plugin (extensions dir or `plugins.load.paths`), restart the gateway,
|
Load the plugin (extensions dir or `plugins.load.paths`), restart the gateway,
|
||||||
then configure `channels.<id>` in your config.
|
then configure `channels.<id>` in your config.
|
||||||
|
|
||||||
### Register a tool
|
### Agent tools
|
||||||
|
|
||||||
```ts
|
See the dedicated guide: [Plugin agent tools](/plugins/agent-tools).
|
||||||
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 }] };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Register a gateway RPC method
|
### Register a gateway RPC method
|
||||||
|
|
||||||
|
|||||||
94
docs/plugins/agent-tools.md
Normal file
94
docs/plugins/agent-tools.md
Normal 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.
|
||||||
70
docs/providers/github-copilot.md
Normal file
70
docs/providers/github-copilot.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
summary: "Sign in to GitHub Copilot from Clawdbot using the device flow"
|
||||||
|
read_when:
|
||||||
|
- You want to use GitHub Copilot as a model provider
|
||||||
|
- You need the `clawdbot models auth login-github-copilot` flow
|
||||||
|
---
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
## CLI setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot models auth login-github-copilot
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
|
||||||
|
open until it completes.
|
||||||
|
|
||||||
|
### Optional flags
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot models auth login-github-copilot --profile-id github-copilot:work
|
||||||
|
clawdbot models auth login-github-copilot --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Set a default model
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot models set github-copilot/gpt-4o
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config snippet
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
agents: { defaults: { model: { primary: "github-copilot/gpt-4o" } } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Requires an interactive TTY; run it directly in a terminal.
|
||||||
|
- Copilot model availability depends on your plan; if a model is rejected, try
|
||||||
|
another ID (for example `github-copilot/gpt-4.1`).
|
||||||
|
- The login stores a GitHub token in the auth profile store and exchanges it for a
|
||||||
|
Copilot API token when Clawdbot runs.
|
||||||
@ -26,6 +26,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
|
|||||||
|
|
||||||
- [OpenAI (API + Codex)](/providers/openai)
|
- [OpenAI (API + Codex)](/providers/openai)
|
||||||
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||||
|
- [Qwen (OAuth)](/providers/qwen)
|
||||||
- [OpenRouter](/providers/openrouter)
|
- [OpenRouter](/providers/openrouter)
|
||||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||||
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
|
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
|
||||||
|
|||||||
51
docs/providers/qwen.md
Normal file
51
docs/providers/qwen.md
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
summary: "Use Qwen OAuth (free tier) in Clawdbot"
|
||||||
|
read_when:
|
||||||
|
- You want to use Qwen with Clawdbot
|
||||||
|
- You want free-tier OAuth access to Qwen Coder
|
||||||
|
---
|
||||||
|
# Qwen
|
||||||
|
|
||||||
|
Qwen provides a free-tier OAuth flow for Qwen Coder and Qwen Vision models
|
||||||
|
(2,000 requests/day, subject to Qwen rate limits).
|
||||||
|
|
||||||
|
## Enable the plugin
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot plugins enable qwen-portal-auth
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the Gateway after enabling.
|
||||||
|
|
||||||
|
## Authenticate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot models auth login --provider qwen-portal --set-default
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs the Qwen device-code OAuth flow and writes a provider entry to your
|
||||||
|
`models.json` (plus a `qwen` alias for quick switching).
|
||||||
|
|
||||||
|
## Model IDs
|
||||||
|
|
||||||
|
- `qwen-portal/coder-model`
|
||||||
|
- `qwen-portal/vision-model`
|
||||||
|
|
||||||
|
Switch models with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot models set qwen-portal/coder-model
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reuse Qwen Code CLI login
|
||||||
|
|
||||||
|
If you already logged in with the Qwen Code CLI, Clawdbot will sync credentials
|
||||||
|
from `~/.qwen/oauth_creds.json` when it loads the auth store. You still need a
|
||||||
|
`models.providers.qwen-portal` entry (use the login command above to create one).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tokens auto-refresh; re-run the login command if refresh fails or access is revoked.
|
||||||
|
- Default base URL: `https://portal.qwen.ai/v1` (override with
|
||||||
|
`models.providers.qwen-portal.baseUrl` if Qwen provides a different endpoint).
|
||||||
|
- See [Model providers](/concepts/model-providers) for provider-wide rules.
|
||||||
265
docs/refactor/exec-host.md
Normal file
265
docs/refactor/exec-host.md
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
---
|
||||||
|
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).
|
||||||
|
- **macOS exec host:** run `system.run` inside the macOS app; node service forwards requests over local IPC.
|
||||||
|
- **No XPC helper:** stick to Unix socket + token + peer checks.
|
||||||
|
|
||||||
|
## Key concepts
|
||||||
|
### Host
|
||||||
|
- `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` (0600).
|
||||||
|
- Token stored in `exec-approvals.json` (0600).
|
||||||
|
- Peer checks: same-UID only.
|
||||||
|
- Challenge/response: nonce + HMAC(token, request-hash) to prevent replay.
|
||||||
|
- Short TTL (e.g., 10s) + max payload + rate limit.
|
||||||
|
|
||||||
|
### Ask flow (macOS app exec host)
|
||||||
|
1) Node service receives `system.run` from gateway.
|
||||||
|
2) Node service connects to the local socket and sends the prompt/exec request.
|
||||||
|
3) App validates peer + token + HMAC + TTL, then shows dialog if needed.
|
||||||
|
4) App executes the command in UI context and returns output.
|
||||||
|
5) Node service returns output to gateway.
|
||||||
|
|
||||||
|
If UI missing:
|
||||||
|
- Apply `askFallback` (`deny|allowlist|full`).
|
||||||
|
|
||||||
|
### Diagram (SCI)
|
||||||
|
```
|
||||||
|
Agent -> Gateway -> Bridge -> Node Service (TS)
|
||||||
|
| IPC (UDS + token + HMAC + TTL)
|
||||||
|
v
|
||||||
|
Mac App (UI + TCC + system.run)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Node identity + binding
|
||||||
|
- Use existing `nodeId` from Bridge pairing.
|
||||||
|
- Binding model:
|
||||||
|
- `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)
|
||||||
187
docs/refactor/plugin-sdk.md
Normal file
187
docs/refactor/plugin-sdk.md
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
---
|
||||||
|
summary: "Plan: one clean plugin SDK + runtime for all messaging connectors"
|
||||||
|
read_when:
|
||||||
|
- Defining or refactoring the plugin architecture
|
||||||
|
- Migrating channel connectors to the plugin SDK/runtime
|
||||||
|
---
|
||||||
|
# Plugin SDK + Runtime Refactor Plan
|
||||||
|
|
||||||
|
Goal: every messaging connector is a plugin (bundled or external) using one stable API.
|
||||||
|
No plugin imports from `src/**` directly. All dependencies go through the SDK or runtime.
|
||||||
|
|
||||||
|
## Why now
|
||||||
|
- Current connectors mix patterns: direct core imports, dist-only bridges, and custom helpers.
|
||||||
|
- This makes upgrades brittle and blocks a clean external plugin surface.
|
||||||
|
|
||||||
|
## Target architecture (two layers)
|
||||||
|
|
||||||
|
### 1) Plugin SDK (compile-time, stable, publishable)
|
||||||
|
Scope: types, helpers, and config utilities. No runtime state, no side effects.
|
||||||
|
|
||||||
|
Contents (examples):
|
||||||
|
- Types: `ChannelPlugin`, adapters, `ChannelMeta`, `ChannelCapabilities`, `ChannelDirectoryEntry`.
|
||||||
|
- Config helpers: `buildChannelConfigSchema`, `setAccountEnabledInConfigSection`, `deleteAccountFromConfigSection`,
|
||||||
|
`applyAccountNameToChannelSection`.
|
||||||
|
- Pairing helpers: `PAIRING_APPROVED_MESSAGE`, `formatPairingApproveHint`.
|
||||||
|
- Onboarding helpers: `promptChannelAccessConfig`, `addWildcardAllowFrom`, onboarding types.
|
||||||
|
- Tool param helpers: `createActionGate`, `readStringParam`, `readNumberParam`, `readReactionParams`, `jsonResult`.
|
||||||
|
- Docs link helper: `formatDocsLink`.
|
||||||
|
|
||||||
|
Delivery:
|
||||||
|
- Publish as `@clawdbot/plugin-sdk` (or export from core under `clawdbot/plugin-sdk`).
|
||||||
|
- Semver with explicit stability guarantees.
|
||||||
|
|
||||||
|
### 2) Plugin Runtime (execution surface, injected)
|
||||||
|
Scope: everything that touches core runtime behavior.
|
||||||
|
Accessed via `ClawdbotPluginApi.runtime` so plugins never import `src/**`.
|
||||||
|
|
||||||
|
Proposed surface (minimal but complete):
|
||||||
|
```ts
|
||||||
|
export type PluginRuntime = {
|
||||||
|
channel: {
|
||||||
|
text: {
|
||||||
|
chunkMarkdownText(text: string, limit: number): string[];
|
||||||
|
resolveTextChunkLimit(cfg: ClawdbotConfig, channel: string, accountId?: string): number;
|
||||||
|
hasControlCommand(text: string, cfg: ClawdbotConfig): boolean;
|
||||||
|
};
|
||||||
|
reply: {
|
||||||
|
dispatchReplyWithBufferedBlockDispatcher(params: {
|
||||||
|
ctx: unknown;
|
||||||
|
cfg: unknown;
|
||||||
|
dispatcherOptions: {
|
||||||
|
deliver: (payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }) =>
|
||||||
|
void | Promise<void>;
|
||||||
|
onError?: (err: unknown, info: { kind: string }) => void;
|
||||||
|
};
|
||||||
|
}): Promise<void>;
|
||||||
|
createReplyDispatcherWithTyping?: unknown; // adapter for Teams-style flows
|
||||||
|
};
|
||||||
|
routing: {
|
||||||
|
resolveAgentRoute(params: {
|
||||||
|
cfg: unknown;
|
||||||
|
channel: string;
|
||||||
|
accountId: string;
|
||||||
|
peer: { kind: "dm" | "group" | "channel"; id: string };
|
||||||
|
}): { sessionKey: string; accountId: string };
|
||||||
|
};
|
||||||
|
pairing: {
|
||||||
|
buildPairingReply(params: { channel: string; idLine: string; code: string }): string;
|
||||||
|
readAllowFromStore(channel: string): Promise<string[]>;
|
||||||
|
upsertPairingRequest(params: {
|
||||||
|
channel: string;
|
||||||
|
id: string;
|
||||||
|
meta?: { name?: string };
|
||||||
|
}): Promise<{ code: string; created: boolean }>;
|
||||||
|
};
|
||||||
|
media: {
|
||||||
|
fetchRemoteMedia(params: { url: string }): Promise<{ buffer: Buffer; contentType?: string }>;
|
||||||
|
saveMediaBuffer(
|
||||||
|
buffer: Uint8Array,
|
||||||
|
contentType: string | undefined,
|
||||||
|
direction: "inbound" | "outbound",
|
||||||
|
maxBytes: number,
|
||||||
|
): Promise<{ path: string; contentType?: string }>;
|
||||||
|
};
|
||||||
|
mentions: {
|
||||||
|
buildMentionRegexes(cfg: ClawdbotConfig, agentId?: string): RegExp[];
|
||||||
|
matchesMentionPatterns(text: string, regexes: RegExp[]): boolean;
|
||||||
|
};
|
||||||
|
groups: {
|
||||||
|
resolveGroupPolicy(cfg: ClawdbotConfig, channel: string, accountId: string, groupId: string): {
|
||||||
|
allowlistEnabled: boolean;
|
||||||
|
allowed: boolean;
|
||||||
|
groupConfig?: unknown;
|
||||||
|
defaultConfig?: unknown;
|
||||||
|
};
|
||||||
|
resolveRequireMention(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
channel: string,
|
||||||
|
accountId: string,
|
||||||
|
groupId: string,
|
||||||
|
override?: boolean,
|
||||||
|
): boolean;
|
||||||
|
};
|
||||||
|
debounce: {
|
||||||
|
createInboundDebouncer<T>(opts: {
|
||||||
|
debounceMs: number;
|
||||||
|
buildKey: (v: T) => string | null;
|
||||||
|
shouldDebounce: (v: T) => boolean;
|
||||||
|
onFlush: (entries: T[]) => Promise<void>;
|
||||||
|
onError?: (err: unknown) => void;
|
||||||
|
}): { push: (v: T) => void; flush: () => Promise<void> };
|
||||||
|
resolveInboundDebounceMs(cfg: ClawdbotConfig, channel: string): number;
|
||||||
|
};
|
||||||
|
commands: {
|
||||||
|
resolveCommandAuthorizedFromAuthorizers(params: {
|
||||||
|
useAccessGroups: boolean;
|
||||||
|
authorizers: Array<{ configured: boolean; allowed: boolean }>;
|
||||||
|
}): boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
logging: {
|
||||||
|
shouldLogVerbose(): boolean;
|
||||||
|
getChildLogger(name: string): PluginLogger;
|
||||||
|
};
|
||||||
|
state: {
|
||||||
|
resolveStateDir(cfg: ClawdbotConfig): string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Runtime is the only way to access core behavior.
|
||||||
|
- SDK is intentionally small and stable.
|
||||||
|
- Each runtime method maps to an existing core implementation (no duplication).
|
||||||
|
|
||||||
|
## Migration plan (phased, safe)
|
||||||
|
|
||||||
|
### Phase 0: scaffolding
|
||||||
|
- Introduce `@clawdbot/plugin-sdk`.
|
||||||
|
- Add `api.runtime` to `ClawdbotPluginApi` with the surface above.
|
||||||
|
- Maintain existing imports during a transition window (deprecation warnings).
|
||||||
|
|
||||||
|
### Phase 1: bridge cleanup (low risk)
|
||||||
|
- Replace per-extension `core-bridge.ts` with `api.runtime`.
|
||||||
|
- Migrate BlueBubbles, Zalo, Zalo Personal first (already close).
|
||||||
|
- Remove duplicated bridge code.
|
||||||
|
|
||||||
|
### Phase 2: light direct-import plugins
|
||||||
|
- Migrate Matrix to SDK + runtime.
|
||||||
|
- Validate onboarding, directory, group mention logic.
|
||||||
|
|
||||||
|
### Phase 3: heavy direct-import plugins
|
||||||
|
- Migrate MS Teams (largest set of runtime helpers).
|
||||||
|
- Ensure reply/typing semantics match current behavior.
|
||||||
|
|
||||||
|
### Phase 4: iMessage pluginization
|
||||||
|
- Move iMessage into `extensions/imessage`.
|
||||||
|
- Replace direct core calls with `api.runtime`.
|
||||||
|
- Keep config keys, CLI behavior, and docs intact.
|
||||||
|
|
||||||
|
### Phase 5: enforcement
|
||||||
|
- Add lint rule / CI check: no `extensions/**` imports from `src/**`.
|
||||||
|
- Add plugin SDK/version compatibility checks (runtime + SDK semver).
|
||||||
|
|
||||||
|
## Compatibility and versioning
|
||||||
|
- SDK: semver, published, documented changes.
|
||||||
|
- Runtime: versioned per core release. Add `api.runtime.version`.
|
||||||
|
- Plugins declare a required runtime range (e.g., `clawdbotRuntime: ">=2026.2.0"`).
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
- Adapter-level unit tests (runtime functions exercised with real core implementation).
|
||||||
|
- Golden tests per plugin: ensure no behavior drift (routing, pairing, allowlist, mention gating).
|
||||||
|
- A single end-to-end plugin sample used in CI (install + run + smoke).
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
- Where to host SDK types: separate package or core export?
|
||||||
|
- Runtime type distribution: in SDK (types only) or in core?
|
||||||
|
- How to expose docs links for bundled vs external plugins?
|
||||||
|
- Do we allow limited direct core imports for in-repo plugins during transition?
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
- All channel connectors are plugins using SDK + runtime.
|
||||||
|
- No `extensions/**` imports from `src/**`.
|
||||||
|
- New connector templates depend only on SDK + runtime.
|
||||||
|
- External plugins can be developed and updated without core source access.
|
||||||
|
|
||||||
|
Related docs: [Plugins](/plugin), [Channels](/channels/index), [Configuration](/gateway/configuration).
|
||||||
116
docs/reference/api-usage-costs.md
Normal file
116
docs/reference/api-usage-costs.md
Normal 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:
|
Rules of thumb:
|
||||||
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
|
- **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`.
|
Implementation detail: the decision happens in `initSessionState()` in `src/auto-reply/reply/session.ts`.
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user