diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 096e18c88..000000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "Peekaboo"] - path = Peekaboo - url = https://github.com/steipete/Peekaboo.git - branch = main diff --git a/AGENTS.md b/AGENTS.md index dbc2f5fa8..ed08c1bb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Repository Guidelines - Repo: https://github.com/clawdbot/clawdbot -- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`. +- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n". ## Project Structure & Module Organization - Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`). @@ -84,6 +84,7 @@ - When answering questions, respond with high-confidence answers only: verify in code; do not guess. - Never update the Carbon dependency. - Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`). +- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default. - CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars. - Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes. - Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.** diff --git a/CHANGELOG.md b/CHANGELOG.md index 68540eb61..cad0cf0fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,152 @@ 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 ### 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 @@ -32,6 +173,7 @@ Docs: https://docs.clawd.bot ### Changes - CLI: stamp build commit into dist metadata so banners show the commit in npm installs. +- CLI: close memory manager after memory commands to avoid hanging processes. (#1127) — thanks @NicholasSpisak. ## 2026.1.16-1 diff --git a/Peekaboo b/Peekaboo deleted file mode 160000 index 5c195f5e4..000000000 --- a/Peekaboo +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 5c195f5e46ebfcc953af74fdd05fbc962d05a50c diff --git a/README.md b/README.md index 132419141..6c28bc24f 100644 --- a/README.md +++ b/README.md @@ -249,7 +249,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar - `/compact` — compact session context (summary) - `/think ` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only) - `/verbose on|off` -- `/cost on|off` — append per-response token/cost usage lines +- `/usage off|tokens|full` — per-response usage footer - `/restart` — restart the gateway (owner-only in groups) - `/activation mention|always` — group activation toggle (groups only) diff --git a/Swabble/Package.swift b/Swabble/Package.swift index 70448139e..9f5a00036 100644 --- a/Swabble/Package.swift +++ b/Swabble/Package.swift @@ -13,7 +13,7 @@ let package = Package( .executable(name: "swabble", targets: ["SwabbleCLI"]), ], dependencies: [ - .package(path: "../Peekaboo/Commander"), + .package(url: "https://github.com/steipete/Commander.git", exact: "0.2.1"), .package(url: "https://github.com/apple/swift-testing", from: "0.99.0"), ], targets: [ diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index 2bad2ca2f..248cec36e 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -1,6 +1,24 @@ { - "originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b", + "originHash" : "4ed05a95fa9feada29b97f81b3194392e59a0c7b9edf24851f922bc2b72b0438", "pins" : [ + { + "identity" : "axorcist", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/AXorcist.git", + "state" : { + "revision" : "c75d06f7f93e264a9786edc2b78c04973061cb2f", + "version" : "0.1.0" + } + }, + { + "identity" : "commander", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/Commander.git", + "state" : { + "revision" : "9e349575c8e3c6745e81fe19e5bb5efa01b078ce", + "version" : "0.2.1" + } + }, { "identity" : "elevenlabskit", "kind" : "remoteSourceControl", @@ -10,15 +28,6 @@ "version" : "0.1.0" } }, - { - "identity" : "eventsource", - "kind" : "remoteSourceControl", - "location" : "https://github.com/mattt/eventsource.git", - "state" : { - "revision" : "ca2a9d90cbe49e09b92f4b6ebd922c03ebea51d0", - "version" : "1.3.0" - } - }, { "identity" : "menubarextraaccess", "kind" : "remoteSourceControl", @@ -28,6 +37,15 @@ "version" : "1.2.2" } }, + { + "identity" : "peekaboo", + "kind" : "remoteSourceControl", + "location" : "https://github.com/steipete/Peekaboo.git", + "state" : { + "branch" : "main", + "revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", @@ -46,33 +64,6 @@ "version" : "1.2.1" } }, - { - "identity" : "swift-asn1", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-asn1.git", - "state" : { - "revision" : "810496cf121e525d660cd0ea89a758740476b85f", - "version" : "1.5.1" - } - }, - { - "identity" : "swift-async-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms", - "state" : { - "revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", - "state" : { - "branch" : "main", - "revision" : "8e5e4a8f3617283b556064574651fc0869943c9a" - } - }, { "identity" : "swift-concurrency-extras", "kind" : "remoteSourceControl", @@ -82,24 +73,6 @@ "version" : "1.3.2" } }, - { - "identity" : "swift-configuration", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-configuration", - "state" : { - "revision" : "3528deb75256d7dcbb0d71fa75077caae0a8c749", - "version" : "1.0.0" - } - }, - { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "revision" : "6f70fa9eab24c1fd982af18c281c4525d05e3095", - "version" : "4.2.0" - } - }, { "identity" : "swift-log", "kind" : "remoteSourceControl", @@ -118,24 +91,6 @@ "version" : "1.1.1" } }, - { - "identity" : "swift-sdk", - "kind" : "remoteSourceControl", - "location" : "https://github.com/modelcontextprotocol/swift-sdk.git", - "state" : { - "revision" : "c0407a0b52677cb395d824cac2879b963075ba8c", - "version" : "0.10.2" - } - }, - { - "identity" : "swift-service-lifecycle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-service-lifecycle", - "state" : { - "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", - "version" : "2.9.1" - } - }, { "identity" : "swift-subprocess", "kind" : "remoteSourceControl", diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 36928c71c..3cd135e50 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -20,10 +20,9 @@ let package = Package( .package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.1.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.8.0"), .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.8.1"), + .package(url: "https://github.com/steipete/Peekaboo.git", branch: "main"), .package(path: "../shared/ClawdbotKit"), .package(path: "../../Swabble"), - .package(path: "../../Peekaboo/Core/PeekabooCore"), - .package(path: "../../Peekaboo/Core/PeekabooAutomationKit"), ], targets: [ .target( @@ -61,8 +60,8 @@ let package = Package( .product(name: "Subprocess", package: "swift-subprocess"), .product(name: "Logging", package: "swift-log"), .product(name: "Sparkle", package: "Sparkle"), - .product(name: "PeekabooBridge", package: "PeekabooCore"), - .product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"), + .product(name: "PeekabooBridge", package: "Peekaboo"), + .product(name: "PeekabooAutomationKit", package: "Peekaboo"), ], exclude: [ "Resources/Info.plist", diff --git a/apps/macos/README.md b/apps/macos/README.md new file mode 100644 index 000000000..ae35b772e --- /dev/null +++ b/apps/macos/README.md @@ -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) diff --git a/apps/macos/Sources/Clawdbot/AppState.swift b/apps/macos/Sources/Clawdbot/AppState.swift index 22994bc25..fdb702f17 100644 --- a/apps/macos/Sources/Clawdbot/AppState.swift +++ b/apps/macos/Sources/Clawdbot/AppState.swift @@ -170,8 +170,15 @@ final class AppState { didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } } } - var systemRunPolicy: SystemRunPolicy { - didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } } + var execApprovalMode: ExecApprovalQuickMode { + didSet { + self.ifNotPreview { + ExecApprovalsStore.updateDefaults { defaults in + defaults.security = self.execApprovalMode.security + defaults.ask = self.execApprovalMode.ask + } + } + } } /// Tracks whether the Canvas panel is currently visible (not persisted). @@ -274,7 +281,8 @@ final class AppState { self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? "" self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true - self.systemRunPolicy = SystemRunPolicy.load() + let execDefaults = ExecApprovalsStore.resolveDefaults() + self.execApprovalMode = ExecApprovalQuickMode.from(security: execDefaults.security, ask: execDefaults.ask) self.peekabooBridgeEnabled = UserDefaults.standard .object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true if !self.isPreview { diff --git a/apps/macos/Sources/Clawdbot/Bridge/BridgeConnectionHandler.swift b/apps/macos/Sources/Clawdbot/Bridge/BridgeConnectionHandler.swift index ebe753e5d..ea38c3ee1 100644 --- a/apps/macos/Sources/Clawdbot/Bridge/BridgeConnectionHandler.swift +++ b/apps/macos/Sources/Clawdbot/Bridge/BridgeConnectionHandler.swift @@ -8,6 +8,8 @@ struct BridgeNodeInfo: Sendable { var displayName: String? var platform: String? var version: String? + var coreVersion: String? + var uiVersion: String? var deviceFamily: String? var modelIdentifier: String? var remoteAddress: String? @@ -147,6 +149,8 @@ actor BridgeConnectionHandler { displayName: hello.displayName, platform: hello.platform, version: hello.version, + coreVersion: hello.coreVersion, + uiVersion: hello.uiVersion, deviceFamily: hello.deviceFamily, modelIdentifier: hello.modelIdentifier, remoteAddress: self.remoteAddressString(), @@ -171,6 +175,8 @@ actor BridgeConnectionHandler { displayName: req.displayName, platform: req.platform, version: req.version, + coreVersion: req.coreVersion, + uiVersion: req.uiVersion, deviceFamily: req.deviceFamily, modelIdentifier: req.modelIdentifier, caps: req.caps, @@ -186,6 +192,8 @@ actor BridgeConnectionHandler { displayName: enriched.displayName, platform: enriched.platform, version: enriched.version, + coreVersion: enriched.coreVersion, + uiVersion: enriched.uiVersion, deviceFamily: enriched.deviceFamily, modelIdentifier: enriched.modelIdentifier, remoteAddress: enriched.remoteAddress, diff --git a/apps/macos/Sources/Clawdbot/CommandResolver.swift b/apps/macos/Sources/Clawdbot/CommandResolver.swift index f1d36a9bc..9e8ae1c41 100644 --- a/apps/macos/Sources/Clawdbot/CommandResolver.swift +++ b/apps/macos/Sources/Clawdbot/CommandResolver.swift @@ -214,9 +214,10 @@ enum CommandResolver { subcommand: String, extraArgs: [String] = [], defaults: UserDefaults = .standard, + configRoot: [String: Any]? = nil, searchPaths: [String]? = nil) -> [String] { - let settings = self.connectionSettings(defaults: defaults) + let settings = self.connectionSettings(defaults: defaults, configRoot: configRoot) if settings.mode == .remote, let ssh = self.sshNodeCommand( subcommand: subcommand, extraArgs: extraArgs, @@ -264,12 +265,14 @@ enum CommandResolver { subcommand: String, extraArgs: [String] = [], defaults: UserDefaults = .standard, + configRoot: [String: Any]? = nil, searchPaths: [String]? = nil) -> [String] { self.clawdbotNodeCommand( subcommand: subcommand, extraArgs: extraArgs, defaults: defaults, + configRoot: configRoot, searchPaths: searchPaths) } @@ -384,8 +387,11 @@ enum CommandResolver { let cliPath: String } - static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings { - let root = ClawdbotConfigFile.loadDict() + static func connectionSettings( + defaults: UserDefaults = .standard, + configRoot: [String: Any]? = nil) -> RemoteSettings + { + let root = configRoot ?? ClawdbotConfigFile.loadDict() let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode let target = defaults.string(forKey: remoteTargetKey) ?? "" let identity = defaults.string(forKey: remoteIdentityKey) ?? "" diff --git a/apps/macos/Sources/Clawdbot/ExecApprovals.swift b/apps/macos/Sources/Clawdbot/ExecApprovals.swift new file mode 100644 index 000000000..eab1aea11 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/ExecApprovals.swift @@ -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[.. [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 = [] + private var lastRefresh: Date? + private let refreshInterval: TimeInterval = 90 + + func currentBins(force: Bool = false) async -> Set { + if force || self.isStale() { + await self.refresh() + } + return self.bins + } + + func refresh() async { + do { + let report = try await GatewayConnection.shared.skillsStatus() + var next = Set() + 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 + } +} diff --git a/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift b/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift new file mode 100644 index 000000000..ca5e85edf --- /dev/null +++ b/apps/macos/Sources/Clawdbot/ExecApprovalsSocket.swift @@ -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.. 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 = [ + "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? + 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.. 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.authenticationCode(for: Data(message.utf8), using: key) + return mac.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index bf569213e..477a65954 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -249,6 +249,13 @@ actor GatewayConnection { return trimmed.isEmpty ? nil : trimmed } + func cachedGatewayVersion() -> String? { + guard let snapshot = self.lastSnapshot else { return nil } + let raw = snapshot.server["version"]?.value as? String + let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + func snapshotPaths() -> (configPath: String?, stateDir: String?) { guard let snapshot = self.lastSnapshot else { return (nil, nil) } let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift b/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift index 032275bba..e66ff2e04 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEnvironment.swift @@ -27,7 +27,11 @@ struct Semver: Comparable, CustomStringConvertible, Sendable { else { return nil } // Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5") let patchRaw = String(parts[2]) - let patchNumeric = patchRaw.split { $0 == "-" || $0 == "+" }.first.flatMap { Int($0) } ?? 0 + guard let patchToken = patchRaw.split(whereSeparator: { $0 == "-" || $0 == "+" }).first, + let patchNumeric = Int(patchToken) + else { + return nil + } return Semver(major: major, minor: minor, patch: patchNumeric) } diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift index 6f484cc80..700b79f19 100644 --- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift @@ -16,6 +16,10 @@ enum GatewayLaunchAgentManager { static func set(enabled: Bool, bundlePath: String, port: Int) async -> String? { _ = bundlePath + guard !CommandResolver.connectionModeIsRemote() else { + self.logger.info("launchd change skipped (remote mode)") + return nil + } if enabled, self.isLaunchAgentWriteDisabled() { self.logger.info("launchd enable skipped (disable marker set)") return nil @@ -112,7 +116,9 @@ extension GatewayLaunchAgentManager { { let command = CommandResolver.clawdbotCommand( subcommand: "daemon", - extraArgs: self.withJsonFlag(args)) + extraArgs: self.withJsonFlag(args), + // Launchd management must always run locally, even if remote mode is configured. + configRoot: ["gateway": ["mode": "local"]]) var env = ProcessInfo.processInfo.environment env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout) diff --git a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift index 8369dbb93..35d81243b 100644 --- a/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayProcessManager.swift @@ -114,6 +114,9 @@ final class GatewayProcessManager { self.lastFailureReason = nil self.status = .stopped self.logger.info("gateway stop requested") + if CommandResolver.connectionModeIsRemote() { + return + } let bundlePath = Bundle.main.bundleURL.path Task { _ = await GatewayLaunchAgentManager.set( diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift index 5a5dc05d8..5f144864b 100644 --- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift @@ -83,27 +83,7 @@ struct GeneralSettings: View { subtitle: "Allow the agent to capture a photo or short video via the built-in camera.", binding: self.$cameraEnabled) - VStack(alignment: .leading, spacing: 6) { - Text("Node Run Commands") - .font(.body) - - Picker("", selection: self.$state.systemRunPolicy) { - ForEach(SystemRunPolicy.allCases) { policy in - Text(policy.title).tag(policy) - } - } - .labelsHidden() - .pickerStyle(.menu) - - Text(""" - Controls remote command execution on this Mac when it is paired as a node. \ - "Always Ask" prompts on each command; "Always Allow" runs without prompts; \ - "Never" disables `system.run`. - """) - .font(.footnote) - .foregroundStyle(.tertiary) - .fixedSize(horizontal: false, vertical: true) - } + SystemRunSettingsView() VStack(alignment: .leading, spacing: 6) { Text("Location Access") diff --git a/apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift b/apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift deleted file mode 100644 index 7b5f9934f..000000000 --- a/apps/macos/Sources/Clawdbot/MacNodeConfigFile.swift +++ /dev/null @@ -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) - } -} diff --git a/apps/macos/Sources/Clawdbot/MenuBar.swift b/apps/macos/Sources/Clawdbot/MenuBar.swift index 696322648..01c626f0d 100644 --- a/apps/macos/Sources/Clawdbot/MenuBar.swift +++ b/apps/macos/Sources/Clawdbot/MenuBar.swift @@ -256,6 +256,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } TerminationSignalWatcher.shared.start() NodePairingApprovalPrompter.shared.start() + ExecApprovalsPromptServer.shared.start() MacNodeModeCoordinator.shared.start() VoiceWakeGlobalSettingsSync.shared.start() Task { PresenceReporter.shared.start() } @@ -280,6 +281,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { PresenceReporter.shared.stop() NodePairingApprovalPrompter.shared.stop() + ExecApprovalsPromptServer.shared.stop() MacNodeModeCoordinator.shared.stop() TerminationSignalWatcher.shared.stop() VoiceWakeGlobalSettingsSync.shared.stop() diff --git a/apps/macos/Sources/Clawdbot/MenuContentView.swift b/apps/macos/Sources/Clawdbot/MenuContentView.swift index 34773bd8b..049e1de9e 100644 --- a/apps/macos/Sources/Clawdbot/MenuContentView.swift +++ b/apps/macos/Sources/Clawdbot/MenuContentView.swift @@ -31,10 +31,10 @@ struct MenuContent: View { self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled) } - private var systemRunPolicyBinding: Binding { + private var execApprovalModeBinding: Binding { Binding( - get: { self.state.systemRunPolicy }, - set: { self.state.systemRunPolicy = $0 }) + get: { self.state.execApprovalMode }, + set: { self.state.execApprovalMode = $0 }) } var body: some View { @@ -74,12 +74,12 @@ struct MenuContent: View { Toggle(isOn: self.$cameraEnabled) { Label("Allow Camera", systemImage: "camera") } - Picker(selection: self.systemRunPolicyBinding) { - ForEach(SystemRunPolicy.allCases) { policy in - Text(policy.title).tag(policy) + Picker(selection: self.execApprovalModeBinding) { + ForEach(ExecApprovalQuickMode.allCases) { mode in + Text(mode.title).tag(mode) } } label: { - Label("Node Run Commands", systemImage: "terminal") + Label("Exec Approvals", systemImage: "terminal") } Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) { Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis") diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift index 7092598fa..fd1727c00 100644 --- a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift @@ -14,6 +14,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { private weak var statusItem: NSStatusItem? private var loadTask: Task? private var nodesLoadTask: Task? + private var previewTasks: [Task] = [] private var isMenuOpen = false private var lastKnownMenuWidth: CGFloat? private var menuOpenWidth: CGFloat? @@ -87,6 +88,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { self.menuOpenWidth = nil self.loadTask?.cancel() self.nodesLoadTask?.cancel() + self.cancelPreviewTasks() } func menuNeedsUpdate(_ menu: NSMenu) { @@ -107,6 +109,7 @@ extension MenuSessionsInjector { private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey } private func inject(into menu: NSMenu) { + self.cancelPreviewTasks() // Remove any previous injected items. for item in menu.items where item.tag == self.tag { menu.removeItem(item) @@ -280,9 +283,7 @@ extension MenuSessionsInjector { private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int { let rows = self.usageRows - let errorText = self.cachedUsageErrorText - - if rows.isEmpty, errorText == nil { + if rows.isEmpty { return cursor } @@ -306,25 +307,6 @@ extension MenuSessionsInjector { menu.insertItem(headerItem, at: cursor) cursor += 1 - if let errorText = errorText?.nonEmpty, !rows.isEmpty { - menu.insertItem( - self.makeMessageItem( - text: errorText, - symbolName: "exclamationmark.triangle", - width: width, - maxLines: 2), - at: cursor) - cursor += 1 - } - - if rows.isEmpty { - menu.insertItem( - self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width), - at: cursor) - cursor += 1 - return cursor - } - if let selectedProvider = self.selectedUsageProviderId, let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }), rows.count > 1 @@ -440,6 +422,8 @@ extension MenuSessionsInjector { displayName: "Gateway", platform: platform, version: nil, + coreVersion: nil, + uiVersion: nil, deviceFamily: nil, modelIdentifier: nil, remoteIp: host, @@ -473,15 +457,46 @@ extension MenuSessionsInjector { item.tag = self.tag item.isEnabled = false let view = AnyView(SessionMenuPreviewView( - sessionKey: sessionKey, width: width, - maxItems: 10, maxLines: maxLines, - title: title)) - item.view = self.makeHostedView(rootView: view, width: width, highlighted: false) + title: title, + items: [], + status: .loading)) + let hosting = NSHostingView(rootView: view) + hosting.frame.size.width = max(1, width) + let size = hosting.fittingSize + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + item.view = hosting + + let task = Task { [weak hosting] in + let snapshot = await SessionMenuPreviewLoader.load(sessionKey: sessionKey, maxItems: 10) + guard !Task.isCancelled else { return } + await MainActor.run { + guard let hosting else { return } + let nextView = AnyView(SessionMenuPreviewView( + width: width, + maxLines: maxLines, + title: title, + items: snapshot.items, + status: snapshot.status)) + hosting.rootView = nextView + hosting.invalidateIntrinsicContentSize() + hosting.frame.size.width = max(1, width) + let size = hosting.fittingSize + hosting.frame.size.height = size.height + } + } + self.previewTasks.append(task) return item } + private func cancelPreviewTasks() { + for task in self.previewTasks { + task.cancel() + } + self.previewTasks.removeAll() + } + private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem { let view = AnyView( HStack(alignment: .top, spacing: 8) { @@ -559,14 +574,11 @@ extension MenuSessionsInjector { do { self.cachedUsageSummary = try await UsageLoader.loadSummary() - self.cachedUsageErrorText = nil - self.usageCacheUpdatedAt = Date() } catch { - if self.cachedUsageSummary == nil { - self.cachedUsageErrorText = self.compactUsageError(error) - } - self.usageCacheUpdatedAt = Date() + self.cachedUsageSummary = nil + self.cachedUsageErrorText = nil } + self.usageCacheUpdatedAt = Date() } private func compactUsageError(_ error: Error) -> String { @@ -747,8 +759,8 @@ extension MenuSessionsInjector { menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform)) } - if let version = entry.version?.nonEmpty { - menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version))) + if let version = NodeMenuEntryFormatter.detailRightVersion(entry)?.nonEmpty { + menu.addItem(self.makeNodeCopyItem(label: "Version", value: version)) } menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No")) diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgePairingClient.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgePairingClient.swift index 38b4877bc..22341d902 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgePairingClient.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgePairingClient.swift @@ -95,6 +95,8 @@ actor MacNodeBridgePairingClient { displayName: hello.displayName, platform: hello.platform, version: hello.version, + coreVersion: hello.coreVersion, + uiVersion: hello.uiVersion, deviceFamily: hello.deviceFamily, modelIdentifier: hello.modelIdentifier, caps: hello.caps, diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift index 7bfa8c15b..7217d76a0 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift @@ -43,7 +43,6 @@ final class MacNodeModeCoordinator { private func run() async { var retryDelay: UInt64 = 1_000_000_000 var lastCameraEnabled: Bool? - var lastSystemRunPolicy: SystemRunPolicy? let defaults = UserDefaults.standard while !Task.isCancelled { if await MainActor.run(body: { AppStateStore.shared.isPaused }) { @@ -60,15 +59,6 @@ final class MacNodeModeCoordinator { try? await Task.sleep(nanoseconds: 200_000_000) } - let systemRunPolicy = SystemRunPolicy.load() - if lastSystemRunPolicy == nil { - lastSystemRunPolicy = systemRunPolicy - } else if lastSystemRunPolicy != systemRunPolicy { - lastSystemRunPolicy = systemRunPolicy - await self.session.disconnect() - try? await Task.sleep(nanoseconds: 200_000_000) - } - guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else { try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000)) retryDelay = min(retryDelay * 2, 10_000_000_000) @@ -89,8 +79,13 @@ final class MacNodeModeCoordinator { if let mainSessionKey { await self?.runtime.updateMainSessionKey(mainSessionKey) } + await self?.runtime.setEventSender { [weak self] event, payload in + guard let self else { return } + try? await self.session.sendEvent(event: event, payloadJSON: payload) + } }, - onDisconnected: { reason in + onDisconnected: { [weak self] reason in + await self?.runtime.setEventSender(nil) await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason) }, onInvoke: { [weak self] req in @@ -119,12 +114,19 @@ final class MacNodeModeCoordinator { let caps = self.currentCaps() let commands = self.currentCommands(caps: caps) let permissions = await self.currentPermissions() + let uiVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + let liveGatewayVersion = await GatewayConnection.shared.cachedGatewayVersion() + let fallbackGatewayVersion = GatewayProcessManager.shared.environmentStatus.gatewayVersion + let coreVersion = (liveGatewayVersion ?? fallbackGatewayVersion)? + .trimmingCharacters(in: .whitespacesAndNewlines) return BridgeHello( nodeId: Self.nodeId(), displayName: InstanceIdentity.displayName, token: token, platform: "macos", - version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, + version: uiVersion, + coreVersion: coreVersion?.isEmpty == false ? coreVersion : nil, + uiVersion: uiVersion, deviceFamily: "Mac", modelIdentifier: InstanceIdentity.modelIdentifier, caps: caps, @@ -161,13 +163,12 @@ final class MacNodeModeCoordinator { ClawdbotCanvasA2UICommand.reset.rawValue, MacNodeScreenCommand.record.rawValue, ClawdbotSystemCommand.notify.rawValue, + ClawdbotSystemCommand.which.rawValue, + ClawdbotSystemCommand.run.rawValue, + ClawdbotSystemCommand.execApprovalsGet.rawValue, + ClawdbotSystemCommand.execApprovalsSet.rawValue, ] - if SystemRunPolicy.load() != .never { - commands.append(ClawdbotSystemCommand.which.rawValue) - commands.append(ClawdbotSystemCommand.run.rawValue) - } - let capsSet = Set(caps) if capsSet.contains(ClawdbotCapability.camera.rawValue) { commands.append(ClawdbotCameraCommand.list.rawValue) diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift index e0bcbfae3..16d5189ba 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift @@ -8,6 +8,7 @@ actor MacNodeRuntime { private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)? private var mainSessionKey: String = "main" + private var eventSender: (@Sendable (String, String?) async -> Void)? init( makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = { @@ -23,6 +24,10 @@ actor MacNodeRuntime { self.mainSessionKey = trimmed } + func setEventSender(_ sender: (@Sendable (String, String?) async -> Void)?) { + self.eventSender = sender + } + func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse { let command = req.command if self.isCanvasCommand(command), !Self.canvasEnabled() { @@ -59,6 +64,10 @@ actor MacNodeRuntime { return try await self.handleSystemWhich(req) case ClawdbotSystemCommand.notify.rawValue: return try await self.handleSystemNotify(req) + case ClawdbotSystemCommand.execApprovalsGet.rawValue: + return try await self.handleSystemExecApprovalsGet(req) + case ClawdbotSystemCommand.execApprovalsSet.rawValue: + return try await self.handleSystemExecApprovalsSet(req) default: return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command") } @@ -427,42 +436,168 @@ actor MacNodeRuntime { guard !command.isEmpty else { return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: command required") } + let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: params.rawCommand) - let wasAllowlisted = SystemRunAllowlist.contains(command) - switch Self.systemRunPolicy() { - case .never: + let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent + let approvals = ExecApprovalsStore.resolve(agentId: agentId) + let security = approvals.agent.security + let ask = approvals.agent.ask + let 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( req, code: .unavailable, - message: "SYSTEM_RUN_DISABLED: policy=never") - case .always: - break - case .ask: - if !wasAllowlisted { - let services = await self.mainActorServices() - let decision = await services.confirmSystemRun( - command: SystemRunAllowlist.displayString(for: command), - cwd: params.cwd) - switch decision { - case .allowOnce: - break - case .allowAlways: - SystemRunAllowlist.add(command) - case .deny: + message: "SYSTEM_RUN_DISABLED: security=deny") + } + + let requiresAsk: Bool = { + if ask == .always { return true } + if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true } + return false + }() + + var approvedByAsk = false + if requiresAsk { + let decision: ExecApprovalDecision? = await ExecApprovalsPromptPresenter.prompt( + ExecApprovalPromptRequest( + command: displayCommand, + cwd: params.cwd, + host: "node", + security: security.rawValue, + ask: ask.rawValue, + agentId: agentId, + 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( req, code: .unavailable, - message: "SYSTEM_RUN_DENIED: user denied") + message: "SYSTEM_RUN_DENIED: approval required") } + case .allowAlways?: + approvedByAsk = true + if security == .allowlist { + let pattern = resolution?.resolvedPath ?? + resolution?.rawExecutable ?? + command.first?.trimmingCharacters(in: .whitespacesAndNewlines) ?? + "" + if !pattern.isEmpty { + ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern) + } + } + case .allowOnce?: + approvedByAsk = true } } - let env = Self.sanitizedEnv(params.env) + if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk { + await self.emitExecEvent( + "exec.denied", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: displayCommand, + reason: "allowlist-miss")) + return Self.errorResponse( + req, + code: .unavailable, + message: "SYSTEM_RUN_DENIED: allowlist miss") + } + + if let match = allowlistMatch { + ExecApprovalsStore.recordAllowlistUse( + agentId: agentId, + pattern: match.pattern, + command: displayCommand, + resolvedPath: resolution?.resolvedPath) + } if params.needsScreenRecording == true { let authorized = await PermissionManager .status([.screenRecording])[.screenRecording] ?? false if !authorized { + await self.emitExecEvent( + "exec.denied", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: displayCommand, + reason: "permission:screenRecording")) return Self.errorResponse( req, code: .unavailable, @@ -471,11 +606,33 @@ actor MacNodeRuntime { } let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 } + await self.emitExecEvent( + "exec.started", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: displayCommand)) let result = await ShellExecutor.runDetailed( command: command, cwd: params.cwd, env: env, timeout: timeoutSec) + let combined = [result.stdout, result.stderr, result.errorMessage] + .compactMap { $0 } + .filter { !$0.isEmpty } + .joined(separator: "\n") + await self.emitExecEvent( + "exec.finished", + payload: ExecEventPayload( + sessionKey: sessionKey, + runId: runId, + host: "node", + command: displayCommand, + exitCode: result.exitCode, + timedOut: result.timedOut, + success: result.success, + output: ExecEventPayload.truncateOutput(combined))) struct RunPayload: Encodable { var exitCode: Int? @@ -523,6 +680,82 @@ actor MacNodeRuntime { return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) } + private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + _ = ExecApprovalsStore.ensureFile() + let snapshot = ExecApprovalsStore.readSnapshot() + let redacted = ExecApprovalsSnapshot( + path: snapshot.path, + exists: snapshot.exists, + hash: snapshot.hash, + file: ExecApprovalsStore.redactForSnapshot(snapshot.file)) + let payload = try Self.encodePayload(redacted) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + } + + private func handleSystemExecApprovalsSet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { + struct SetParams: Decodable { + var file: ExecApprovalsFile + var baseHash: String? + } + + let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON) + let current = ExecApprovalsStore.ensureFile() + let snapshot = ExecApprovalsStore.readSnapshot() + if snapshot.exists { + if snapshot.hash.isEmpty { + return Self.errorResponse( + req, + code: .invalidRequest, + message: "INVALID_REQUEST: exec approvals base hash unavailable; reload and retry") + } + let baseHash = params.baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if baseHash.isEmpty { + return Self.errorResponse( + req, + code: .invalidRequest, + message: "INVALID_REQUEST: exec approvals base hash required; reload and retry") + } + if baseHash != snapshot.hash { + return Self.errorResponse( + req, + code: .invalidRequest, + message: "INVALID_REQUEST: exec approvals changed; reload and retry") + } + } + + var normalized = ExecApprovalsStore.normalizeIncoming(params.file) + let socketPath = normalized.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) + let token = normalized.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedPath = (socketPath?.isEmpty == false) + ? socketPath! + : current.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? + ExecApprovalsStore.socketPath() + let resolvedToken = (token?.isEmpty == false) + ? token! + : current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken) + + ExecApprovalsStore.saveFile(normalized) + let nextSnapshot = ExecApprovalsStore.readSnapshot() + let redacted = ExecApprovalsSnapshot( + path: nextSnapshot.path, + exists: nextSnapshot.exists, + hash: nextSnapshot.hash, + file: ExecApprovalsStore.redactForSnapshot(nextSnapshot.file)) + let payload = try Self.encodePayload(redacted) + return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) + } + + private func emitExecEvent(_ event: String, payload: ExecEventPayload) async { + guard let sender = self.eventSender else { return } + guard let data = try? JSONEncoder().encode(payload), + let json = String(data: data, encoding: .utf8) + else { + return + } + await sender(event, json) + } + private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse { let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON) let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines) @@ -589,10 +822,6 @@ actor MacNodeRuntime { UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false } - private nonisolated static func systemRunPolicy() -> SystemRunPolicy { - SystemRunPolicy.load() - } - private static let blockedEnvKeys: Set = [ "PATH", "NODE_OPTIONS", diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift index 5f5008713..ef115b178 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntimeMainActorServices.swift @@ -1,14 +1,7 @@ -import AppKit import ClawdbotKit import CoreLocation import Foundation -enum SystemRunDecision: Sendable { - case allowOnce - case allowAlways - case deny -} - @MainActor protocol MacNodeRuntimeMainActorServices: Sendable { func recordScreen( @@ -24,8 +17,6 @@ protocol MacNodeRuntimeMainActorServices: Sendable { desiredAccuracy: ClawdbotLocationAccuracy, maxAgeMs: Int?, timeoutMs: Int?) async throws -> CLLocation - - func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision } @MainActor @@ -67,30 +58,4 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices timeoutMs: timeoutMs) } - func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision { - let alert = NSAlert() - alert.alertStyle = .warning - alert.messageText = "Allow this command?" - - var details = "Clawdbot wants to run:\n\n\(command)" - let trimmedCwd = cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !trimmedCwd.isEmpty { - details += "\n\nWorking directory:\n\(trimmedCwd)" - } - details += "\n\nThis runs on this Mac via node mode." - alert.informativeText = details - - alert.addButton(withTitle: "Allow Once") - alert.addButton(withTitle: "Always Allow") - alert.addButton(withTitle: "Don't Allow") - - switch alert.runModal() { - case .alertFirstButtonReturn: - return .allowOnce - case .alertSecondButtonReturn: - return .allowAlways - default: - return .deny - } - } } diff --git a/apps/macos/Sources/Clawdbot/NodesMenu.swift b/apps/macos/Sources/Clawdbot/NodesMenu.swift index 3ba560aaf..f88177d8d 100644 --- a/apps/macos/Sources/Clawdbot/NodesMenu.swift +++ b/apps/macos/Sources/Clawdbot/NodesMenu.swift @@ -35,8 +35,9 @@ struct NodeMenuEntryFormatter { if let platform = self.platformText(entry) { parts.append("platform \(platform)") } - if let version = entry.version?.nonEmpty { - parts.append("app \(self.compactVersion(version))") + let versionLabels = self.versionLabels(entry) + if !versionLabels.isEmpty { + parts.append(versionLabels.joined(separator: " · ")) } parts.append("status \(self.roleText(entry))") return parts.joined(separator: " · ") @@ -60,8 +61,9 @@ struct NodeMenuEntryFormatter { } static func detailRightVersion(_ entry: NodeInfo) -> String? { - guard let version = entry.version?.nonEmpty else { return nil } - return self.shortVersionLabel(version) + let labels = self.versionLabels(entry, compact: false) + if labels.isEmpty { return nil } + return labels.joined(separator: " · ") } static func platformText(_ entry: NodeInfo) -> String? { @@ -127,6 +129,39 @@ struct NodeMenuEntryFormatter { return compact } + private static func versionLabels(_ entry: NodeInfo, compact: Bool = true) -> [String] { + let (core, ui) = self.resolveVersions(entry) + var labels: [String] = [] + if let core { + let label = compact ? self.compactVersion(core) : self.shortVersionLabel(core) + labels.append("core \(label)") + } + if let ui { + let label = compact ? self.compactVersion(ui) : self.shortVersionLabel(ui) + labels.append("ui \(label)") + } + return labels + } + + private static func resolveVersions(_ entry: NodeInfo) -> (core: String?, ui: String?) { + let core = entry.coreVersion?.nonEmpty + let ui = entry.uiVersion?.nonEmpty + if core != nil || ui != nil { + return (core, ui) + } + guard let legacy = entry.version?.nonEmpty else { return (nil, nil) } + if self.isHeadlessPlatform(entry) { + return (legacy, nil) + } + return (nil, legacy) + } + + private static func isHeadlessPlatform(_ entry: NodeInfo) -> Bool { + let raw = entry.platform?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + if raw == "darwin" || raw == "linux" || raw == "win32" || raw == "windows" { return true } + return false + } + static func leadingSymbol(_ entry: NodeInfo) -> String { if self.isGateway(entry) { return self.safeSystemSymbol( diff --git a/apps/macos/Sources/Clawdbot/NodesStore.swift b/apps/macos/Sources/Clawdbot/NodesStore.swift index 24501bc6f..51d43336d 100644 --- a/apps/macos/Sources/Clawdbot/NodesStore.swift +++ b/apps/macos/Sources/Clawdbot/NodesStore.swift @@ -7,6 +7,8 @@ struct NodeInfo: Identifiable, Codable { let displayName: String? let platform: String? let version: String? + let coreVersion: String? + let uiVersion: String? let deviceFamily: String? let modelIdentifier: String? let remoteIp: String? diff --git a/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift b/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift index 76bf271fd..497e54a5c 100644 --- a/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift +++ b/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift @@ -3,13 +3,13 @@ import ClawdbotKit import OSLog import SwiftUI -private struct SessionPreviewItem: Identifiable, Sendable { +struct SessionPreviewItem: Identifiable, Sendable { let id: String let role: PreviewRole let text: String } -private enum PreviewRole: String, Sendable { +enum PreviewRole: String, Sendable { case user case assistant case tool @@ -27,7 +27,7 @@ private enum PreviewRole: String, Sendable { } } -private actor SessionPreviewCache { +actor SessionPreviewCache { static let shared = SessionPreviewCache() private struct CacheEntry { @@ -52,25 +52,33 @@ private actor SessionPreviewCache { } } -struct SessionMenuPreviewView: View { - private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview") - private static let previewTimeoutSeconds: Double = 4 - - let sessionKey: String - let width: CGFloat - let maxItems: Int - let maxLines: Int - let title: String - - @Environment(\.menuItemHighlighted) private var isHighlighted - @State private var items: [SessionPreviewItem] = [] - @State private var status: LoadStatus = .loading - - private struct PreviewTimeoutError: LocalizedError { - var errorDescription: String? { "preview timeout" } +#if DEBUG +extension SessionPreviewCache { + func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) { + self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt) } - private enum LoadStatus: Equatable { + func _testReset() { + self.entries = [:] + } +} +#endif + +struct SessionMenuPreviewSnapshot: Sendable { + let items: [SessionPreviewItem] + let status: SessionMenuPreviewView.LoadStatus +} + +struct SessionMenuPreviewView: View { + let width: CGFloat + let maxLines: Int + let title: String + let items: [SessionPreviewItem] + let status: LoadStatus + + @Environment(\.menuItemHighlighted) private var isHighlighted + + enum LoadStatus: Equatable { case loading case ready case empty @@ -85,10 +93,6 @@ struct SessionMenuPreviewView: View { self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary } - private var previewLimit: Int { - min(max(self.maxItems * 3, 20), 120) - } - var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 4) { @@ -123,9 +127,6 @@ struct SessionMenuPreviewView: View { .padding(.leading, 16) .padding(.trailing, 11) .frame(width: max(1, self.width), alignment: .leading) - .task(id: self.sessionKey) { - await self.loadPreview() - } } @ViewBuilder @@ -157,55 +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 { - self.status = .loading +enum SessionMenuPreviewLoader { + private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview") + private static let previewTimeoutSeconds: Double = 4 + private static let cacheMaxAgeSeconds: TimeInterval = 30 + + private struct PreviewTimeoutError: LocalizedError { + var errorDescription: String? { "preview timeout" } + } + + static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot { + if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) { + return Self.snapshot(from: cached) } do { - let timeoutMs = Int(Self.previewTimeoutSeconds * 1000) + let timeoutMs = Int(self.previewTimeoutSeconds * 1000) let payload = try await AsyncTimeout.withTimeout( - seconds: Self.previewTimeoutSeconds, + seconds: self.previewTimeoutSeconds, onTimeout: { PreviewTimeoutError() }, operation: { try await GatewayConnection.shared.chatHistory( - sessionKey: self.sessionKey, - limit: self.previewLimit, + sessionKey: sessionKey, + limit: self.previewLimit(for: maxItems), timeoutMs: timeoutMs) }) - let built = Self.previewItems(from: payload, maxItems: self.maxItems) - await SessionPreviewCache.shared.store(items: built, for: self.sessionKey) - await MainActor.run { - self.items = built - self.status = built.isEmpty ? .empty : .ready - } + let built = Self.previewItems(from: payload, maxItems: maxItems) + await SessionPreviewCache.shared.store(items: built, for: sessionKey) + return Self.snapshot(from: built) } catch is CancellationError { - return + return SessionMenuPreviewSnapshot(items: [], status: .loading) } catch { - let fallback = await SessionPreviewCache.shared.lastItems(for: self.sessionKey) - await MainActor.run { - if let fallback { - self.items = fallback - self.status = fallback.isEmpty ? .empty : .ready - } else { - self.status = .error("Preview unavailable") - } + let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey) + if let fallback { + return Self.snapshot(from: fallback) } let errorDescription = String(describing: error) Self.logger.warning( - "Session preview failed session=\(self.sessionKey, privacy: .public) " + + "Session preview failed session=\(sessionKey, privacy: .public) " + "error=\(errorDescription, privacy: .public)") + return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable")) } } + private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot { + SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready) + } + + private static func previewLimit(for maxItems: Int) -> Int { + min(max(maxItems * 3, 20), 120) + } + private static func previewItems( from payload: ClawdbotChatHistoryPayload, maxItems: Int) -> [SessionPreviewItem] diff --git a/apps/macos/Sources/Clawdbot/SystemRunPolicy.swift b/apps/macos/Sources/Clawdbot/SystemRunPolicy.swift deleted file mode 100644 index b38734bc3..000000000 --- a/apps/macos/Sources/Clawdbot/SystemRunPolicy.swift +++ /dev/null @@ -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 { - 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()) - } - } -} diff --git a/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift b/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift new file mode 100644 index 000000000..bb342a874 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift @@ -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 { + 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() + 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) + } + } +} diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 3ca98cbbb..a44f6739a 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -530,6 +530,8 @@ public struct NodePairRequestParams: Codable, Sendable { public let displayname: String? public let platform: String? public let version: String? + public let coreversion: String? + public let uiversion: String? public let devicefamily: String? public let modelidentifier: String? public let caps: [String]? @@ -542,6 +544,8 @@ public struct NodePairRequestParams: Codable, Sendable { displayname: String?, platform: String?, version: String?, + coreversion: String?, + uiversion: String?, devicefamily: String?, modelidentifier: String?, caps: [String]?, @@ -553,6 +557,8 @@ public struct NodePairRequestParams: Codable, Sendable { self.displayname = displayname self.platform = platform self.version = version + self.coreversion = coreversion + self.uiversion = uiversion self.devicefamily = devicefamily self.modelidentifier = modelidentifier self.caps = caps @@ -565,6 +571,8 @@ public struct NodePairRequestParams: Codable, Sendable { case displayname = "displayName" case platform case version + case coreversion = "coreVersion" + case uiversion = "uiVersion" case devicefamily = "deviceFamily" case modelidentifier = "modelIdentifier" case caps @@ -760,6 +768,10 @@ public struct SessionsPatchParams: Codable, Sendable { public let reasoninglevel: AnyCodable? public let responseusage: AnyCodable? public let elevatedlevel: AnyCodable? + public let exechost: AnyCodable? + public let execsecurity: AnyCodable? + public let execask: AnyCodable? + public let execnode: AnyCodable? public let model: AnyCodable? public let spawnedby: AnyCodable? public let sendpolicy: AnyCodable? @@ -773,6 +785,10 @@ public struct SessionsPatchParams: Codable, Sendable { reasoninglevel: AnyCodable?, responseusage: AnyCodable?, elevatedlevel: AnyCodable?, + exechost: AnyCodable?, + execsecurity: AnyCodable?, + execask: AnyCodable?, + execnode: AnyCodable?, model: AnyCodable?, spawnedby: AnyCodable?, sendpolicy: AnyCodable?, @@ -785,6 +801,10 @@ public struct SessionsPatchParams: Codable, Sendable { self.reasoninglevel = reasoninglevel self.responseusage = responseusage self.elevatedlevel = elevatedlevel + self.exechost = exechost + self.execsecurity = execsecurity + self.execask = execask + self.execnode = execnode self.model = model self.spawnedby = spawnedby self.sendpolicy = sendpolicy @@ -798,6 +818,10 @@ public struct SessionsPatchParams: Codable, Sendable { case reasoninglevel = "reasoningLevel" case responseusage = "responseUsage" case elevatedlevel = "elevatedLevel" + case exechost = "execHost" + case execsecurity = "execSecurity" + case execask = "execAsk" + case execnode = "execNode" case model case spawnedby = "spawnedBy" case sendpolicy = "sendPolicy" @@ -1616,6 +1640,85 @@ public struct LogsTailResult: Codable, Sendable { } } +public struct ExecApprovalsGetParams: Codable, Sendable { +} + +public struct ExecApprovalsSetParams: Codable, Sendable { + public let file: [String: AnyCodable] + public let basehash: String? + + public init( + file: [String: AnyCodable], + basehash: String? + ) { + self.file = file + self.basehash = basehash + } + private enum CodingKeys: String, CodingKey { + case file + case basehash = "baseHash" + } +} + +public struct ExecApprovalsNodeGetParams: Codable, Sendable { + public let nodeid: String + + public init( + nodeid: String + ) { + self.nodeid = nodeid + } + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + } +} + +public struct ExecApprovalsNodeSetParams: Codable, Sendable { + public let nodeid: String + public let file: [String: AnyCodable] + public let basehash: String? + + public init( + nodeid: String, + file: [String: AnyCodable], + basehash: String? + ) { + self.nodeid = nodeid + self.file = file + self.basehash = basehash + } + private enum CodingKeys: String, CodingKey { + case nodeid = "nodeId" + case file + case basehash = "baseHash" + } +} + +public struct ExecApprovalsSnapshot: Codable, Sendable { + public let path: String + public let exists: Bool + public let hash: String + public let file: [String: AnyCodable] + + public init( + path: String, + exists: Bool, + hash: String, + file: [String: AnyCodable] + ) { + self.path = path + self.exists = exists + self.hash = hash + self.file = file + } + private enum CodingKeys: String, CodingKey { + case path + case exists + case hash + case file + } +} + public struct ChatHistoryParams: Codable, Sendable { public let sessionkey: String public let limit: Int? diff --git a/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift b/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift index 2830f0929..8feb00f12 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift @@ -34,7 +34,7 @@ import Testing let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot") try self.makeExec(at: clawdbotPath) - let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults) + let cmd = CommandResolver.clawdbotCommand(subcommand: "gateway", defaults: defaults, configRoot: [:]) #expect(cmd.prefix(2).elementsEqual([clawdbotPath.path, "gateway"])) } @@ -55,6 +55,7 @@ import Testing let cmd = CommandResolver.clawdbotCommand( subcommand: "rpc", defaults: defaults, + configRoot: [:], searchPaths: [tmp.appendingPathComponent("node_modules/.bin").path]) #expect(cmd.count >= 3) @@ -75,7 +76,7 @@ import Testing let pnpmPath = tmp.appendingPathComponent("node_modules/.bin/pnpm") try self.makeExec(at: pnpmPath) - let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults) + let cmd = CommandResolver.clawdbotCommand(subcommand: "rpc", defaults: defaults, configRoot: [:]) #expect(cmd.prefix(4).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "rpc"])) } @@ -93,7 +94,8 @@ import Testing let cmd = CommandResolver.clawdbotCommand( subcommand: "health", extraArgs: ["--json", "--timeout", "5"], - defaults: defaults) + defaults: defaults, + configRoot: [:]) #expect(cmd.prefix(5).elementsEqual([pnpmPath.path, "--silent", "clawdbot", "health", "--json"])) #expect(cmd.suffix(2).elementsEqual(["--timeout", "5"])) @@ -114,7 +116,11 @@ import Testing defaults.set("/tmp/id_ed25519", forKey: remoteIdentityKey) defaults.set("/srv/clawdbot", forKey: remoteProjectRootKey) - let cmd = CommandResolver.clawdbotCommand(subcommand: "status", extraArgs: ["--json"], defaults: defaults) + let cmd = CommandResolver.clawdbotCommand( + subcommand: "status", + extraArgs: ["--json"], + defaults: defaults, + configRoot: [:]) #expect(cmd.first == "/usr/bin/ssh") #expect(cmd.contains("clawd@example.com")) @@ -128,4 +134,27 @@ import Testing #expect(script.contains("CLI=")) } } + + @Test func configRootLocalOverridesRemoteDefaults() async throws { + let defaults = self.makeDefaults() + defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey) + defaults.set("clawd@example.com:2222", forKey: remoteTargetKey) + + let tmp = try makeTempDir() + CommandResolver.setProjectRoot(tmp.path) + + let clawdbotPath = tmp.appendingPathComponent("node_modules/.bin/clawdbot") + try self.makeExec(at: clawdbotPath) + + let cmd = CommandResolver.clawdbotCommand( + subcommand: "daemon", + defaults: defaults, + configRoot: ["gateway": ["mode": "local"]]) + + #expect(cmd.first == clawdbotPath.path) + #expect(cmd.count >= 2) + if cmd.count >= 2 { + #expect(cmd[1] == "daemon") + } + } } diff --git a/apps/macos/Tests/ClawdbotIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/ClawdbotIPCTests/ExecAllowlistTests.swift new file mode 100644 index 000000000..5d03344d9 --- /dev/null +++ b/apps/macos/Tests/ClawdbotIPCTests/ExecAllowlistTests.swift @@ -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) + } +} diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift index 45943904c..12d03c185 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/MacNodeRuntimeTests.swift @@ -74,10 +74,6 @@ struct MacNodeRuntimeTests { { CLLocation(latitude: 0, longitude: 0) } - - func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision { - .allowOnce - } } let services = await MainActor.run { FakeMainActorServices() } diff --git a/apps/macos/Tests/ClawdbotIPCTests/SessionMenuPreviewTests.swift b/apps/macos/Tests/ClawdbotIPCTests/SessionMenuPreviewTests.swift new file mode 100644 index 000000000..b1d7b462c --- /dev/null +++ b/apps/macos/Tests/ClawdbotIPCTests/SessionMenuPreviewTests.swift @@ -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) + } +} diff --git a/apps/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift b/apps/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift index 5033cb9cc..4a01e8aee 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/UtilitiesTests.swift @@ -37,7 +37,7 @@ import Testing defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey) defaults.set("ssh alice@example.com", forKey: remoteTargetKey) - let settings = CommandResolver.connectionSettings(defaults: defaults) + let settings = CommandResolver.connectionSettings(defaults: defaults, configRoot: [:]) #expect(settings.mode == .remote) #expect(settings.target == "alice@example.com") } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BridgeFrames.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BridgeFrames.swift index 833a3c96b..5d45ab269 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BridgeFrames.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BridgeFrames.swift @@ -63,6 +63,8 @@ public struct BridgeHello: Codable, Sendable { public let token: String? public let platform: String? public let version: String? + public let coreVersion: String? + public let uiVersion: String? public let deviceFamily: String? public let modelIdentifier: String? public let caps: [String]? @@ -76,6 +78,8 @@ public struct BridgeHello: Codable, Sendable { token: String?, platform: String?, version: String?, + coreVersion: String? = nil, + uiVersion: String? = nil, deviceFamily: String? = nil, modelIdentifier: String? = nil, caps: [String]? = nil, @@ -88,6 +92,8 @@ public struct BridgeHello: Codable, Sendable { self.token = token self.platform = platform self.version = version + self.coreVersion = coreVersion + self.uiVersion = uiVersion self.deviceFamily = deviceFamily self.modelIdentifier = modelIdentifier self.caps = caps @@ -121,6 +127,8 @@ public struct BridgePairRequest: Codable, Sendable { public let displayName: String? public let platform: String? public let version: String? + public let coreVersion: String? + public let uiVersion: String? public let deviceFamily: String? public let modelIdentifier: String? public let caps: [String]? @@ -135,6 +143,8 @@ public struct BridgePairRequest: Codable, Sendable { displayName: String?, platform: String?, version: String?, + coreVersion: String? = nil, + uiVersion: String? = nil, deviceFamily: String? = nil, modelIdentifier: String? = nil, caps: [String]? = nil, @@ -148,6 +158,8 @@ public struct BridgePairRequest: Codable, Sendable { self.displayName = displayName self.platform = platform self.version = version + self.coreVersion = coreVersion + self.uiVersion = uiVersion self.deviceFamily = deviceFamily self.modelIdentifier = modelIdentifier self.caps = caps diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift index ca35a25b3..1de76dbc6 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift @@ -4,6 +4,8 @@ public enum ClawdbotSystemCommand: String, Codable, Sendable { case run = "system.run" case which = "system.which" case notify = "system.notify" + case execApprovalsGet = "system.execApprovals.get" + case execApprovalsSet = "system.execApprovals.set" } public enum ClawdbotNotificationPriority: String, Codable, Sendable { @@ -20,23 +22,32 @@ public enum ClawdbotNotificationDelivery: String, Codable, Sendable { public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable { public var command: [String] + public var rawCommand: String? public var cwd: String? public var env: [String: String]? public var timeoutMs: Int? public var needsScreenRecording: Bool? + public var agentId: String? + public var sessionKey: String? public init( command: [String], + rawCommand: String? = nil, cwd: String? = nil, env: [String: String]? = nil, timeoutMs: Int? = nil, - needsScreenRecording: Bool? = nil) + needsScreenRecording: Bool? = nil, + agentId: String? = nil, + sessionKey: String? = nil) { self.command = command + self.rawCommand = rawCommand self.cwd = cwd self.env = env self.timeoutMs = timeoutMs self.needsScreenRecording = needsScreenRecording + self.agentId = agentId + self.sessionKey = sessionKey } } diff --git a/docs.acp.md b/docs.acp.md new file mode 100644 index 000000000..ca664134e --- /dev/null +++ b/docs.acp.md @@ -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 +``` + +Example run: + +```bash +clawdbot acp --url wss://gateway-host:18789 --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:` 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", "", + "--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:` 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` diff --git a/docs/brave-search.md b/docs/brave-search.md new file mode 100644 index 000000000..a29143fb4 --- /dev/null +++ b/docs/brave-search.md @@ -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. diff --git a/docs/channels/bluebubbles.md b/docs/channels/bluebubbles.md new file mode 100644 index 000000000..a2b96dbe3 --- /dev/null +++ b/docs/channels/bluebubbles.md @@ -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=`). +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//typing`) and read receipts (`/api/v1/chat//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 `` 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 `. +- 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. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 3aa01420b..a16b6ed04 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -58,7 +58,7 @@ Minimal config: - The `discord` tool is only exposed when the current channel is Discord. 13. Native commands use isolated session keys (`agent::discord:slash:`) rather than the shared `main` session. -Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets. +Note: Name → id resolution uses guild member search and requires Server Members Intent; if the bot can’t search members, use ids or `<@id>` mentions. Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`. Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy. @@ -175,6 +175,7 @@ Notes: - `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages. - Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`. - If `channels` is present, any channel not listed is denied by default. +- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread channel id explicitly. - Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered). - Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels..users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`. @@ -192,8 +193,11 @@ Notes: - Your config requires mentions and you didn’t mention it, or - Your guild/channel allowlist denies the channel/user. - **`requireMention: false` but still no replies**: - - `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds..channels` to restrict). - - `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored. +- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds..channels` to restrict). + - If you only set `DISCORD_BOT_TOKEN` and never create a `channels.discord` section, the runtime + defaults `groupPolicy` to `open`. Add `channels.discord.groupPolicy`, + `channels.defaults.groupPolicy`, or a guild/channel allowlist to lock it down. +- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored. - **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit can’t verify permissions. - **DMs don’t work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you haven’t been approved yet (`channels.discord.dm.policy="pairing"`). @@ -361,6 +365,10 @@ Allowlist matching notes: - Use `*` to allow any sender/channel. - When `guilds..channels` is present, channels not listed are denied by default. - When `guilds..channels` is omitted, all channels in the allowlisted guild are allowed. +- To allow **no channels**, set `channels.discord.groupPolicy: "disabled"` (or keep an empty allowlist). +- The configure wizard accepts `Guild/Channel` names (public + private) and resolves them to IDs when possible. +- On startup, Clawdbot resolves channel/user names in allowlists to IDs (when the bot can search members) + and logs the mapping; unresolved entries are kept as typed. Native command notes: - The registered commands mirror Clawdbot’s chat commands. diff --git a/docs/channels/index.md b/docs/channels/index.md index 3021d5237..d294cb03c 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -17,6 +17,7 @@ Text is supported everywhere; media and reactions vary by channel. - [Slack](/channels/slack) — Bolt SDK; workspace apps. - [Signal](/channels/signal) — signal-cli; privacy-focused. - [iMessage](/channels/imessage) — macOS only; native integration. +- [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default). - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index b2349ca50..d0b632cb6 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -70,9 +70,10 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis - `clawdbot pairing list matrix` - `clawdbot pairing approve matrix ` - 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) -- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). +- Default: `channels.matrix.groupPolicy = "allowlist"` (mention-gated). Use `channels.defaults.groupPolicy` to override the default when unset. - Allowlist rooms with `channels.matrix.rooms`: ```json5 { @@ -86,6 +87,9 @@ Matrix is an open messaging protocol. Clawdbot connects as a Matrix user and lis } ``` - `requireMention: false` enables auto-reply in that room. +- The configure wizard prompts for room allowlists (room IDs, aliases, or names) and resolves names when possible. +- On startup, Clawdbot resolves room/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed. +- To allow **no rooms**, set `channels.matrix.groupPolicy: "disabled"` (or keep an empty allowlist). ## Threads - Reply threading is supported. diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index 79f96bbba..c8c668e84 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -76,12 +76,13 @@ Disable with: **DM access** - Default: `channels.msteams.dmPolicy = "pairing"`. Unknown senders are ignored until approved. -- `channels.msteams.allowFrom` accepts AAD object IDs or UPNs. +- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names (resolved at startup when Graph allows). **Group access** -- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). +- Default: `channels.msteams.groupPolicy = "allowlist"` (blocked unless you add `groupAllowFrom`). Use `channels.defaults.groupPolicy` to override the default when unset. - `channels.msteams.groupAllowFrom` controls which senders can trigger in group chats/channels (falls back to `channels.msteams.allowFrom`). - Set `groupPolicy: "open"` to allow any member (still mention‑gated by default). +- To allow **no channels**, set `channels.msteams.groupPolicy: "disabled"`. Example: ```json5 @@ -95,6 +96,32 @@ Example: } ``` +**Teams + channel allowlist** +- Scope group/channel replies by listing teams and channels under `channels.msteams.teams`. +- Keys can be team IDs or names; channel keys can be conversation IDs or names. +- When `groupPolicy="allowlist"` and a teams allowlist is present, only listed teams/channels are accepted (mention‑gated). +- The configure wizard accepts `Team/Channel` entries and stores them for you. +- On startup, Clawdbot resolves team/channel and user allowlist names to IDs (when Graph permissions allow) + and logs the mapping; unresolved entries are kept as typed. + +Example: +```json5 +{ + channels: { + msteams: { + groupPolicy: "allowlist", + teams: { + "My Team": { + channels: { + "General": { requireMention: true } + } + } + } + } + } +} +``` + ## How it works 1. Install the Microsoft Teams plugin. 2. Create an **Azure Bot** (App ID + secret + tenant ID). diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 9f85b706b..6607d54af 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -1,11 +1,13 @@ --- -summary: "Slack socket mode setup and Clawdbot config" -read_when: "Setting up Slack or debugging Slack socket mode" +summary: "Slack setup for socket or HTTP webhook mode" +read_when: "Setting up Slack or debugging Slack socket/HTTP mode" --- -# Slack (socket mode) +# Slack -## Quick setup (beginner) +## Socket mode (default) + +### Quick setup (beginner) 1) Create a Slack app and enable **Socket Mode**. 2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`). 3) Set tokens for Clawdbot and start the gateway. @@ -23,7 +25,7 @@ Minimal config: } ``` -## Setup +### Setup 1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps. 2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`). 3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`). @@ -43,7 +45,7 @@ Use the manifest below so scopes and events stay in sync. Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. -## Clawdbot config (minimal) +### Clawdbot config (minimal) Set tokens via env vars (recommended): - `SLACK_APP_TOKEN=xapp-...` @@ -63,7 +65,7 @@ Or via config: } ``` -## User token (optional) +### User token (optional) Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history, pins, reactions, emoji, member info). By default this stays read-only: reads prefer the user token when present, and writes still use the bot token unless @@ -102,18 +104,51 @@ Example with userTokenReadOnly explicitly set (allow user token writes): } ``` -### Token usage +#### Token usage - Read operations (history, reactions list, pins list, emoji list, member info, search) prefer the user token when configured, otherwise the bot token. - Write operations (send/edit/delete messages, add/remove reactions, pin/unpin, file uploads) use the bot token by default. If `userTokenReadOnly: false` and no bot token is available, Clawdbot falls back to the user token. -## History context +### History context - `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt. - Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). -## Manifest (optional) +## HTTP mode (Events API) +Use HTTP webhook mode when your Gateway is reachable by Slack over HTTPS (typical for server deployments). +HTTP mode uses the Events API + Interactivity + Slash Commands with a shared request URL. + +### Setup +1) Create a Slack app and **disable Socket Mode** (optional if you only use HTTP). +2) **Basic Information** → copy the **Signing Secret**. +3) **OAuth & Permissions** → install the app and copy the **Bot User OAuth Token** (`xoxb-...`). +4) **Event Subscriptions** → enable events and set the **Request URL** to your gateway webhook path (default `/slack/events`). +5) **Interactivity & Shortcuts** → enable and set the same **Request URL**. +6) **Slash Commands** → set the same **Request URL** for your command(s). + +Example request URL: +`https://gateway-host/slack/events` + +### Clawdbot config (minimal) +```json5 +{ + channels: { + slack: { + enabled: true, + mode: "http", + botToken: "xoxb-...", + signingSecret: "your-signing-secret", + webhookPath: "/slack/events" + } + } +} +``` + +Multi-account HTTP mode: set `channels.slack.accounts..mode = "http"` and provide a unique +`webhookPath` per account so each Slack app can point to its own URL. + +### Manifest (optional) Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the user scopes if you plan to configure a user token. @@ -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). - Approve via: `clawdbot pairing approve slack `. - 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 - `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`). - `allowlist` requires channels to be listed in `channels.slack.channels`. + - If you only set `SLACK_BOT_TOKEN`/`SLACK_APP_TOKEN` and never create a `channels.slack` section, + the runtime defaults `groupPolicy` to `open`. Add `channels.slack.groupPolicy`, + `channels.defaults.groupPolicy`, or a channel allowlist to lock it down. + - The configure wizard accepts `#channel` names and resolves them to IDs when possible + (public + private); if multiple matches exist, it prefers the active channel. + - On startup, Clawdbot resolves channel/user names in allowlists to IDs (when tokens allow) + and logs the mapping; unresolved entries are kept as typed. + - To allow **no channels**, set `channels.slack.groupPolicy: "disabled"` (or keep an empty allowlist). Channel options (`channels.slack.channels.` or `channels.slack.channels.`): - `allow`: allow/deny the channel when `groupPolicy="allowlist"`. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index 334d6fccb..7f655b1c9 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -152,6 +152,7 @@ By default, the bot only responds to mentions in groups (`@botname` or patterns ``` **Important:** Setting `channels.telegram.groups` creates an **allowlist** - only listed groups (or `"*"`) will be accepted. +Forum topics inherit their parent group config (allowFrom, requireMention, skills, prompts) unless you add per-topic overrides under `channels.telegram.groups..topics.`. To allow all groups with always-respond: ```json5 @@ -216,6 +217,7 @@ Telegram forum topics include a `message_thread_id` per message. Clawdbot: - General topic (thread id `1`) is special: message sends omit `message_thread_id` (Telegram rejects it), but typing indicators still include it. - Exposes `MessageThreadId` + `IsForum` in template context for routing/templating. - Topic-specific configuration is available under `channels.telegram.groups..topics.` (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. diff --git a/docs/channels/zalouser.md b/docs/channels/zalouser.md index 495c59286..a667a3f82 100644 --- a/docs/channels/zalouser.md +++ b/docs/channels/zalouser.md @@ -66,11 +66,36 @@ clawdbot directory groups list --channel zalouser --query "work" ## Access control (DMs) `channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`). +`channels.zalouser.allowFrom` accepts user IDs or names (resolved at startup when available). Approve via: - `clawdbot pairing list zalouser` - `clawdbot pairing approve zalouser ` +## Group access (optional) +- Default: `channels.zalouser.groupPolicy = "open"` (groups allowed). Use `channels.defaults.groupPolicy` to override the default when unset. +- Restrict to an allowlist with: + - `channels.zalouser.groupPolicy = "allowlist"` + - `channels.zalouser.groups` (keys are group IDs or names) +- Block all groups: `channels.zalouser.groupPolicy = "disabled"`. +- The configure wizard can prompt for group allowlists. +- On startup, Clawdbot resolves group/user names in allowlists to IDs and logs the mapping; unresolved entries are kept as typed. + +Example: +```json5 +{ + channels: { + zalouser: { + groupPolicy: "allowlist", + groups: { + "123456789": { allow: true }, + "Work Chat": { allow: true } + } + } + } +} +``` + ## Multi-account Accounts map to zca profiles. Example: diff --git a/docs/cli/acp.md b/docs/cli/acp.md new file mode 100644 index 000000000..6e27416b3 --- /dev/null +++ b/docs/cli/acp.md @@ -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 + +# 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 + +# 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 +``` + +Example direct run (no config write): + +```bash +clawdbot acp --url wss://gateway-host:18789 --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:` 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", "", + "--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 `: use a specific Gateway session key. +- `--session-label