Compare commits

..
97 changed files with 1025 additions and 3744 deletions

No files matched your search

+1 -2
View File
@@ -1,6 +1,6 @@
# Repository Guidelines # Repository Guidelines
- Repo: https://github.com/clawdbot/clawdbot - Repo: https://github.com/clawdbot/clawdbot
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n". - GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
## Project Structure & Module Organization ## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`). - Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
@@ -84,7 +84,6 @@
- When answering questions, respond with high-confidence answers only: verify in code; do not guess. - When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency. - Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`). - Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars. - CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes. - Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.** - Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
+33 -27
View File
@@ -2,30 +2,32 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.17-7 ## 2026.1.18-4
### Changes ### Changes
- Exec approvals: add `clawdbot approvals` CLI for viewing and updating gateway/node allowlists.
- CLI: add `clawdbot service` gateway/node management and a `clawdbot node status` alias.
- Status: show gateway + node service summaries in `clawdbot status` and `status --all`.
- Control UI: add gateway/node target selector for exec approvals.
- Docs: add approvals/service references and refresh node/control UI docs.
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule). - macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
- macOS: stop syncing Peekaboo as a git submodule in postinstall. - macOS: stop syncing Peekaboo as a git submodule in postinstall.
- Swabble: use the tagged Commander Swift package release. - Swabble: use the tagged Commander Swift package release.
- CLI: add `clawdbot acp client` interactive ACP harness for debugging. - 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: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
- Memory: add native Gemini embeddings provider for memory search. (#1151) - Memory: add native Gemini embeddings provider for memory search. (#1151) — thanks @gumadeiras.
- 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.
- 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) — thanks @gumadeiras.
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151) — thanks @gumadeiras.
- Agents: surface tool failures when no assistant output is emitted. (#1175) — thanks @vrknetha.
## 2026.1.18-3
### Changes
- Exec: add host/security/ask routing for gateway + node exec. - Exec: add host/security/ask routing for gateway + node exec.
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node). - 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: 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. - 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 headless node host (`clawdbot node start`) for `system.run`/`system.which`.
- Nodes: add node daemon service install/status/start/stop/restart. - Nodes: add node daemon service install/status/start/stop/restart.
- Hooks: run `BOOT.md` on gateway startup with the boot-md hook. (#1164) — thanks @ngutman.
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins. - 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. - 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. - Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
@@ -33,29 +35,17 @@ Docs: https://docs.clawd.bot
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals - 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 - 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. - 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
### Fixes ### Fixes
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events. - Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
- Exec approvals: parse command tokens correctly for PATH and relative resolution.
- Tools: return a companion-app-required message when node exec is requested with no paired node. - 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. - 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. - 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. - Tests: stabilize plugin SDK resolution and embedded agent timeouts.
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-6 ## 2026.1.17-6
@@ -83,6 +73,22 @@ Docs: https://docs.clawd.bot
### Fixes ### Fixes
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864) - 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 ## 2026.1.17-3
### Changes ### Changes
+1 -1
View File
@@ -43,7 +43,7 @@
"location" : "https://github.com/steipete/Peekaboo.git", "location" : "https://github.com/steipete/Peekaboo.git",
"state" : { "state" : {
"branch" : "main", "branch" : "main",
"revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90" "revision" : "b2d0384d9f0f45b945d5f718f8a865bd574d83c2"
} }
}, },
{ {
@@ -1,4 +1,3 @@
import CryptoKit
import Foundation import Foundation
import OSLog import OSLog
import Security import Security
@@ -122,13 +121,6 @@ struct ExecApprovalsFile: Codable {
var agents: [String: ExecApprovalsAgent]? var agents: [String: ExecApprovalsAgent]?
} }
struct ExecApprovalsSnapshot: Codable {
var path: String
var exists: Bool
var hash: String
var file: ExecApprovalsFile
}
struct ExecApprovalsResolved { struct ExecApprovalsResolved {
let url: URL let url: URL
let socketPath: String let socketPath: String
@@ -161,58 +153,6 @@ enum ExecApprovalsStore {
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path 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 { static func loadFile() -> ExecApprovalsFile {
let url = self.fileURL() let url = self.fileURL()
guard FileManager.default.fileExists(atPath: url.path) else { guard FileManager.default.fileExists(atPath: url.path) else {
@@ -432,12 +372,6 @@ enum ExecApprovalsStore {
return UUID().uuidString 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 { private static func expandPath(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "~" { if trimmed == "~" {
@@ -158,8 +158,6 @@ final class MacNodeModeCoordinator {
ClawdbotSystemCommand.notify.rawValue, ClawdbotSystemCommand.notify.rawValue,
ClawdbotSystemCommand.which.rawValue, ClawdbotSystemCommand.which.rawValue,
ClawdbotSystemCommand.run.rawValue, ClawdbotSystemCommand.run.rawValue,
ClawdbotSystemCommand.execApprovalsGet.rawValue,
ClawdbotSystemCommand.execApprovalsSet.rawValue,
] ]
let capsSet = Set(caps) let capsSet = Set(caps)
@@ -64,10 +64,6 @@ actor MacNodeRuntime {
return try await self.handleSystemWhich(req) return try await self.handleSystemWhich(req)
case ClawdbotSystemCommand.notify.rawValue: case ClawdbotSystemCommand.notify.rawValue:
return try await self.handleSystemNotify(req) return try await self.handleSystemNotify(req)
case ClawdbotSystemCommand.execApprovalsGet.rawValue:
return try await self.handleSystemExecApprovalsGet(req)
case ClawdbotSystemCommand.execApprovalsSet.rawValue:
return try await self.handleSystemExecApprovalsSet(req)
default: default:
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command") return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
} }
@@ -680,72 +676,6 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload) return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
} }
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
_ = ExecApprovalsStore.ensureFile()
let snapshot = ExecApprovalsStore.readSnapshot()
let redacted = ExecApprovalsSnapshot(
path: snapshot.path,
exists: snapshot.exists,
hash: snapshot.hash,
file: ExecApprovalsStore.redactForSnapshot(snapshot.file))
let payload = try Self.encodePayload(redacted)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleSystemExecApprovalsSet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
struct SetParams: Decodable {
var file: ExecApprovalsFile
var baseHash: String?
}
let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON)
let current = ExecApprovalsStore.ensureFile()
let snapshot = ExecApprovalsStore.readSnapshot()
if snapshot.exists {
if snapshot.hash.isEmpty {
return Self.errorResponse(
req,
code: .invalidRequest,
message: "INVALID_REQUEST: exec approvals base hash unavailable; reload and retry")
}
let baseHash = params.baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if baseHash.isEmpty {
return Self.errorResponse(
req,
code: .invalidRequest,
message: "INVALID_REQUEST: exec approvals base hash required; reload and retry")
}
if baseHash != snapshot.hash {
return Self.errorResponse(
req,
code: .invalidRequest,
message: "INVALID_REQUEST: exec approvals changed; reload and retry")
}
}
var normalized = ExecApprovalsStore.normalizeIncoming(params.file)
let socketPath = normalized.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines)
let token = normalized.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedPath = (socketPath?.isEmpty == false)
? socketPath!
: current.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ??
ExecApprovalsStore.socketPath()
let resolvedToken = (token?.isEmpty == false)
? token!
: current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken)
ExecApprovalsStore.saveFile(normalized)
let nextSnapshot = ExecApprovalsStore.readSnapshot()
let redacted = ExecApprovalsSnapshot(
path: nextSnapshot.path,
exists: nextSnapshot.exists,
hash: nextSnapshot.hash,
file: ExecApprovalsStore.redactForSnapshot(nextSnapshot.file))
let payload = try Self.encodePayload(redacted)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async { private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
guard let sender = self.eventSender else { return } guard let sender = self.eventSender else { return }
guard let data = try? JSONEncoder().encode(payload), guard let data = try? JSONEncoder().encode(payload),
@@ -4,8 +4,6 @@ public enum ClawdbotSystemCommand: String, Codable, Sendable {
case run = "system.run" case run = "system.run"
case which = "system.which" case which = "system.which"
case notify = "system.notify" case notify = "system.notify"
case execApprovalsGet = "system.execApprovals.get"
case execApprovalsSet = "system.execApprovals.set"
} }
public enum ClawdbotNotificationPriority: String, Codable, Sendable { public enum ClawdbotNotificationPriority: String, Codable, Sendable {
+10 -45
View File
@@ -1,13 +1,11 @@
--- ---
summary: "Slack setup for socket or HTTP webhook mode" summary: "Slack socket mode setup and Clawdbot config"
read_when: "Setting up Slack or debugging Slack socket/HTTP mode" read_when: "Setting up Slack or debugging Slack socket mode"
--- ---
# Slack # Slack (socket mode)
## Socket mode (default) ## Quick setup (beginner)
### Quick setup (beginner)
1) Create a Slack app and enable **Socket Mode**. 1) Create a Slack app and enable **Socket Mode**.
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`). 2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
3) Set tokens for Clawdbot and start the gateway. 3) Set tokens for Clawdbot and start the gateway.
@@ -25,7 +23,7 @@ Minimal config:
} }
``` ```
### Setup ## Setup
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps. 1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`). 2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`). 3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
@@ -45,7 +43,7 @@ Use the manifest below so scopes and events stay in sync.
Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
### Clawdbot config (minimal) ## Clawdbot config (minimal)
Set tokens via env vars (recommended): Set tokens via env vars (recommended):
- `SLACK_APP_TOKEN=xapp-...` - `SLACK_APP_TOKEN=xapp-...`
@@ -65,7 +63,7 @@ Or via config:
} }
``` ```
### User token (optional) ## User token (optional)
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history, Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
pins, reactions, emoji, member info). By default this stays read-only: reads pins, reactions, emoji, member info). By default this stays read-only: reads
prefer the user token when present, and writes still use the bot token unless prefer the user token when present, and writes still use the bot token unless
@@ -104,51 +102,18 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
} }
``` ```
#### Token usage ### Token usage
- Read operations (history, reactions list, pins list, emoji list, member info, - Read operations (history, reactions list, pins list, emoji list, member info,
search) prefer the user token when configured, otherwise the bot token. search) prefer the user token when configured, otherwise the bot token.
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin, - Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
file uploads) use the bot token by default. If `userTokenReadOnly: false` and file uploads) use the bot token by default. If `userTokenReadOnly: false` and
no bot token is available, Clawdbot falls back to the user token. no bot token is available, Clawdbot falls back to the user token.
### History context ## History context
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt. - `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). - Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
## HTTP mode (Events API) ## Manifest (optional)
Use HTTP webhook mode when your Gateway is reachable by Slack over HTTPS (typical for server deployments).
HTTP mode uses the Events API + Interactivity + Slash Commands with a shared request URL.
### Setup
1) Create a Slack app and **disable Socket Mode** (optional if you only use HTTP).
2) **Basic Information** → copy the **Signing Secret**.
3) **OAuth & Permissions** → install the app and copy the **Bot User OAuth Token** (`xoxb-...`).
4) **Event Subscriptions** → enable events and set the **Request URL** to your gateway webhook path (default `/slack/events`).
5) **Interactivity & Shortcuts** → enable and set the same **Request URL**.
6) **Slash Commands** → set the same **Request URL** for your command(s).
Example request URL:
`https://gateway-host/slack/events`
### Clawdbot config (minimal)
```json5
{
channels: {
slack: {
enabled: true,
mode: "http",
botToken: "xoxb-...",
signingSecret: "your-signing-secret",
webhookPath: "/slack/events"
}
}
}
```
Multi-account HTTP mode: set `channels.slack.accounts.<id>.mode = "http"` and provide a unique
`webhookPath` per account so each Slack app can point to its own URL.
### Manifest (optional)
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
user scopes if you plan to configure a user token. user scopes if you plan to configure a user token.
-44
View File
@@ -1,44 +0,0 @@
---
summary: "CLI reference for `clawdbot approvals` (exec approvals for gateway or node hosts)"
read_when:
- You want to edit exec approvals from the CLI
- You need to manage allowlists on gateway or node hosts
---
# `clawdbot approvals`
Manage exec approvals for the **gateway host** or a **node host**.
By default, commands target the gateway. Use `--node` to edit a node’s approvals.
Related:
- Exec approvals: [Exec approvals](/tools/exec-approvals)
- Nodes: [Nodes](/nodes)
## Common commands
```bash
clawdbot approvals get
clawdbot approvals get --node <id|name|ip>
```
## Replace approvals from a file
```bash
clawdbot approvals set --file ./exec-approvals.json
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
```
## Allowlist helpers
```bash
clawdbot approvals allowlist add "~/Projects/**/bin/rg"
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"
clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
```
## Notes
- `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix).
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`.
-3
View File
@@ -9,9 +9,6 @@ read_when:
Manage the Gateway daemon (background service). Manage the Gateway daemon (background service).
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains
as a legacy alias for compatibility.
Related: Related:
- Gateway CLI: [Gateway](/cli/gateway) - Gateway CLI: [Gateway](/cli/gateway)
- macOS platform notes: [macOS](/platforms/macos) - macOS platform notes: [macOS](/platforms/macos)
+4 -19
View File
@@ -7,7 +7,7 @@ read_when:
# `clawdbot hooks` # `clawdbot hooks`
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, and gateway startup). Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
Related: Related:
- Hooks: [Hooks](/hooks) - Hooks: [Hooks](/hooks)
@@ -29,10 +29,9 @@ List all discovered hooks from workspace, managed, and bundled directories.
**Example output:** **Example output:**
``` ```
Hooks (4/4 ready) Hooks (3/3 ready)
Ready: Ready:
🚀 boot-md ✓ - Run BOOT.md on gateway startup
📝 command-logger ✓ - Log all command events to a centralized audit file 📝 command-logger ✓ - Log all command events to a centralized audit file
💾 session-memory ✓ - Save session context to memory when /new command is issued 💾 session-memory ✓ - Save session context to memory when /new command is issued
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance 😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
@@ -108,8 +107,8 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
``` ```
Hooks Status Hooks Status
Total hooks: 4 Total hooks: 2
Ready: 4 Ready: 2
Not ready: 0 Not ready: 0
``` ```
@@ -274,17 +273,3 @@ clawdbot hooks enable soul-evil
``` ```
**See:** [SOUL Evil Hook](/hooks/soul-evil) **See:** [SOUL Evil Hook](/hooks/soul-evil)
### boot-md
Runs `BOOT.md` when the gateway starts (after channels start).
**Events**: `gateway:startup`
**Enable**:
```bash
clawdbot hooks enable boot-md
```
**See:** [boot-md documentation](/hooks#boot-md)
+6 -33
View File
@@ -29,13 +29,11 @@ This page describes the current CLI behavior. If commands change, update this do
- [`sessions`](/cli/sessions) - [`sessions`](/cli/sessions)
- [`gateway`](/cli/gateway) - [`gateway`](/cli/gateway)
- [`daemon`](/cli/daemon) - [`daemon`](/cli/daemon)
- [`service`](/cli/service)
- [`logs`](/cli/logs) - [`logs`](/cli/logs)
- [`models`](/cli/models) - [`models`](/cli/models)
- [`memory`](/cli/memory) - [`memory`](/cli/memory)
- [`nodes`](/cli/nodes) - [`nodes`](/cli/nodes)
- [`node`](/cli/node) - [`node`](/cli/node)
- [`approvals`](/cli/approvals)
- [`sandbox`](/cli/sandbox) - [`sandbox`](/cli/sandbox)
- [`tui`](/cli/tui) - [`tui`](/cli/tui)
- [`browser`](/cli/browser) - [`browser`](/cli/browser)
@@ -145,21 +143,6 @@ clawdbot [--dev] [--profile <name>] <command>
start start
stop stop
restart restart
service
gateway
status
install
uninstall
start
stop
restart
node
status
install
uninstall
start
stop
restart
logs logs
models models
list list
@@ -197,10 +180,6 @@ clawdbot [--dev] [--profile <name>] <command>
start start
stop stop
restart restart
approvals
get
set
allowlist add|remove
browser browser
status status
start start
@@ -541,9 +520,6 @@ Options:
- `--verbose` - `--verbose`
- `--debug` (alias for `--verbose`) - `--debug` (alias for `--verbose`)
Notes:
- Overview includes Gateway + Node service status when available.
### Usage tracking ### Usage tracking
Clawdbot can surface provider usage/quota when OAuth/API creds are available. Clawdbot can surface provider usage/quota when OAuth/API creds are available.
@@ -805,15 +781,12 @@ All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
Subcommands: Subcommands:
- `node start --host <gateway-host> --port 18790` - `node start --host <gateway-host> --port 18790`
- `node service status` - `node daemon status`
- `node service install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]` - `node daemon install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
- `node service uninstall` - `node daemon uninstall`
- `node service start` - `node daemon start`
- `node service stop` - `node daemon stop`
- `node service restart` - `node daemon restart`
Legacy alias:
- `node daemon …` (same as `node service …`)
## Nodes ## Nodes
+6 -17
View File
@@ -37,14 +37,12 @@ Options:
- `--node-id <id>`: Override node id (clears pairing token) - `--node-id <id>`: Override node id (clears pairing token)
- `--display-name <name>`: Override the node display name - `--display-name <name>`: Override the node display name
## Service (background) ## Daemon (background service)
Install a headless node host as a user service. Install a headless node host as a user service.
```bash ```bash
clawdbot node service install --host <gateway-host> --port 18790 clawdbot node daemon install --host <gateway-host> --port 18790
# or
clawdbot service node install --host <gateway-host> --port 18790
``` ```
Options: Options:
@@ -59,20 +57,12 @@ Options:
Manage the service: Manage the service:
```bash
clawdbot node status
clawdbot service node status
clawdbot node service status
clawdbot node service start
clawdbot node service stop
clawdbot node service restart
clawdbot node service uninstall
```
Legacy alias:
```bash ```bash
clawdbot node daemon status clawdbot node daemon status
clawdbot node daemon start
clawdbot node daemon stop
clawdbot node daemon restart
clawdbot node daemon uninstall
``` ```
## Pairing ## Pairing
@@ -93,4 +83,3 @@ The node host stores its node id + token in `~/.clawdbot/node.json`.
- `~/.clawdbot/exec-approvals.json` - `~/.clawdbot/exec-approvals.json`
- [Exec approvals](/tools/exec-approvals) - [Exec approvals](/tools/exec-approvals)
- `clawdbot approvals --node <id|name|ip>` (edit from the Gateway)
-51
View File
@@ -1,51 +0,0 @@
---
summary: "CLI reference for `clawdbot service` (manage gateway + node services)"
read_when:
- You want to manage Gateway or node services cross-platform
- You want a single surface for start/stop/install/uninstall
---
# `clawdbot service`
Manage the **Gateway** service and **node host** services.
Related:
- Gateway daemon (legacy alias): [Daemon](/cli/daemon)
- Node host: [Node](/cli/node)
## Gateway service
```bash
clawdbot service gateway status
clawdbot service gateway install --port 18789
clawdbot service gateway start
clawdbot service gateway stop
clawdbot service gateway restart
clawdbot service gateway uninstall
```
Notes:
- `service gateway status` supports `--json` and `--deep` for system checks.
- `service gateway install` supports `--runtime node|bun` and `--token`.
## Node host service
```bash
clawdbot service node status
clawdbot service node install --host <gateway-host> --port 18790
clawdbot service node start
clawdbot service node stop
clawdbot service node restart
clawdbot service node uninstall
```
Notes:
- `service node install` supports `--runtime node|bun`, `--node-id`, `--display-name`,
and TLS options (`--tls`, `--tls-fingerprint`).
## Aliases
- `clawdbot daemon …` → `clawdbot service gateway …`
- `clawdbot node service …` → `clawdbot service node …`
- `clawdbot node status` → `clawdbot service node status`
- `clawdbot node daemon …` → `clawdbot service node …` (legacy)
-1
View File
@@ -19,5 +19,4 @@ clawdbot status --usage
Notes: Notes:
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal). - `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
- Output includes per-agent session stores when multiple agents are configured. - Output includes per-agent session stores when multiple agents are configured.
- Overview includes Gateway + Node service install/runtime status when available.
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)). - Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
+4 -78
View File
@@ -5,19 +5,13 @@ read_when:
--- ---
# Agent Loop (Clawdbot) # Agent Loop (Clawdbot)
An agentic loop is the full “real” run of an agent: intake → context assembly → model inference → Short, exact flow of one agent run.
tool execution → streaming replies → persistence. It’s the authoritative path that turns a message
into actions and a final reply, while keeping session state consistent.
In Clawdbot, a loop is a single, serialized run per session that emits lifecycle and stream events
as the model thinks, calls tools, and streams output. This doc explains how that authentic loop is
wired end-to-end.
## Entry points ## Entry points
- Gateway RPC: `agent` and `agent.wait`. - Gateway RPC: `agent` and `agent.wait`.
- CLI: `agent` command. - CLI: `agent` command.
## How it works (high-level) ## High-level flow
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately. 1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
2) `agentCommand` runs the agent: 2) `agentCommand` runs the agent:
- resolves model + thinking/verbose defaults - resolves model + thinking/verbose defaults
@@ -25,9 +19,8 @@ wired end-to-end.
- calls `runEmbeddedPiAgent` (pi-agent-core runtime) - calls `runEmbeddedPiAgent` (pi-agent-core runtime)
- emits **lifecycle end/error** if the embedded loop does not emit one - emits **lifecycle end/error** if the embedded loop does not emit one
3) `runEmbeddedPiAgent`: 3) `runEmbeddedPiAgent`:
- serializes runs via per-session + global queues - builds `AgentSession` and subscribes to pi events
- resolves model + auth profile and builds the pi session - streams assistant deltas + tool events
- subscribes to pi events and streams assistant/tool deltas
- enforces timeout -> aborts run if exceeded - enforces timeout -> aborts run if exceeded
- returns payloads + usage metadata - returns payloads + usage metadata
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream: 4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream:
@@ -38,73 +31,6 @@ wired end-to-end.
- waits for **lifecycle end/error** for `runId` - waits for **lifecycle end/error** for `runId`
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }` - returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
## Queueing + concurrency
- Runs are serialized per session key (session lane) and optionally through a global lane.
- This prevents tool/session races and keeps session history consistent.
- Messaging channels can choose queue modes (collect/steer/followup) that feed this lane system.
See [Command Queue](/concepts/queue).
## Session + workspace preparation
- Workspace is resolved and created; sandboxed runs may redirect to a sandbox workspace root.
- Skills are loaded (or reused from a snapshot) and injected into env and prompt.
- Bootstrap/context files are resolved and injected into the system prompt report.
- A session write lock is acquired; `SessionManager` is opened and prepared before streaming.
## Prompt assembly + system prompt
- System prompt is built from Clawdbot’s base prompt, skills prompt, bootstrap context, and per-run overrides.
- Model-specific limits and compaction reserve tokens are enforced.
- See [System prompt](/concepts/system-prompt) for what the model sees.
## Hook points (where you can intercept)
Clawdbot has two hook systems:
- **Internal hooks** (Gateway hooks): event-driven scripts for commands and lifecycle events.
- **Plugin hooks**: extension points inside the agent/tool lifecycle and gateway pipeline.
### Internal hooks (Gateway hooks)
- **`agent:bootstrap`**: runs while building bootstrap files before the system prompt is finalized.
Use this to add/remove bootstrap context files.
- **Command hooks**: `/new`, `/reset`, `/stop`, and other command events (see Hooks doc).
See [Hooks](/hooks) for setup and examples.
### Plugin hooks (agent + gateway lifecycle)
These run inside the agent loop or gateway pipeline:
- **`before_agent_start`**: inject context or override system prompt before the run starts.
- **`agent_end`**: inspect the final message list and run metadata after completion.
- **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles.
- **`before_tool_call` / `after_tool_call`**: intercept tool params/results.
- **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks.
- **`session_start` / `session_end`**: session lifecycle boundaries.
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
See [Plugins](/plugin#plugin-hooks) for the hook API and registration details.
## Streaming + partial replies
- Assistant deltas are streamed from pi-agent-core and emitted as `assistant` events.
- Block streaming can emit partial replies either on `text_end` or `message_end`.
- Reasoning streaming can be emitted as a separate stream or as block replies.
- See [Streaming](/concepts/streaming) for chunking and block reply behavior.
## Tool execution + messaging tools
- Tool start/update/end events are emitted on the `tool` stream.
- Tool results are sanitized for size and image payloads before logging/emitting.
- Messaging tool sends are tracked to suppress duplicate assistant confirmations.
## Reply shaping + suppression
- Final payloads are assembled from:
- assistant text (and optional reasoning)
- inline tool summaries (when verbose + allowed)
- assistant error text when the model errors
- `NO_REPLY` is treated as a silent token and filtered from outgoing payloads.
- Messaging tool duplicates are removed from the final payload list.
- If no renderable payloads remain and a tool errored, a fallback tool error reply is emitted
(unless a messaging tool already sent a user-visible reply).
## Compaction + retries
- Auto-compaction emits `compaction` stream events and can trigger a retry.
- On retry, in-memory buffers and tool summaries are reset to avoid duplicate output.
- See [Compaction](/concepts/compaction) for the compaction pipeline.
## Event streams (today) ## Event streams (today)
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`) - `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
- `assistant`: streamed deltas from pi-agent-core - `assistant`: streamed deltas from pi-agent-core
-4
View File
@@ -86,10 +86,6 @@ These are the standard files Clawdbot expects inside the workspace:
- Optional tiny checklist for heartbeat runs. - Optional tiny checklist for heartbeat runs.
- Keep it short to avoid token burn. - Keep it short to avoid token burn.
- `BOOT.md`
- Optional startup checklist executed on gateway restart when internal hooks are enabled.
- Keep it short; use the message tool for outbound sends.
- `BOOTSTRAP.md` - `BOOTSTRAP.md`
- One-time first-run ritual. - One-time first-run ritual.
- Only created for a brand-new workspace. - Only created for a brand-new workspace.
-10
View File
@@ -18,7 +18,6 @@ The prompt is intentionally compact and uses fixed sections:
- **Skills** (when available): tells the model how to load skill instructions on demand. - **Skills** (when available): tells the model how to load skill instructions on demand.
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`. - **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
- **Workspace**: working directory (`agents.defaults.workspace`). - **Workspace**: working directory (`agents.defaults.workspace`).
- **Documentation**: local path to Clawdbot docs (repo or npm package) and when to read them.
- **Workspace Files (injected)**: indicates bootstrap files are included below. - **Workspace Files (injected)**: indicates bootstrap files are included below.
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available. - **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
- **Current Date & Time**: user-local time, timezone, and time format. - **Current Date & Time**: user-local time, timezone, and time format.
@@ -99,12 +98,3 @@ Skills section is omitted.
``` ```
This keeps the base prompt small while still enabling targeted skill usage. This keeps the base prompt small while still enabling targeted skill usage.
## Documentation
When available, the system prompt includes a **Documentation** section that points to the
local Clawdbot docs directory (either `docs/` in the repo workspace or the bundled npm
package docs) and also notes the public mirror, source repo, community Discord, and
ClawdHub (https://clawdhub.com) for skills discovery. The prompt instructs the model to consult local docs first
for Clawdbot behavior, commands, configuration, or architecture, and to run
`clawdbot status` itself when possible (asking the user only when it lacks access).
-7
View File
@@ -657,10 +657,6 @@
"source": "/templates/AGENTS", "source": "/templates/AGENTS",
"destination": "/reference/templates/AGENTS" "destination": "/reference/templates/AGENTS"
}, },
{
"source": "/templates/BOOT",
"destination": "/reference/templates/BOOT"
},
{ {
"source": "/templates/BOOTSTRAP", "source": "/templates/BOOTSTRAP",
"destination": "/reference/templates/BOOTSTRAP" "destination": "/reference/templates/BOOTSTRAP"
@@ -826,10 +822,8 @@
"cli/models", "cli/models",
"cli/logs", "cli/logs",
"cli/nodes", "cli/nodes",
"cli/approvals",
"cli/gateway", "cli/gateway",
"cli/daemon", "cli/daemon",
"cli/service",
"cli/tui", "cli/tui",
"cli/voicecall", "cli/voicecall",
"cli/wake", "cli/wake",
@@ -1057,7 +1051,6 @@
"reference/RELEASING", "reference/RELEASING",
"reference/AGENTS.default", "reference/AGENTS.default",
"reference/templates/AGENTS", "reference/templates/AGENTS",
"reference/templates/BOOT",
"reference/templates/BOOTSTRAP", "reference/templates/BOOTSTRAP",
"reference/templates/HEARTBEAT", "reference/templates/HEARTBEAT",
"reference/templates/IDENTITY", "reference/templates/IDENTITY",
+2 -30
View File
@@ -37,11 +37,10 @@ The hooks system allows you to:
### Bundled Hooks ### Bundled Hooks
Clawdbot ships with four bundled hooks that are automatically discovered: Clawdbot ships with three bundled hooks that are automatically discovered:
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new` - **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log` - **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled)
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance - **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
List available hooks: List available hooks:
@@ -196,7 +195,7 @@ Each event includes:
```typescript ```typescript
{ {
type: 'command' | 'session' | 'agent' | 'gateway', type: 'command' | 'session' | 'agent',
action: string, // e.g., 'new', 'reset', 'stop' action: string, // e.g., 'new', 'reset', 'stop'
sessionKey: string, // Session identifier sessionKey: string, // Session identifier
timestamp: Date, // When the event occurred timestamp: Date, // When the event occurred
@@ -229,12 +228,6 @@ Triggered when agent commands are issued:
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`) - **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
### Gateway Events
Triggered when the gateway starts:
- **`gateway:startup`**: After channels start and hooks are loaded
### Future Events ### Future Events
Planned event types: Planned event types:
@@ -549,26 +542,6 @@ clawdbot hooks enable soul-evil
} }
``` ```
### boot-md
Runs `BOOT.md` when the gateway starts (after channels start).
Internal hooks must be enabled for this to run.
**Events**: `gateway:startup`
**Requirements**: `workspace.dir` must be configured
**What it does**:
1. Reads `BOOT.md` from your workspace
2. Runs the instructions via the agent runner
3. Sends any requested outbound messages via the message tool
**Enable**:
```bash
clawdbot hooks enable boot-md
```
## Best Practices ## Best Practices
### Keep Handlers Fast ### Keep Handlers Fast
@@ -641,7 +614,6 @@ The gateway logs hook loading at startup:
``` ```
Registered hook: session-memory -> command:new Registered hook: session-memory -> command:new
Registered hook: command-logger -> command Registered hook: command-logger -> command
Registered hook: boot-md -> gateway:startup
``` ```
### Check Discovery ### Check Discovery
+2 -2
View File
@@ -149,8 +149,8 @@ Notes:
## System commands (node host / mac node) ## System commands (node host / mac node)
The macOS node exposes `system.run`, `system.notify`, and `system.execApprovals.get/set`. The macOS node exposes `system.run` and `system.notify`. The headless node host
The headless node host exposes `system.run`, `system.which`, and `system.execApprovals.get/set`. exposes `system.run` and `system.which`.
Examples: Examples:
-23
View File
@@ -104,29 +104,6 @@ Rules:
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the - If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
**active reply model** when its provider supports the capability. **active reply model** when its provider supports the capability.
### Auto-enable audio (when keys exist)
If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
audio provider keys configured, Clawdbot will **auto-enable audio transcription**
even when you haven’t listed models explicitly.
Providers checked (in order):
1) OpenAI
2) Groq
3) Deepgram
To disable this behavior, set:
```json5
{
tools: {
media: {
audio: {
enabled: false
}
}
}
}
```
## Capabilities (optional) ## Capabilities (optional)
If you set `capabilities`, the entry only runs for those media types. For shared If you set `capabilities`, the entry only runs for those media types. For shared
lists, Clawdbot can infer defaults: lists, Clawdbot can infer defaults:
-116
View File
@@ -1,116 +0,0 @@
---
summary: "Audit what can spend money, which keys are used, and how to view usage"
read_when:
- You want to understand which features may call paid APIs
- You need to audit keys, costs, and usage visibility
- You’re explaining /status or /usage cost reporting
---
# API usage & costs
This doc lists **features that can invoke API keys** and where their costs show up. It focuses on
Clawdbot features that can generate provider usage or paid API calls.
## Where costs show up (chat + CLI)
**Per-session cost snapshot**
- `/status` shows the current session model, context usage, and last response tokens.
- If the model uses **API-key auth**, `/status` also shows **estimated cost** for the last reply.
**Per-message cost footer**
- `/usage full` appends a usage footer to every reply, including **estimated cost** (API-key only).
- `/usage tokens` shows tokens only; OAuth flows hide dollar cost.
**CLI usage windows (provider quotas)**
- `clawdbot status --usage` and `clawdbot channels list` show provider **usage windows**
(quota snapshots, not per-message costs).
See [Token use & costs](/token-use) for details and examples.
## How keys are discovered
Clawdbot can pick up credentials from:
- **Auth profiles** (per-agent, stored in `auth-profiles.json`).
- **Environment variables** (e.g. `OPENAI_API_KEY`, `BRAVE_API_KEY`, `FIRECRAWL_API_KEY`).
- **Config** (`models.providers.*.apiKey`, `tools.web.search.*`, `tools.web.fetch.firecrawl.*`,
`memorySearch.*`, `talk.apiKey`).
- **Skills** (`skills.entries.<name>.apiKey`) which may export keys to the skill process env.
## Features that can spend keys
### 1) Core model responses (chat + tools)
Every reply or tool call uses the **current model provider** (OpenAI, Anthropic, etc). This is the
primary source of usage and cost.
See [Models](/providers/models) for pricing config and [Token use & costs](/token-use) for display.
### 2) Media understanding (audio/image/video)
Inbound media can be summarized/transcribed before the reply runs. This uses model/provider APIs.
- Audio: OpenAI / Groq / Deepgram (now **auto-enabled** when keys exist).
- Image: OpenAI / Anthropic / Google.
- Video: Google.
See [Media understanding](/nodes/media-understanding).
### 3) Memory embeddings + semantic search
Semantic memory search uses **embedding APIs** when configured for remote providers:
- `memorySearch.provider = "openai"` → OpenAI embeddings
- `memorySearch.provider = "gemini"` → Gemini embeddings
- Optional fallback to OpenAI if local embeddings fail
You can keep it local with `memorySearch.provider = "local"` (no API usage).
See [Memory](/concepts/memory).
### 4) Web search tool (Brave / Perplexity via OpenRouter)
`web_search` uses API keys and may incur usage charges:
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Perplexity** (via OpenRouter): `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
**Brave free tier (generous):**
- **2,000 requests/month**
- **1 request/second**
- **Credit card required** for verification (no charge unless you upgrade)
See [Web tools](/tools/web).
### 5) Web fetch tool (Firecrawl)
`web_fetch` can call **Firecrawl** when an API key is present:
- `FIRECRAWL_API_KEY` or `tools.web.fetch.firecrawl.apiKey`
If Firecrawl isn’t configured, the tool falls back to direct fetch + readability (no paid API).
See [Web tools](/tools/web).
### 6) Provider usage snapshots (status/health)
Some status commands call **provider usage endpoints** to display quota windows or auth health.
These are typically low-volume calls but still hit provider APIs:
- `clawdbot status --usage`
- `clawdbot models status --json`
See [Models CLI](/cli/models).
### 7) Compaction safeguard summarization
The compaction safeguard can summarize session history using the **current model**, which
invokes provider APIs when it runs.
See [Session management + compaction](/reference/session-management-compaction).
### 8) Model scan / probe
`clawdbot models scan` can probe OpenRouter models and uses `OPENROUTER_API_KEY` when
probing is enabled.
See [Models CLI](/cli/models).
### 9) Talk (speech)
Talk mode can invoke **ElevenLabs** when configured:
- `ELEVENLABS_API_KEY` or `talk.apiKey`
See [Talk mode](/nodes/talk).
### 10) Skills (third-party APIs)
Skills can store `apiKey` in `skills.entries.<name>.apiKey`. If a skill uses that key for external
APIs, it can incur costs according to the skill’s provider.
See [Skills](/tools/skills).
-9
View File
@@ -1,9 +0,0 @@
---
summary: "Workspace template for BOOT.md"
read_when:
- Adding a BOOT.md checklist
---
# BOOT.md
Add short, explicit instructions for what Clawdbot should do on startup (enable `hooks.internal.enabled`).
If the task sends a message, use the message tool and then reply with NO_REPLY.
+2 -6
View File
@@ -107,12 +107,8 @@ overrides, and allowlists. Pick a scope (Defaults or an agent), tweak the policy
add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata
per pattern so you can keep the list tidy. per pattern so you can keep the list tidy.
The target selector chooses **Gateway** (local approvals) or a **Node**. Nodes Note: the Control UI edits the approvals file on the **Gateway host**. For a
must advertise `system.execApprovals.get/set` (macOS app or headless node host). headless node host, edit its local `~/.clawdbot/exec-approvals.json` directly.
If a node does not advertise exec approvals yet, edit its local
`~/.clawdbot/exec-approvals.json` directly.
CLI: `clawdbot approvals` supports gateway or node editing (see [Approvals CLI](/cli/approvals)).
## Approval flow ## Approval flow
+1 -1
View File
@@ -36,7 +36,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`) - Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
- Skills: status, enable/disable, install, API key updates (`skills.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`)
- Nodes: list + caps (`node.list`) - Nodes: list + caps (`node.list`)
- Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`) - Exec approvals: edit allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`) - Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
- Config: apply + restart with validation (`config.apply`) and wake the last active session - Config: apply + restart with validation (`config.apply`) and wake the last active session
- Config writes include a base-hash guard to prevent clobbering concurrent edits - Config writes include a base-hash guard to prevent clobbering concurrent edits
@@ -1,4 +1,5 @@
import os from "node:os"; import os from "node:os";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk"; import type { PluginRuntime } from "clawdbot/plugin-sdk";
+3 -5
View File
@@ -4,13 +4,11 @@
"type": "module", "type": "module",
"description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture", "description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture",
"dependencies": { "dependencies": {
"@lancedb/lancedb": "^0.15.0",
"@sinclair/typebox": "0.34.47", "@sinclair/typebox": "0.34.47",
"openai": "^4.104.0" "@lancedb/lancedb": "^0.15.0",
"openai": "^4.77.0"
}, },
"clawdbot": { "clawdbot": {
"extensions": [ "extensions": ["./index.ts"]
"./index.ts"
]
} }
} }
+3 -3
View File
@@ -9,10 +9,10 @@
] ]
}, },
"dependencies": { "dependencies": {
"@microsoft/agents-hosting": "^1.2.2",
"@microsoft/agents-hosting-express": "^1.2.2",
"@microsoft/agents-hosting-extensions-teams": "^1.2.2",
"clawdbot": "workspace:*", "clawdbot": "workspace:*",
"@microsoft/agents-hosting": "^1.1.1",
"@microsoft/agents-hosting-express": "^1.1.1",
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
"express": "^5.2.1", "express": "^5.2.1",
"proper-lockfile": "^4.1.2" "proper-lockfile": "^4.1.2"
} }
+6 -6
View File
@@ -168,7 +168,7 @@
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.2.1", "express": "^5.2.1",
"file-type": "^21.3.0", "file-type": "^21.3.0",
"grammy": "^1.39.3", "grammy": "^1.39.2",
"hono": "4.11.4", "hono": "4.11.4",
"jiti": "^2.6.1", "jiti": "^2.6.1",
"json5": "^2.2.3", "json5": "^2.2.3",
@@ -182,7 +182,7 @@
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"sqlite-vec": "0.1.7-alpha.2", "sqlite-vec": "0.1.7-alpha.2",
"tar": "7.5.3", "tar": "^7.5.3",
"tslog": "^4.10.2", "tslog": "^4.10.2",
"undici": "^7.18.2", "undici": "^7.18.2",
"ws": "^8.19.0", "ws": "^8.19.0",
@@ -200,24 +200,24 @@
"@types/body-parser": "^1.19.6", "@types/body-parser": "^1.19.6",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/node": "^25.0.9", "@types/node": "^25.0.6",
"@types/proper-lockfile": "^4.1.4", "@types/proper-lockfile": "^4.1.4",
"@types/qrcode-terminal": "^0.12.2", "@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.17", "@vitest/coverage-v8": "^4.0.16",
"docx-preview": "^0.3.7", "docx-preview": "^0.3.7",
"lit": "^3.3.2", "lit": "^3.3.2",
"lucide": "^0.562.0", "lucide": "^0.562.0",
"ollama": "^0.6.3", "ollama": "^0.6.3",
"oxfmt": "0.24.0", "oxfmt": "0.24.0",
"oxlint": "^1.39.0", "oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.1", "oxlint-tsgolint": "^0.11.0",
"quicktype-core": "^23.2.6", "quicktype-core": "^23.2.6",
"rolldown": "1.0.0-beta.59", "rolldown": "1.0.0-beta.59",
"signal-utils": "^0.21.1", "signal-utils": "^0.21.1",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vitest": "^4.0.17", "vitest": "^4.0.16",
"wireit": "^0.14.12" "wireit": "^0.14.12"
}, },
"pnpm": { "pnpm": {
+737 -700
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -50,15 +50,15 @@ import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
const DEFAULT_MAX_OUTPUT = clampNumber( const DEFAULT_MAX_OUTPUT = clampNumber(
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"), readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
200_000, 30_000,
1_000, 1_000,
200_000, 150_000,
); );
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber( const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(
readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"), readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"),
200_000, 30_000,
1_000, 1_000,
200_000, 150_000,
); );
const DEFAULT_PATH = const DEFAULT_PATH =
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
-8
View File
@@ -6,7 +6,6 @@ import { shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging.js"; import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js"; import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { resolveClawdbotDocsPath } from "./docs-path.js";
import { resolveSessionAgentIds } from "./agent-scope.js"; import { resolveSessionAgentIds } from "./agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
import { resolveCliBackendConfig } from "./cli-backends.js"; import { resolveCliBackendConfig } from "./cli-backends.js";
@@ -84,12 +83,6 @@ export async function runCliAgent(params: {
sessionAgentId === defaultAgentId sessionAgentId === defaultAgentId
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined; : undefined;
const docsPath = await resolveClawdbotDocsPath({
workspaceDir,
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const systemPrompt = buildSystemPrompt({ const systemPrompt = buildSystemPrompt({
workspaceDir, workspaceDir,
config: params.config, config: params.config,
@@ -97,7 +90,6 @@ export async function runCliAgent(params: {
extraSystemPrompt, extraSystemPrompt,
ownerNumbers: params.ownerNumbers, ownerNumbers: params.ownerNumbers,
heartbeatPrompt, heartbeatPrompt,
docsPath: docsPath ?? undefined,
tools: [], tools: [],
contextFiles, contextFiles,
modelDisplay, modelDisplay,
-2
View File
@@ -168,7 +168,6 @@ export function buildSystemPrompt(params: {
extraSystemPrompt?: string; extraSystemPrompt?: string;
ownerNumbers?: string[]; ownerNumbers?: string[];
heartbeatPrompt?: string; heartbeatPrompt?: string;
docsPath?: string;
tools: AgentTool[]; tools: AgentTool[];
contextFiles?: EmbeddedContextFile[]; contextFiles?: EmbeddedContextFile[];
modelDisplay: string; modelDisplay: string;
@@ -183,7 +182,6 @@ export function buildSystemPrompt(params: {
ownerNumbers: params.ownerNumbers, ownerNumbers: params.ownerNumbers,
reasoningTagHint: false, reasoningTagHint: false,
heartbeatPrompt: params.heartbeatPrompt, heartbeatPrompt: params.heartbeatPrompt,
docsPath: params.docsPath,
runtimeInfo: { runtimeInfo: {
host: "clawdbot", host: "clawdbot",
os: `${os.type()} ${os.release()}`, os: `${os.type()} ${os.release()}`,
-27
View File
@@ -1,27 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
export async function resolveClawdbotDocsPath(params: {
workspaceDir?: string;
argv1?: string;
cwd?: string;
moduleUrl?: string;
}): Promise<string | null> {
const workspaceDir = params.workspaceDir?.trim();
if (workspaceDir) {
const workspaceDocs = path.join(workspaceDir, "docs");
if (fs.existsSync(workspaceDocs)) return workspaceDocs;
}
const packageRoot = await resolveClawdbotPackageRoot({
cwd: params.cwd,
argv1: params.argv1,
moduleUrl: params.moduleUrl,
});
if (!packageRoot) return null;
const packageDocs = path.join(packageRoot, "docs");
return fs.existsSync(packageDocs) ? packageDocs : null;
}
@@ -26,11 +26,6 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout"); expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
expect(classifyFailoverReason("string should match pattern")).toBe("format"); expect(classifyFailoverReason("string should match pattern")).toBe("format");
expect(classifyFailoverReason("bad request")).toBeNull(); expect(classifyFailoverReason("bad request")).toBeNull();
expect(
classifyFailoverReason(
"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels",
),
).toBeNull();
}); });
it("classifies OpenAI usage limit errors as rate_limit", () => { it("classifies OpenAI usage limit errors as rate_limit", () => {
expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe( expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe(
@@ -1,16 +0,0 @@
import { describe, expect, it } from "vitest";
import { isImageDimensionErrorMessage, parseImageDimensionError } from "./pi-embedded-helpers.js";
describe("image dimension errors", () => {
it("parses anthropic image dimension errors", () => {
const raw =
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}";
const parsed = parseImageDimensionError(raw);
expect(parsed).not.toBeNull();
expect(parsed?.maxDimensionPx).toBe(2000);
expect(parsed?.messageIndex).toBe(84);
expect(parsed?.contentIndex).toBe(1);
expect(isImageDimensionErrorMessage(raw)).toBe(true);
});
});
@@ -23,10 +23,5 @@ describe("isCloudCodeAssistFormatError", () => {
}); });
it("ignores unrelated errors", () => { it("ignores unrelated errors", () => {
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false); expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
expect(
isCloudCodeAssistFormatError(
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}",
),
).toBe(false);
}); });
}); });
-2
View File
@@ -21,13 +21,11 @@ export {
isContextOverflowError, isContextOverflowError,
isFailoverAssistantError, isFailoverAssistantError,
isFailoverErrorMessage, isFailoverErrorMessage,
isImageDimensionErrorMessage,
isOverloadedErrorMessage, isOverloadedErrorMessage,
isRawApiErrorPayload, isRawApiErrorPayload,
isRateLimitAssistantError, isRateLimitAssistantError,
isRateLimitErrorMessage, isRateLimitErrorMessage,
isTimeoutErrorMessage, isTimeoutErrorMessage,
parseImageDimensionError,
} from "./pi-embedded-helpers/errors.js"; } from "./pi-embedded-helpers/errors.js";
export { export {
downgradeGeminiHistory, downgradeGeminiHistory,
+2 -29
View File
@@ -339,6 +339,7 @@ const ERROR_PATTERNS = {
"no api key found", "no api key found",
], ],
format: [ format: [
"invalid_request_error",
"string should match pattern", "string should match pattern",
"tool_use.id", "tool_use.id",
"tool_use_id", "tool_use_id",
@@ -347,10 +348,6 @@ const ERROR_PATTERNS = {
], ],
} as const; } as const;
const IMAGE_DIMENSION_ERROR_RE =
/image dimensions exceed max allowed size for many-image requests:\s*(\d+)\s*pixels/i;
const IMAGE_DIMENSION_PATH_RE = /messages\.(\d+)\.content\.(\d+)\.image/i;
function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean { function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean {
if (!raw) return false; if (!raw) return false;
const value = raw.toLowerCase(); const value = raw.toLowerCase();
@@ -393,31 +390,8 @@ export function isOverloadedErrorMessage(raw: string): boolean {
return matchesErrorPatterns(raw, ERROR_PATTERNS.overloaded); return matchesErrorPatterns(raw, ERROR_PATTERNS.overloaded);
} }
export function parseImageDimensionError(raw: string): {
maxDimensionPx?: number;
messageIndex?: number;
contentIndex?: number;
raw: string;
} | null {
if (!raw) return null;
const lower = raw.toLowerCase();
if (!lower.includes("image dimensions exceed max allowed size")) return null;
const limitMatch = raw.match(IMAGE_DIMENSION_ERROR_RE);
const pathMatch = raw.match(IMAGE_DIMENSION_PATH_RE);
return {
maxDimensionPx: limitMatch?.[1] ? Number.parseInt(limitMatch[1], 10) : undefined,
messageIndex: pathMatch?.[1] ? Number.parseInt(pathMatch[1], 10) : undefined,
contentIndex: pathMatch?.[2] ? Number.parseInt(pathMatch[2], 10) : undefined,
raw,
};
}
export function isImageDimensionErrorMessage(raw: string): boolean {
return Boolean(parseImageDimensionError(raw));
}
export function isCloudCodeAssistFormatError(raw: string): boolean { export function isCloudCodeAssistFormatError(raw: string): boolean {
return !isImageDimensionErrorMessage(raw) && matchesErrorPatterns(raw, ERROR_PATTERNS.format); return matchesErrorPatterns(raw, ERROR_PATTERNS.format);
} }
export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean { export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean {
@@ -426,7 +400,6 @@ export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean
} }
export function classifyFailoverReason(raw: string): FailoverReason | null { export function classifyFailoverReason(raw: string): FailoverReason | null {
if (isImageDimensionErrorMessage(raw)) return null;
if (isRateLimitErrorMessage(raw)) return "rate_limit"; if (isRateLimitErrorMessage(raw)) return "rate_limit";
if (isOverloadedErrorMessage(raw)) return "rate_limit"; if (isOverloadedErrorMessage(raw)) return "rate_limit";
if (isCloudCodeAssistFormatError(raw)) return "format"; if (isCloudCodeAssistFormatError(raw)) return "format";
-8
View File
@@ -17,7 +17,6 @@ import { resolveUserPath } from "../../utils.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js"; import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { resolveSessionAgentIds } from "../agent-scope.js"; import { resolveSessionAgentIds } from "../agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
import { resolveClawdbotDocsPath } from "../docs-path.js";
import type { ExecElevatedDefaults } from "../bash-tools.js"; import type { ExecElevatedDefaults } from "../bash-tools.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js"; import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js";
@@ -251,12 +250,6 @@ export async function compactEmbeddedPiSession(params: {
}); });
const isDefaultAgent = sessionAgentId === defaultAgentId; const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full"; const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const docsPath = await resolveClawdbotDocsPath({
workspaceDir: effectiveWorkspace,
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const appendPrompt = buildEmbeddedSystemPrompt({ const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace, workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel, defaultThinkLevel: params.thinkLevel,
@@ -268,7 +261,6 @@ export async function compactEmbeddedPiSession(params: {
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined, : undefined,
skillsPrompt, skillsPrompt,
docsPath: docsPath ?? undefined,
promptMode, promptMode,
runtimeInfo, runtimeInfo,
sandboxInfo, sandboxInfo,
+1 -21
View File
@@ -31,7 +31,6 @@ import {
isContextOverflowError, isContextOverflowError,
isFailoverAssistantError, isFailoverAssistantError,
isFailoverErrorMessage, isFailoverErrorMessage,
parseImageDimensionError,
isRateLimitAssistantError, isRateLimitAssistantError,
isTimeoutErrorMessage, isTimeoutErrorMessage,
pickFallbackThinkingLevel, pickFallbackThinkingLevel,
@@ -358,26 +357,6 @@ export async function runEmbeddedPiAgent(
const failoverFailure = isFailoverAssistantError(lastAssistant); const failoverFailure = isFailoverAssistantError(lastAssistant);
const assistantFailoverReason = classifyFailoverReason(lastAssistant?.errorMessage ?? ""); const assistantFailoverReason = classifyFailoverReason(lastAssistant?.errorMessage ?? "");
const cloudCodeAssistFormatError = attempt.cloudCodeAssistFormatError; const cloudCodeAssistFormatError = attempt.cloudCodeAssistFormatError;
const imageDimensionError = parseImageDimensionError(lastAssistant?.errorMessage ?? "");
if (imageDimensionError && lastProfileId) {
const details = [
imageDimensionError.messageIndex !== undefined
? `message=${imageDimensionError.messageIndex}`
: null,
imageDimensionError.contentIndex !== undefined
? `content=${imageDimensionError.contentIndex}`
: null,
imageDimensionError.maxDimensionPx !== undefined
? `limit=${imageDimensionError.maxDimensionPx}px`
: null,
]
.filter(Boolean)
.join(" ");
log.warn(
`Profile ${lastProfileId} rejected image payload${details ? ` (${details})` : ""}.`,
);
}
// Treat timeout as potential rate limit (Antigravity hangs on rate limit) // Treat timeout as potential rate limit (Antigravity hangs on rate limit)
const shouldRotate = (!aborted && failoverFailure) || timedOut; const shouldRotate = (!aborted && failoverFailure) || timedOut;
@@ -453,6 +432,7 @@ export async function runEmbeddedPiAgent(
toolMetas: attempt.toolMetas, toolMetas: attempt.toolMetas,
lastAssistant: attempt.lastAssistant, lastAssistant: attempt.lastAssistant,
lastToolError: attempt.lastToolError, lastToolError: attempt.lastToolError,
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
config: params.config, config: params.config,
sessionKey: params.sessionKey ?? params.sessionId, sessionKey: params.sessionKey ?? params.sessionId,
verboseLevel: params.verboseLevel, verboseLevel: params.verboseLevel,
@@ -18,7 +18,6 @@ import { resolveUserPath } from "../../../utils.js";
import { resolveClawdbotAgentDir } from "../../agent-paths.js"; import { resolveClawdbotAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.js"; import { resolveSessionAgentIds } from "../../agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
import { resolveClawdbotDocsPath } from "../../docs-path.js";
import { resolveModelAuthMode } from "../../model-auth.js"; import { resolveModelAuthMode } from "../../model-auth.js";
import { import {
isCloudCodeAssistFormatError, isCloudCodeAssistFormatError,
@@ -217,12 +216,6 @@ export async function runEmbeddedAttempt(
}); });
const isDefaultAgent = sessionAgentId === defaultAgentId; const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full"; const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const docsPath = await resolveClawdbotDocsPath({
workspaceDir: effectiveWorkspace,
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const appendPrompt = buildEmbeddedSystemPrompt({ const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace, workspaceDir: effectiveWorkspace,
@@ -235,7 +228,6 @@ export async function runEmbeddedAttempt(
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt) ? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined, : undefined,
skillsPrompt, skillsPrompt,
docsPath: docsPath ?? undefined,
reactionGuidance, reactionGuidance,
promptMode, promptMode,
runtimeInfo, runtimeInfo,
+2 -23
View File
@@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url";
import type { ImageContent } from "@mariozechner/pi-ai"; import type { ImageContent } from "@mariozechner/pi-ai";
import { assertSandboxPath } from "../../sandbox-paths.js"; import { assertSandboxPath } from "../../sandbox-paths.js";
import { sanitizeImageBlocks } from "../../tool-images.js";
import { extractTextFromMessage } from "../../../tui/tui-formatters.js"; import { extractTextFromMessage } from "../../../tui/tui-formatters.js";
import { loadWebMedia } from "../../../web/media.js"; import { loadWebMedia } from "../../../web/media.js";
import { resolveUserPath } from "../../../utils.js"; import { resolveUserPath } from "../../../utils.js";
@@ -49,17 +48,6 @@ function isImageExtension(filePath: string): boolean {
return IMAGE_EXTENSIONS.has(ext); return IMAGE_EXTENSIONS.has(ext);
} }
async function sanitizeImagesWithLog(
images: ImageContent[],
label: string,
): Promise<ImageContent[]> {
const { images: sanitized, dropped } = await sanitizeImageBlocks(images, label);
if (dropped > 0) {
log.warn(`Native image: dropped ${dropped} image(s) after sanitization (${label}).`);
}
return sanitized;
}
/** /**
* Detects image references in a user prompt. * Detects image references in a user prompt.
* *
@@ -404,18 +392,9 @@ export async function detectAndLoadPromptImages(params: {
} }
} }
const sanitizedPromptImages = await sanitizeImagesWithLog(promptImages, "prompt:images");
const sanitizedHistoryImagesByIndex = new Map<number, ImageContent[]>();
for (const [index, images] of historyImagesByIndex) {
const sanitized = await sanitizeImagesWithLog(images, `history:images:${index}`);
if (sanitized.length > 0) {
sanitizedHistoryImagesByIndex.set(index, sanitized);
}
}
return { return {
images: sanitizedPromptImages, images: promptImages,
historyImagesByIndex: sanitizedHistoryImagesByIndex, historyImagesByIndex,
detectedRefs: allRefs, detectedRefs: allRefs,
loadedCount, loadedCount,
skippedCount, skippedCount,
@@ -147,4 +147,40 @@ describe("buildEmbeddedRunPayloads", () => {
expect(payloads).toHaveLength(1); expect(payloads).toHaveLength(1);
expect(payloads[0]?.text).toBe("All good"); expect(payloads[0]?.text).toBe("All good");
}); });
it("adds tool error fallback when assistant output is NO_REPLY", () => {
const payloads = buildEmbeddedRunPayloads({
assistantTexts: ["NO_REPLY"],
toolMetas: [],
lastAssistant: { stopReason: "end_turn" } as AssistantMessage,
lastToolError: { toolName: "browser", error: "tab not found" },
sessionKey: "session:telegram",
inlineToolResultsAllowed: false,
verboseLevel: "off",
reasoningLevel: "off",
toolResultFormat: "plain",
});
expect(payloads).toHaveLength(1);
expect(payloads[0]?.isError).toBe(true);
expect(payloads[0]?.text).toContain("browser");
expect(payloads[0]?.text).toContain("tab not found");
});
it("skips tool error fallback when messaging tool already sent", () => {
const payloads = buildEmbeddedRunPayloads({
assistantTexts: [],
toolMetas: [],
lastAssistant: undefined,
lastToolError: { toolName: "browser", error: "tab not found" },
didSendViaMessagingTool: true,
sessionKey: "session:telegram",
inlineToolResultsAllowed: false,
verboseLevel: "off",
reasoningLevel: "off",
toolResultFormat: "plain",
});
expect(payloads).toHaveLength(0);
});
}); });
+35 -22
View File
@@ -24,6 +24,7 @@ export function buildEmbeddedRunPayloads(params: {
toolMetas: ToolMetaEntry[]; toolMetas: ToolMetaEntry[];
lastAssistant: AssistantMessage | undefined; lastAssistant: AssistantMessage | undefined;
lastToolError?: { toolName: string; meta?: string; error?: string }; lastToolError?: { toolName: string; meta?: string; error?: string };
didSendViaMessagingTool?: boolean;
config?: ClawdbotConfig; config?: ClawdbotConfig;
sessionKey: string; sessionKey: string;
verboseLevel?: VerboseLevel; verboseLevel?: VerboseLevel;
@@ -156,34 +157,46 @@ export function buildEmbeddedRunPayloads(params: {
}); });
} }
if (replyItems.length === 0 && params.lastToolError) { const buildPayloads = (items: typeof replyItems) => {
const hasAudioAsVoiceTag = items.some((item) => item.audioAsVoice);
return items
.map((item) => ({
text: item.text?.trim() ? item.text.trim() : undefined,
mediaUrls: item.media?.length ? item.media : undefined,
mediaUrl: item.media?.[0],
isError: item.isError,
replyToId: item.replyToId,
replyToTag: item.replyToTag,
replyToCurrent: item.replyToCurrent,
audioAsVoice: item.audioAsVoice || Boolean(hasAudioAsVoiceTag && item.media?.length),
}))
.filter((p) => {
if (!p.text && !p.mediaUrl && (!p.mediaUrls || p.mediaUrls.length === 0)) return false;
if (p.text && isSilentReplyText(p.text, SILENT_REPLY_TOKEN)) return false;
return true;
});
};
let payloads = buildPayloads(replyItems);
if (
payloads.length === 0 &&
params.lastToolError &&
params.didSendViaMessagingTool !== true
) {
const toolSummary = formatToolAggregate( const toolSummary = formatToolAggregate(
params.lastToolError.toolName, params.lastToolError.toolName,
params.lastToolError.meta ? [params.lastToolError.meta] : undefined, params.lastToolError.meta ? [params.lastToolError.meta] : undefined,
{ markdown: useMarkdown }, { markdown: useMarkdown },
); );
const errorSuffix = params.lastToolError.error ? `: ${params.lastToolError.error}` : ""; const errorSuffix = params.lastToolError.error ? `: ${params.lastToolError.error}` : "";
replyItems.push({ payloads = buildPayloads([
text: `⚠️ ${toolSummary} failed${errorSuffix}`, {
isError: true, text: `⚠️ ${toolSummary} failed${errorSuffix}`,
}); isError: true,
},
]);
} }
const hasAudioAsVoiceTag = replyItems.some((item) => item.audioAsVoice); return payloads;
return replyItems
.map((item) => ({
text: item.text?.trim() ? item.text.trim() : undefined,
mediaUrls: item.media?.length ? item.media : undefined,
mediaUrl: item.media?.[0],
isError: item.isError,
replyToId: item.replyToId,
replyToTag: item.replyToTag,
replyToCurrent: item.replyToCurrent,
audioAsVoice: item.audioAsVoice || Boolean(hasAudioAsVoiceTag && item.media?.length),
}))
.filter((p) => {
if (!p.text && !p.mediaUrl && (!p.mediaUrls || p.mediaUrls.length === 0)) return false;
if (p.text && isSilentReplyText(p.text, SILENT_REPLY_TOKEN)) return false;
return true;
});
} }
@@ -15,7 +15,6 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: boolean; reasoningTagHint: boolean;
heartbeatPrompt?: string; heartbeatPrompt?: string;
skillsPrompt?: string; skillsPrompt?: string;
docsPath?: string;
reactionGuidance?: { reactionGuidance?: {
level: "minimal" | "extensive"; level: "minimal" | "extensive";
channel: string; channel: string;
@@ -49,7 +48,6 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: params.reasoningTagHint, reasoningTagHint: params.reasoningTagHint,
heartbeatPrompt: params.heartbeatPrompt, heartbeatPrompt: params.heartbeatPrompt,
skillsPrompt: params.skillsPrompt, skillsPrompt: params.skillsPrompt,
docsPath: params.docsPath,
reactionGuidance: params.reactionGuidance, reactionGuidance: params.reactionGuidance,
promptMode: params.promptMode, promptMode: params.promptMode,
runtimeInfo: params.runtimeInfo, runtimeInfo: params.runtimeInfo,
-17
View File
@@ -32,14 +32,12 @@ describe("buildAgentSystemPrompt", () => {
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>", "<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
heartbeatPrompt: "ping", heartbeatPrompt: "ping",
toolNames: ["message", "memory_search"], toolNames: ["message", "memory_search"],
docsPath: "/tmp/clawd/docs",
extraSystemPrompt: "Subagent details", extraSystemPrompt: "Subagent details",
}); });
expect(prompt).not.toContain("## User Identity"); expect(prompt).not.toContain("## User Identity");
expect(prompt).not.toContain("## Skills"); expect(prompt).not.toContain("## Skills");
expect(prompt).not.toContain("## Memory Recall"); expect(prompt).not.toContain("## Memory Recall");
expect(prompt).not.toContain("## Documentation");
expect(prompt).not.toContain("## Reply Tags"); expect(prompt).not.toContain("## Reply Tags");
expect(prompt).not.toContain("## Messaging"); expect(prompt).not.toContain("## Messaging");
expect(prompt).not.toContain("## Silent Replies"); expect(prompt).not.toContain("## Silent Replies");
@@ -88,7 +86,6 @@ describe("buildAgentSystemPrompt", () => {
toolNames: ["Read", "Exec", "process"], toolNames: ["Read", "Exec", "process"],
skillsPrompt: skillsPrompt:
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>", "<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
docsPath: "/tmp/clawd/docs",
}); });
expect(prompt).toContain("- Read: Read file contents"); expect(prompt).toContain("- Read: Read file contents");
@@ -96,20 +93,6 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain( expect(prompt).toContain(
"Use `Read` to load the SKILL.md at the location listed for that skill.", "Use `Read` to load the SKILL.md at the location listed for that skill.",
); );
expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs");
});
it("includes docs guidance when docsPath is provided", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
docsPath: "/tmp/clawd/docs",
});
expect(prompt).toContain("## Documentation");
expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs");
expect(prompt).toContain(
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
);
}); });
it("includes user time when provided (12-hour)", () => { it("includes user time when provided (12-hour)", () => {
-27
View File
@@ -109,26 +109,6 @@ function buildMessagingSection(params: {
]; ];
} }
function buildDocsSection(params: {
docsPath?: string;
isMinimal: boolean;
readToolName: string;
}) {
const docsPath = params.docsPath?.trim();
if (!docsPath || params.isMinimal) return [];
return [
"## Documentation",
`Clawdbot docs: ${docsPath}`,
"Mirror: https://docs.clawd.bot",
"Source: https://github.com/clawdbot/clawdbot",
"Community: https://discord.com/invite/clawd",
"Find new skills: https://clawdhub.com",
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
"When diagnosing issues, run `clawdbot status` yourself when possible; only ask the user if you lack access (e.g., sandboxed).",
"",
];
}
export function buildAgentSystemPrompt(params: { export function buildAgentSystemPrompt(params: {
workspaceDir: string; workspaceDir: string;
defaultThinkLevel?: ThinkLevel; defaultThinkLevel?: ThinkLevel;
@@ -145,7 +125,6 @@ export function buildAgentSystemPrompt(params: {
contextFiles?: EmbeddedContextFile[]; contextFiles?: EmbeddedContextFile[];
skillsPrompt?: string; skillsPrompt?: string;
heartbeatPrompt?: string; heartbeatPrompt?: string;
docsPath?: string;
/** Controls which hardcoded sections to include. Defaults to "full". */ /** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode; promptMode?: PromptMode;
runtimeInfo?: { runtimeInfo?: {
@@ -316,11 +295,6 @@ export function buildAgentSystemPrompt(params: {
readToolName, readToolName,
}); });
const memorySection = buildMemorySection({ isMinimal, availableTools }); const memorySection = buildMemorySection({ isMinimal, availableTools });
const docsSection = buildDocsSection({
docsPath: params.docsPath,
isMinimal,
readToolName,
});
// For "none" mode, return just the basic identity line // For "none" mode, return just the basic identity line
if (promptMode === "none") { if (promptMode === "none") {
@@ -397,7 +371,6 @@ export function buildAgentSystemPrompt(params: {
`Your working directory is: ${params.workspaceDir}`, `Your working directory is: ${params.workspaceDir}`,
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.", "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
"", "",
...docsSection,
params.sandboxInfo?.enabled ? "## Sandbox" : "", params.sandboxInfo?.enabled ? "## Sandbox" : "",
params.sandboxInfo?.enabled params.sandboxInfo?.enabled
? [ ? [
+1 -51
View File
@@ -1,7 +1,7 @@
import sharp from "sharp"; import sharp from "sharp";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { sanitizeContentBlocksImages, sanitizeImageBlocks } from "./tool-images.js"; import { sanitizeContentBlocksImages } from "./tool-images.js";
describe("tool image sanitizing", () => { describe("tool image sanitizing", () => {
it("shrinks oversized images to <=5MB", async () => { it("shrinks oversized images to <=5MB", async () => {
@@ -33,56 +33,6 @@ describe("tool image sanitizing", () => {
expect(image.mimeType).toBe("image/jpeg"); expect(image.mimeType).toBe("image/jpeg");
}, 20_000); }, 20_000);
it("sanitizes image arrays and reports drops", async () => {
const width = 2600;
const height = 400;
const raw = Buffer.alloc(width * height * 3, 0x7f);
const png = await sharp(raw, {
raw: { width, height, channels: 3 },
})
.png({ compressionLevel: 9 })
.toBuffer();
const images = [
{ type: "image" as const, data: png.toString("base64"), mimeType: "image/png" },
];
const { images: out, dropped } = await sanitizeImageBlocks(images, "test");
expect(dropped).toBe(0);
expect(out.length).toBe(1);
const meta = await sharp(Buffer.from(out[0].data, "base64")).metadata();
expect(meta.width).toBeLessThanOrEqual(2000);
expect(meta.height).toBeLessThanOrEqual(2000);
}, 20_000);
it("shrinks images that exceed max dimension even if size is small", async () => {
const width = 2600;
const height = 400;
const raw = Buffer.alloc(width * height * 3, 0x7f);
const png = await sharp(raw, {
raw: { width, height, channels: 3 },
})
.png({ compressionLevel: 9 })
.toBuffer();
const blocks = [
{
type: "image" as const,
data: png.toString("base64"),
mimeType: "image/png",
},
];
const out = await sanitizeContentBlocksImages(blocks, "test");
const image = out.find((b) => b.type === "image");
if (!image || image.type !== "image") {
throw new Error("expected image block");
}
const meta = await sharp(Buffer.from(image.data, "base64")).metadata();
expect(meta.width).toBeLessThanOrEqual(2000);
expect(meta.height).toBeLessThanOrEqual(2000);
expect(image.mimeType).toBe("image/jpeg");
}, 20_000);
it("corrects mismatched jpeg mimeType", async () => { it("corrects mismatched jpeg mimeType", async () => {
const jpeg = await sharp({ const jpeg = await sharp({
create: { create: {
+12 -55
View File
@@ -1,7 +1,5 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ImageContent } from "@mariozechner/pi-ai";
import { createSubsystemLogger } from "../logging.js";
import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js"; import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js";
type ToolContentBlock = AgentToolResult<unknown>["content"][number]; type ToolContentBlock = AgentToolResult<unknown>["content"][number];
@@ -16,7 +14,6 @@ type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
// and recompress base64 image blocks when they exceed these limits. // and recompress base64 image blocks when they exceed these limits.
const MAX_IMAGE_DIMENSION_PX = 2000; const MAX_IMAGE_DIMENSION_PX = 2000;
const MAX_IMAGE_BYTES = 5 * 1024 * 1024; const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const log = createSubsystemLogger("agents/tool-images");
function isImageBlock(block: unknown): block is ImageContentBlock { function isImageBlock(block: unknown): block is ImageContentBlock {
if (!block || typeof block !== "object") return false; if (!block || typeof block !== "object") return false;
@@ -44,41 +41,26 @@ async function resizeImageBase64IfNeeded(params: {
mimeType: string; mimeType: string;
maxDimensionPx: number; maxDimensionPx: number;
maxBytes: number; maxBytes: number;
label?: string; }): Promise<{ base64: string; mimeType: string; resized: boolean }> {
}): Promise<{
base64: string;
mimeType: string;
resized: boolean;
width?: number;
height?: number;
}> {
const buf = Buffer.from(params.base64, "base64"); const buf = Buffer.from(params.base64, "base64");
const meta = await getImageMetadata(buf); const meta = await getImageMetadata(buf);
const width = meta?.width; const width = meta?.width;
const height = meta?.height; const height = meta?.height;
const overBytes = buf.byteLength > params.maxBytes; const overBytes = buf.byteLength > params.maxBytes;
const hasDimensions = typeof width === "number" && typeof height === "number"; const maxDim = Math.max(width ?? 0, height ?? 0);
if (hasDimensions && !overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) { if (typeof width !== "number" || typeof height !== "number") {
return { if (!overBytes) {
base64: params.base64, return {
mimeType: params.mimeType, base64: params.base64,
resized: false, mimeType: params.mimeType,
width, resized: false,
height, };
}; }
} } else if (!overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
if (hasDimensions && (width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)) { return { base64: params.base64, mimeType: params.mimeType, resized: false };
log.warn("Image exceeds limits; resizing", {
label: params.label,
width,
height,
maxDimensionPx: params.maxDimensionPx,
maxBytes: params.maxBytes,
});
} }
const qualities = [85, 75, 65, 55, 45, 35]; const qualities = [85, 75, 65, 55, 45, 35];
const maxDim = hasDimensions ? Math.max(width ?? 0, height ?? 0) : params.maxDimensionPx;
const sideStart = maxDim > 0 ? Math.min(params.maxDimensionPx, maxDim) : params.maxDimensionPx; const sideStart = maxDim > 0 ? Math.min(params.maxDimensionPx, maxDim) : params.maxDimensionPx;
const sideGrid = [sideStart, 1800, 1600, 1400, 1200, 1000, 800] const sideGrid = [sideStart, 1800, 1600, 1400, 1200, 1000, 800]
.map((v) => Math.min(params.maxDimensionPx, v)) .map((v) => Math.min(params.maxDimensionPx, v))
@@ -98,23 +80,10 @@ async function resizeImageBase64IfNeeded(params: {
smallest = { buffer: out, size: out.byteLength }; smallest = { buffer: out, size: out.byteLength };
} }
if (out.byteLength <= params.maxBytes) { if (out.byteLength <= params.maxBytes) {
log.info("Image resized", {
label: params.label,
width,
height,
maxDimensionPx: params.maxDimensionPx,
maxBytes: params.maxBytes,
originalBytes: buf.byteLength,
resizedBytes: out.byteLength,
quality,
side,
});
return { return {
base64: out.toString("base64"), base64: out.toString("base64"),
mimeType: "image/jpeg", mimeType: "image/jpeg",
resized: true, resized: true,
width,
height,
}; };
} }
} }
@@ -158,7 +127,6 @@ export async function sanitizeContentBlocksImages(
mimeType, mimeType,
maxDimensionPx, maxDimensionPx,
maxBytes, maxBytes,
label,
}); });
out.push({ out.push({
...block, ...block,
@@ -176,17 +144,6 @@ export async function sanitizeContentBlocksImages(
return out; return out;
} }
export async function sanitizeImageBlocks(
images: ImageContent[],
label: string,
opts: { maxDimensionPx?: number; maxBytes?: number } = {},
): Promise<{ images: ImageContent[]; dropped: number }> {
if (images.length === 0) return { images, dropped: 0 };
const sanitized = await sanitizeContentBlocksImages(images as ToolContentBlock[], label, opts);
const next = sanitized.filter(isImageBlock) as ImageContent[];
return { images: next, dropped: Math.max(0, images.length - next.length) };
}
export async function sanitizeToolResultImages( export async function sanitizeToolResultImages(
result: AgentToolResult<unknown>, result: AgentToolResult<unknown>,
label: string, label: string,
+1 -1
View File
@@ -189,7 +189,7 @@ export async function handleBashChatCommand(params: {
}): Promise<ReplyPayload> { }): Promise<ReplyPayload> {
if (params.cfg.commands?.bash !== true) { if (params.cfg.commands?.bash !== true) {
return { return {
text: "⚠️ bash is disabled. Set commands.bash=true to enable. Docs: https://docs.clawd.bot/tools/slash-commands#config", text: "⚠️ bash is disabled. Set commands.bash=true to enable.",
}; };
} }
-87
View File
@@ -1,87 +0,0 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
const callGatewayFromCli = vi.fn(
async (method: string, _opts: unknown, params?: unknown) => {
if (method.endsWith(".get")) {
return {
path: "/tmp/exec-approvals.json",
exists: true,
hash: "hash-1",
file: { version: 1, agents: {} },
};
}
return { method, params };
},
);
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
const defaultRuntime = {
log: (msg: string) => runtimeLogs.push(msg),
error: (msg: string) => runtimeErrors.push(msg),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
};
vi.mock("./gateway-rpc.js", () => ({
callGatewayFromCli: (method: string, opts: unknown, params?: unknown) =>
callGatewayFromCli(method, opts, params),
}));
vi.mock("./nodes-cli/rpc.js", async () => {
const actual = await vi.importActual<typeof import("./nodes-cli/rpc.js")>(
"./nodes-cli/rpc.js",
);
return {
...actual,
resolveNodeId: vi.fn(async () => "node-1"),
};
});
vi.mock("../runtime.js", () => ({
defaultRuntime,
}));
describe("exec approvals CLI", () => {
it("loads gateway approvals by default", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGatewayFromCli.mockClear();
const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js");
const program = new Command();
program.exitOverride();
registerExecApprovalsCli(program);
await program.parseAsync(["approvals", "get"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith(
"exec.approvals.get",
expect.anything(),
{},
);
expect(runtimeErrors).toHaveLength(0);
});
it("loads node approvals when --node is set", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGatewayFromCli.mockClear();
const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js");
const program = new Command();
program.exitOverride();
registerExecApprovalsCli(program);
await program.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith(
"exec.approvals.node.get",
expect.anything(),
{ nodeId: "node-1" },
);
expect(runtimeErrors).toHaveLength(0);
});
});
-243
View File
@@ -1,243 +0,0 @@
import fs from "node:fs/promises";
import JSON5 from "json5";
import type { Command } from "commander";
import type { ExecApprovalsAgent, ExecApprovalsFile } from "../infra/exec-approvals.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { callGatewayFromCli } from "./gateway-rpc.js";
import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js";
import type { NodesRpcOpts } from "./nodes-cli/types.js";
type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file: ExecApprovalsFile;
};
type ExecApprovalsCliOpts = NodesRpcOpts & {
node?: string;
file?: string;
stdin?: boolean;
agent?: string;
};
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
}
return Buffer.concat(chunks).toString("utf8");
}
async function resolveTargetNodeId(opts: ExecApprovalsCliOpts): Promise<string | null> {
const raw = opts.node?.trim() ?? "";
if (!raw) return null;
return await resolveNodeId(opts as NodesRpcOpts, raw);
}
async function loadSnapshot(
opts: ExecApprovalsCliOpts,
nodeId: string | null,
): Promise<ExecApprovalsSnapshot> {
const method = nodeId ? "exec.approvals.node.get" : "exec.approvals.get";
const params = nodeId ? { nodeId } : {};
const snapshot = (await callGatewayFromCli(method, opts, params)) as ExecApprovalsSnapshot;
return snapshot;
}
async function saveSnapshot(
opts: ExecApprovalsCliOpts,
nodeId: string | null,
file: ExecApprovalsFile,
baseHash: string,
): Promise<ExecApprovalsSnapshot> {
const method = nodeId ? "exec.approvals.node.set" : "exec.approvals.set";
const params = nodeId ? { nodeId, file, baseHash } : { file, baseHash };
const snapshot = (await callGatewayFromCli(method, opts, params)) as ExecApprovalsSnapshot;
return snapshot;
}
function resolveAgentKey(value?: string | null): string {
const trimmed = value?.trim() ?? "";
return trimmed ? trimmed : "default";
}
function normalizeAllowlistEntry(entry: { pattern?: string } | null): string | null {
const pattern = entry?.pattern?.trim() ?? "";
return pattern ? pattern : null;
}
function ensureAgent(file: ExecApprovalsFile, agentKey: string): ExecApprovalsAgent {
const agents = file.agents ?? {};
const entry = agents[agentKey] ?? {};
file.agents = agents;
return entry;
}
function isEmptyAgent(agent: ExecApprovalsAgent): boolean {
const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : [];
return (
!agent.security &&
!agent.ask &&
!agent.askFallback &&
agent.autoAllowSkills === undefined &&
allowlist.length === 0
);
}
export function registerExecApprovalsCli(program: Command) {
const approvals = program
.command("approvals")
.alias("exec-approvals")
.description("Manage exec approvals (gateway or node host)")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.clawd.bot/cli/approvals")}\n`,
);
const getCmd = approvals
.command("get")
.description("Fetch exec approvals snapshot")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.action(async (opts: ExecApprovalsCliOpts) => {
const nodeId = await resolveTargetNodeId(opts);
const snapshot = await loadSnapshot(opts, nodeId);
const payload = opts.json ? JSON.stringify(snapshot) : JSON.stringify(snapshot, null, 2);
defaultRuntime.log(payload);
});
nodesCallOpts(getCmd);
const setCmd = approvals
.command("set")
.description("Replace exec approvals with a JSON file")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.option("--file <path>", "Path to JSON file to upload")
.option("--stdin", "Read JSON from stdin", false)
.action(async (opts: ExecApprovalsCliOpts) => {
if (!opts.file && !opts.stdin) {
defaultRuntime.error("Provide --file or --stdin.");
defaultRuntime.exit(1);
return;
}
if (opts.file && opts.stdin) {
defaultRuntime.error("Use either --file or --stdin (not both).");
defaultRuntime.exit(1);
return;
}
const nodeId = await resolveTargetNodeId(opts);
const snapshot = await loadSnapshot(opts, nodeId);
if (!snapshot.hash) {
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
defaultRuntime.exit(1);
return;
}
const raw = opts.stdin ? await readStdin() : await fs.readFile(String(opts.file), "utf8");
let file: ExecApprovalsFile;
try {
file = JSON5.parse(raw) as ExecApprovalsFile;
} catch (err) {
defaultRuntime.error(`Failed to parse approvals JSON: ${String(err)}`);
defaultRuntime.exit(1);
return;
}
file.version = 1;
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
defaultRuntime.log(payload);
});
nodesCallOpts(setCmd);
const allowlist = approvals
.command("allowlist")
.description("Edit the per-agent allowlist");
const allowlistAdd = allowlist
.command("add <pattern>")
.description("Add a glob pattern to an allowlist")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.option("--agent <id>", "Agent id (defaults to \"default\")")
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
const trimmed = pattern.trim();
if (!trimmed) {
defaultRuntime.error("Pattern required.");
defaultRuntime.exit(1);
return;
}
const nodeId = await resolveTargetNodeId(opts);
const snapshot = await loadSnapshot(opts, nodeId);
if (!snapshot.hash) {
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
defaultRuntime.exit(1);
return;
}
const file = snapshot.file ?? { version: 1 };
file.version = 1;
const agentKey = resolveAgentKey(opts.agent);
const agent = ensureAgent(file, agentKey);
const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : [];
if (allowlistEntries.some((entry) => normalizeAllowlistEntry(entry) === trimmed)) {
defaultRuntime.log("Already allowlisted.");
return;
}
allowlistEntries.push({ pattern: trimmed, lastUsedAt: Date.now() });
agent.allowlist = allowlistEntries;
file.agents = { ...file.agents, [agentKey]: agent };
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
defaultRuntime.log(payload);
});
nodesCallOpts(allowlistAdd);
const allowlistRemove = allowlist
.command("remove <pattern>")
.description("Remove a glob pattern from an allowlist")
.option("--node <node>", "Target node id/name/IP (defaults to gateway)")
.option("--agent <id>", "Agent id (defaults to \"default\")")
.action(async (pattern: string, opts: ExecApprovalsCliOpts) => {
const trimmed = pattern.trim();
if (!trimmed) {
defaultRuntime.error("Pattern required.");
defaultRuntime.exit(1);
return;
}
const nodeId = await resolveTargetNodeId(opts);
const snapshot = await loadSnapshot(opts, nodeId);
if (!snapshot.hash) {
defaultRuntime.error("Exec approvals hash missing; reload and retry.");
defaultRuntime.exit(1);
return;
}
const file = snapshot.file ?? { version: 1 };
file.version = 1;
const agentKey = resolveAgentKey(opts.agent);
const agent = ensureAgent(file, agentKey);
const allowlistEntries = Array.isArray(agent.allowlist) ? agent.allowlist : [];
const nextEntries = allowlistEntries.filter(
(entry) => normalizeAllowlistEntry(entry) !== trimmed,
);
if (nextEntries.length === allowlistEntries.length) {
defaultRuntime.log("Pattern not found.");
return;
}
if (nextEntries.length === 0) {
delete agent.allowlist;
} else {
agent.allowlist = nextEntries;
}
if (isEmptyAgent(agent)) {
const agents = { ...file.agents };
delete agents[agentKey];
file.agents = Object.keys(agents).length > 0 ? agents : undefined;
} else {
file.agents = { ...file.agents, [agentKey]: agent };
}
const next = await saveSnapshot(opts, nodeId, file, snapshot.hash);
const payload = opts.json ? JSON.stringify(next) : JSON.stringify(next, null, 2);
defaultRuntime.log(payload);
});
nodesCallOpts(allowlistRemove);
}
+4 -4
View File
@@ -46,7 +46,7 @@ type NodeDaemonStatusOptions = {
}; };
function renderNodeServiceStartHints(): string[] { function renderNodeServiceStartHints(): string[] {
const base = ["clawdbot node service install", "clawdbot node start"]; const base = ["clawdbot node daemon install", "clawdbot node start"];
switch (process.platform) { switch (process.platform) {
case "darwin": case "darwin":
return [ return [
@@ -133,7 +133,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
}; };
if (resolveIsNixMode(process.env)) { if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; service install is disabled."); fail("Nix mode detected; daemon install is disabled.");
return; return;
} }
@@ -168,7 +168,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
}); });
if (!json) { if (!json) {
defaultRuntime.log(`Node service already ${service.loadedText}.`); defaultRuntime.log(`Node service already ${service.loadedText}.`);
defaultRuntime.log("Reinstall with: clawdbot node service install --force"); defaultRuntime.log("Reinstall with: clawdbot node daemon install --force");
} }
return; return;
} }
@@ -244,7 +244,7 @@ export async function runNodeDaemonUninstall(opts: NodeDaemonLifecycleOptions =
}; };
if (resolveIsNixMode(process.env)) { if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; service uninstall is disabled."); fail("Nix mode detected; daemon uninstall is disabled.");
return; return;
} }
+58 -66
View File
@@ -51,71 +51,63 @@ export function registerNodeCli(program: Command) {
}); });
}); });
const registerNodeServiceCommands = (cmd: Command) => {
cmd
.command("status")
.description("Show node service status")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStatus(opts);
});
cmd
.command("install")
.description("Install the node service (launchd/systemd/schtasks)")
.option("--host <host>", "Gateway bridge host")
.option("--port <port>", "Gateway bridge port")
.option("--tls", "Use TLS for the bridge connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")
.option("--display-name <name>", "Override node display name")
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonInstall(opts);
});
cmd
.command("uninstall")
.description("Uninstall the node service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonUninstall(opts);
});
cmd
.command("start")
.description("Start the node service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStart(opts);
});
cmd
.command("stop")
.description("Stop the node service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStop(opts);
});
cmd
.command("restart")
.description("Restart the node service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonRestart(opts);
});
};
const service = node
.command("service")
.description("Manage the headless node service (launchd/systemd/schtasks)");
registerNodeServiceCommands(service);
const daemon = node const daemon = node
.command("daemon", { hidden: true }) .command("daemon")
.description("Legacy alias for node service commands"); .description("Manage the headless node daemon service (launchd/systemd/schtasks)");
registerNodeServiceCommands(daemon);
daemon
.command("status")
.description("Show node daemon status")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStatus(opts);
});
daemon
.command("install")
.description("Install the node daemon service (launchd/systemd/schtasks)")
.option("--host <host>", "Gateway bridge host")
.option("--port <port>", "Gateway bridge port")
.option("--tls", "Use TLS for the bridge connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")
.option("--display-name <name>", "Override node display name")
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonInstall(opts);
});
daemon
.command("uninstall")
.description("Uninstall the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonUninstall(opts);
});
daemon
.command("start")
.description("Start the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStart(opts);
});
daemon
.command("stop")
.description("Stop the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStop(opts);
});
daemon
.command("restart")
.description("Restart the node daemon service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonRestart(opts);
});
} }
-4
View File
@@ -8,7 +8,6 @@ import { registerDaemonCli } from "../daemon-cli.js";
import { registerDnsCli } from "../dns-cli.js"; import { registerDnsCli } from "../dns-cli.js";
import { registerDirectoryCli } from "../directory-cli.js"; import { registerDirectoryCli } from "../directory-cli.js";
import { registerDocsCli } from "../docs-cli.js"; import { registerDocsCli } from "../docs-cli.js";
import { registerExecApprovalsCli } from "../exec-approvals-cli.js";
import { registerGatewayCli } from "../gateway-cli.js"; import { registerGatewayCli } from "../gateway-cli.js";
import { registerHooksCli } from "../hooks-cli.js"; import { registerHooksCli } from "../hooks-cli.js";
import { registerWebhooksCli } from "../webhooks-cli.js"; import { registerWebhooksCli } from "../webhooks-cli.js";
@@ -20,7 +19,6 @@ import { registerPairingCli } from "../pairing-cli.js";
import { registerPluginsCli } from "../plugins-cli.js"; import { registerPluginsCli } from "../plugins-cli.js";
import { registerSandboxCli } from "../sandbox-cli.js"; import { registerSandboxCli } from "../sandbox-cli.js";
import { registerSecurityCli } from "../security-cli.js"; import { registerSecurityCli } from "../security-cli.js";
import { registerServiceCli } from "../service-cli.js";
import { registerSkillsCli } from "../skills-cli.js"; import { registerSkillsCli } from "../skills-cli.js";
import { registerTuiCli } from "../tui-cli.js"; import { registerTuiCli } from "../tui-cli.js";
import { registerUpdateCli } from "../update-cli.js"; import { registerUpdateCli } from "../update-cli.js";
@@ -29,10 +27,8 @@ export function registerSubCliCommands(program: Command) {
registerAcpCli(program); registerAcpCli(program);
registerDaemonCli(program); registerDaemonCli(program);
registerGatewayCli(program); registerGatewayCli(program);
registerServiceCli(program);
registerLogsCli(program); registerLogsCli(program);
registerModelsCli(program); registerModelsCli(program);
registerExecApprovalsCli(program);
registerNodesCli(program); registerNodesCli(program);
registerNodeCli(program); registerNodeCli(program);
registerSandboxCli(program); registerSandboxCli(program);
-59
View File
@@ -1,59 +0,0 @@
import { Command } from "commander";
import { describe, expect, it, vi } from "vitest";
const runDaemonStatus = vi.fn(async () => {});
const runNodeDaemonStatus = vi.fn(async () => {});
vi.mock("./daemon-cli/runners.js", () => ({
runDaemonInstall: vi.fn(async () => {}),
runDaemonRestart: vi.fn(async () => {}),
runDaemonStart: vi.fn(async () => {}),
runDaemonStatus: (opts: unknown) => runDaemonStatus(opts),
runDaemonStop: vi.fn(async () => {}),
runDaemonUninstall: vi.fn(async () => {}),
}));
vi.mock("./node-cli/daemon.js", () => ({
runNodeDaemonInstall: vi.fn(async () => {}),
runNodeDaemonRestart: vi.fn(async () => {}),
runNodeDaemonStart: vi.fn(async () => {}),
runNodeDaemonStatus: (opts: unknown) => runNodeDaemonStatus(opts),
runNodeDaemonStop: vi.fn(async () => {}),
runNodeDaemonUninstall: vi.fn(async () => {}),
}));
vi.mock("./deps.js", () => ({
createDefaultDeps: vi.fn(),
}));
describe("service CLI coverage", () => {
it("routes service gateway status to daemon status", async () => {
runDaemonStatus.mockClear();
runNodeDaemonStatus.mockClear();
const { registerServiceCli } = await import("./service-cli.js");
const program = new Command();
program.exitOverride();
registerServiceCli(program);
await program.parseAsync(["service", "gateway", "status"], { from: "user" });
expect(runDaemonStatus).toHaveBeenCalledTimes(1);
expect(runNodeDaemonStatus).toHaveBeenCalledTimes(0);
});
it("routes service node status to node daemon status", async () => {
runDaemonStatus.mockClear();
runNodeDaemonStatus.mockClear();
const { registerServiceCli } = await import("./service-cli.js");
const program = new Command();
program.exitOverride();
registerServiceCli(program);
await program.parseAsync(["service", "node", "status"], { from: "user" });
expect(runNodeDaemonStatus).toHaveBeenCalledTimes(1);
expect(runDaemonStatus).toHaveBeenCalledTimes(0);
});
});
-157
View File
@@ -1,157 +0,0 @@
import type { Command } from "commander";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { createDefaultDeps } from "./deps.js";
import {
runDaemonInstall,
runDaemonRestart,
runDaemonStart,
runDaemonStatus,
runDaemonStop,
runDaemonUninstall,
} from "./daemon-cli/runners.js";
import {
runNodeDaemonInstall,
runNodeDaemonRestart,
runNodeDaemonStart,
runNodeDaemonStatus,
runNodeDaemonStop,
runNodeDaemonUninstall,
} from "./node-cli/daemon.js";
export function registerServiceCli(program: Command) {
const service = program
.command("service")
.description("Manage Gateway and node host services (launchd/systemd/schtasks)")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/service", "docs.clawd.bot/cli/service")}\n`,
);
const gateway = service.command("gateway").description("Manage the Gateway service");
gateway
.command("status")
.description("Show gateway service status + probe the Gateway")
.option("--url <url>", "Gateway WebSocket URL (defaults to config/remote/local)")
.option("--token <token>", "Gateway token (if required)")
.option("--password <password>", "Gateway password (password auth)")
.option("--timeout <ms>", "Timeout in ms", "10000")
.option("--no-probe", "Skip RPC probe")
.option("--deep", "Scan system-level services", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStatus({
rpc: opts,
probe: Boolean(opts.probe),
deep: Boolean(opts.deep),
json: Boolean(opts.json),
});
});
gateway
.command("install")
.description("Install the Gateway service (launchd/systemd/schtasks)")
.option("--port <port>", "Gateway port")
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
.option("--token <token>", "Gateway token (token auth)")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonInstall(opts);
});
gateway
.command("uninstall")
.description("Uninstall the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonUninstall(opts);
});
gateway
.command("start")
.description("Start the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStart(opts);
});
gateway
.command("stop")
.description("Stop the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonStop(opts);
});
gateway
.command("restart")
.description("Restart the Gateway service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runDaemonRestart(opts);
});
const node = service.command("node").description("Manage the node host service");
node
.command("status")
.description("Show node host service status")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStatus(opts);
});
node
.command("install")
.description("Install the node host service (launchd/systemd/schtasks)")
.option("--host <host>", "Gateway bridge host")
.option("--port <port>", "Gateway bridge port")
.option("--tls", "Use TLS for the bridge connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")
.option("--display-name <name>", "Override node display name")
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
.option("--force", "Reinstall/overwrite if already installed", false)
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonInstall(opts);
});
node
.command("uninstall")
.description("Uninstall the node host service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonUninstall(opts);
});
node
.command("start")
.description("Start the node host service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStart(opts);
});
node
.command("stop")
.description("Stop the node host service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonStop(opts);
});
node
.command("restart")
.description("Restart the node host service (launchd/systemd/schtasks)")
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runNodeDaemonRestart(opts);
});
// Build default deps (parity with daemon CLI).
void createDefaultDeps();
}
+6 -18
View File
@@ -2,9 +2,7 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { withProgress } from "../cli/progress.js"; import { withProgress } from "../cli/progress.js";
import { loadConfig, readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js"; import { loadConfig, readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import type { GatewayService } from "../daemon/service.js";
import { resolveGatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js";
import { resolveNodeService } from "../daemon/node-service.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
import { probeGateway } from "../gateway/probe.js"; import { probeGateway } from "../gateway/probe.js";
@@ -132,9 +130,10 @@ export async function statusAllCommand(
const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null); const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null);
progress.tick(); progress.tick();
progress.setLabel("Checking services…"); progress.setLabel("Checking daemon…");
const readServiceSummary = async (service: GatewayService) => { const daemon = await (async () => {
try { try {
const service = resolveGatewayService();
const [loaded, runtimeInfo, command] = await Promise.all([ const [loaded, runtimeInfo, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false), service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined), service.readRuntime(process.env).catch(() => undefined),
@@ -151,9 +150,7 @@ export async function statusAllCommand(
} catch { } catch {
return null; return null;
} }
}; })();
const daemon = await readServiceSummary(resolveGatewayService());
const nodeService = await readServiceSummary(resolveNodeService());
progress.tick(); progress.tick();
progress.setLabel("Scanning agents…"); progress.setLabel("Scanning agents…");
@@ -343,22 +340,13 @@ export async function statusAllCommand(
: { Item: "Gateway self", Value: "unknown" }, : { Item: "Gateway self", Value: "unknown" },
daemon daemon
? { ? {
Item: "Gateway service", Item: "Daemon",
Value: Value:
daemon.installed === false daemon.installed === false
? `${daemon.label} not installed` ? `${daemon.label} not installed`
: `${daemon.label} ${daemon.installed ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`, : `${daemon.label} ${daemon.installed ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`,
} }
: { Item: "Gateway service", Value: "unknown" }, : { Item: "Daemon", Value: "unknown" },
nodeService
? {
Item: "Node service",
Value:
nodeService.installed === false
? `${nodeService.label} not installed`
: `${nodeService.label} ${nodeService.installed ? "installed · " : ""}${nodeService.loadedText}${nodeService.runtime?.status ? ` · ${nodeService.runtime.status}` : ""}${nodeService.runtime?.pid ? ` (pid ${nodeService.runtime.pid})` : ""}`,
}
: { Item: "Node service", Value: "unknown" },
{ {
Item: "Agents", Item: "Agents",
Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`, Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`,
+3 -18
View File
@@ -15,7 +15,7 @@ import {
} from "../memory/status-format.js"; } from "../memory/status-format.js";
import { formatHealthChannelLines, type HealthSummary } from "./health.js"; import { formatHealthChannelLines, type HealthSummary } from "./health.js";
import { resolveControlUiLinks } from "./onboard-helpers.js"; import { resolveControlUiLinks } from "./onboard-helpers.js";
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js"; import { getDaemonStatusSummary } from "./status.daemon.js";
import { import {
formatAge, formatAge,
formatDuration, formatDuration,
@@ -116,10 +116,6 @@ export async function statusCommand(
: undefined; : undefined;
if (opts.json) { if (opts.json) {
const [daemon, nodeDaemon] = await Promise.all([
getDaemonStatusSummary(),
getNodeDaemonStatusSummary(),
]);
runtime.log( runtime.log(
JSON.stringify( JSON.stringify(
{ {
@@ -138,8 +134,6 @@ export async function statusCommand(
self: gatewaySelf, self: gatewaySelf,
error: gatewayProbe?.error ?? null, error: gatewayProbe?.error ?? null,
}, },
gatewayService: daemon,
nodeService: nodeDaemon,
agents: agentStatus, agents: agentStatus,
securityAudit, securityAudit,
...(health || usage ? { health, usage } : {}), ...(health || usage ? { health, usage } : {}),
@@ -216,20 +210,12 @@ export async function statusCommand(
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`; return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
})(); })();
const [daemon, nodeDaemon] = await Promise.all([ const daemon = await getDaemonStatusSummary();
getDaemonStatusSummary(),
getNodeDaemonStatusSummary(),
]);
const daemonValue = (() => { const daemonValue = (() => {
if (daemon.installed === false) return `${daemon.label} not installed`; if (daemon.installed === false) return `${daemon.label} not installed`;
const installedPrefix = daemon.installed === true ? "installed · " : ""; const installedPrefix = daemon.installed === true ? "installed · " : "";
return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`; return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`;
})(); })();
const nodeDaemonValue = (() => {
if (nodeDaemon.installed === false) return `${nodeDaemon.label} not installed`;
const installedPrefix = nodeDaemon.installed === true ? "installed · " : "";
return `${nodeDaemon.label} ${installedPrefix}${nodeDaemon.loadedText}${nodeDaemon.runtimeShort ? ` · ${nodeDaemon.runtimeShort}` : ""}`;
})();
const defaults = summary.sessions.defaults; const defaults = summary.sessions.defaults;
const defaultCtx = defaults.contextTokens const defaultCtx = defaults.contextTokens
@@ -312,8 +298,7 @@ export async function statusCommand(
Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine, Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine,
}, },
{ Item: "Gateway", Value: gatewayValue }, { Item: "Gateway", Value: gatewayValue },
{ Item: "Gateway service", Value: daemonValue }, { Item: "Daemon", Value: daemonValue },
{ Item: "Node service", Value: nodeDaemonValue },
{ Item: "Agents", Value: agentsValue }, { Item: "Agents", Value: agentsValue },
{ Item: "Memory", Value: memoryValue }, { Item: "Memory", Value: memoryValue },
{ Item: "Probes", Value: probesValue }, { Item: "Probes", Value: probesValue },
+4 -18
View File
@@ -1,20 +1,14 @@
import type { GatewayService } from "../daemon/service.js";
import { resolveGatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js";
import { resolveNodeService } from "../daemon/node-service.js";
import { formatDaemonRuntimeShort } from "./status.format.js"; import { formatDaemonRuntimeShort } from "./status.format.js";
type DaemonStatusSummary = { export async function getDaemonStatusSummary(): Promise<{
label: string; label: string;
installed: boolean | null; installed: boolean | null;
loadedText: string; loadedText: string;
runtimeShort: string | null; runtimeShort: string | null;
}; }> {
async function buildDaemonStatusSummary(
service: GatewayService,
fallbackLabel: string,
): Promise<DaemonStatusSummary> {
try { try {
const service = resolveGatewayService();
const [loaded, runtime, command] = await Promise.all([ const [loaded, runtime, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false), service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined), service.readRuntime(process.env).catch(() => undefined),
@@ -26,18 +20,10 @@ async function buildDaemonStatusSummary(
return { label: service.label, installed, loadedText, runtimeShort }; return { label: service.label, installed, loadedText, runtimeShort };
} catch { } catch {
return { return {
label: fallbackLabel, label: "Daemon",
installed: null, installed: null,
loadedText: "unknown", loadedText: "unknown",
runtimeShort: null, runtimeShort: null,
}; };
} }
} }
export async function getDaemonStatusSummary(): Promise<DaemonStatusSummary> {
return await buildDaemonStatusSummary(resolveGatewayService(), "Daemon");
}
export async function getNodeDaemonStatusSummary(): Promise<DaemonStatusSummary> {
return await buildDaemonStatusSummary(resolveNodeService(), "Node");
}
-15
View File
@@ -243,19 +243,6 @@ vi.mock("../daemon/service.js", () => ({
}), }),
}), }),
})); }));
vi.mock("../daemon/node-service.js", () => ({
resolveNodeService: () => ({
label: "LaunchAgent",
loadedText: "loaded",
notLoadedText: "not loaded",
isLoaded: async () => true,
readRuntime: async () => ({ status: "running", pid: 4321 }),
readCommand: async () => ({
programArguments: ["node", "dist/entry.js", "node-host"],
sourcePath: "/tmp/Library/LaunchAgents/com.clawdbot.node.plist",
}),
}),
}));
vi.mock("../security/audit.js", () => ({ vi.mock("../security/audit.js", () => ({
runSecurityAudit: mocks.runSecurityAudit, runSecurityAudit: mocks.runSecurityAudit,
})); }));
@@ -286,8 +273,6 @@ describe("statusCommand", () => {
expect(payload.sessions.recent[0].flags).toContain("verbose:on"); expect(payload.sessions.recent[0].flags).toContain("verbose:on");
expect(payload.securityAudit.summary.critical).toBe(1); expect(payload.securityAudit.summary.critical).toBe(1);
expect(payload.securityAudit.summary.warn).toBe(1); expect(payload.securityAudit.summary.warn).toBe(1);
expect(payload.gatewayService.label).toBe("LaunchAgent");
expect(payload.nodeService.label).toBe("LaunchAgent");
}); });
it("prints formatted lines otherwise", async () => { it("prints formatted lines otherwise", async () => {
-65
View File
@@ -1,65 +0,0 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("Slack HTTP mode config", () => {
it("accepts HTTP mode when signing secret is configured", () => {
const res = validateConfigObject({
channels: {
slack: {
mode: "http",
signingSecret: "secret",
},
},
});
expect(res.ok).toBe(true);
});
it("rejects HTTP mode without signing secret", () => {
const res = validateConfigObject({
channels: {
slack: {
mode: "http",
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.slack.signingSecret");
}
});
it("accepts account HTTP mode when base signing secret is set", () => {
const res = validateConfigObject({
channels: {
slack: {
signingSecret: "secret",
accounts: {
ops: {
mode: "http",
},
},
},
},
});
expect(res.ok).toBe(true);
});
it("rejects account HTTP mode without signing secret", () => {
const res = validateConfigObject({
channels: {
slack: {
accounts: {
ops: {
mode: "http",
},
},
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("channels.slack.accounts.ops.signingSecret");
}
});
});
-6
View File
@@ -70,12 +70,6 @@ export type SlackThreadConfig = {
export type SlackAccountConfig = { export type SlackAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */ /** Optional display name for this account (used in CLI/UI lists). */
name?: string; name?: string;
/** Slack connection mode (socket|http). Default: socket. */
mode?: "socket" | "http";
/** Slack signing secret (required for HTTP mode). */
signingSecret?: string;
/** Slack Events API webhook path (default: /slack/events). */
webhookPath?: string;
/** Optional provider capability tags used for agent/runtime guidance. */ /** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[]; capabilities?: string[];
/** Override native command registration for Slack (bool or "auto"). */ /** Override native command registration for Slack (bool or "auto"). */
-31
View File
@@ -258,9 +258,6 @@ export const SlackThreadSchema = z.object({
export const SlackAccountSchema = z.object({ export const SlackAccountSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
mode: z.enum(["socket", "http"]).optional(),
signingSecret: z.string().optional(),
webhookPath: z.string().optional(),
capabilities: z.array(z.string()).optional(), capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
commands: ProviderCommandsSchema, commands: ProviderCommandsSchema,
@@ -308,35 +305,7 @@ export const SlackAccountSchema = z.object({
}); });
export const SlackConfigSchema = SlackAccountSchema.extend({ export const SlackConfigSchema = SlackAccountSchema.extend({
mode: z.enum(["socket", "http"]).optional().default("socket"),
signingSecret: z.string().optional(),
webhookPath: z.string().optional().default("/slack/events"),
accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(), accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => {
const baseMode = value.mode ?? "socket";
if (baseMode === "http" && !value.signingSecret) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'channels.slack.mode="http" requires channels.slack.signingSecret',
path: ["signingSecret"],
});
}
if (!value.accounts) return;
for (const [accountId, account] of Object.entries(value.accounts)) {
if (!account) continue;
if (account.enabled === false) continue;
const accountMode = account.mode ?? baseMode;
if (accountMode !== "http") continue;
const accountSecret = account.signingSecret ?? value.signingSecret;
if (!accountSecret) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
'channels.slack.accounts.*.mode="http" requires channels.slack.signingSecret or channels.slack.accounts.*.signingSecret',
path: ["accounts", accountId, "signingSecret"],
});
}
}
}); });
export const SignalAccountSchemaBase = z.object({ export const SignalAccountSchemaBase = z.object({
-71
View File
@@ -1,71 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const agentCommand = vi.fn();
vi.mock("../commands/agent.js", () => ({ agentCommand }));
const { runBootOnce } = await import("./boot.js");
const { resolveMainSessionKey } = await import("../config/sessions/main-session.js");
describe("runBootOnce", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const makeDeps = () => ({
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn(),
sendMessageDiscord: vi.fn(),
sendMessageSlack: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
});
it("skips when BOOT.md is missing", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
await expect(
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
).resolves.toEqual({ status: "skipped", reason: "missing" });
expect(agentCommand).not.toHaveBeenCalled();
await fs.rm(workspaceDir, { recursive: true, force: true });
});
it("skips when BOOT.md is empty", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), " \n", "utf-8");
await expect(
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
).resolves.toEqual({ status: "skipped", reason: "empty" });
expect(agentCommand).not.toHaveBeenCalled();
await fs.rm(workspaceDir, { recursive: true, force: true });
});
it("runs agent command when BOOT.md exists", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-"));
const content = "Say hello when you wake up.";
await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8");
agentCommand.mockResolvedValue(undefined);
await expect(
runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }),
).resolves.toEqual({ status: "ran" });
expect(agentCommand).toHaveBeenCalledTimes(1);
const call = agentCommand.mock.calls[0]?.[0];
expect(call).toEqual(
expect.objectContaining({
deliver: false,
sessionKey: resolveMainSessionKey({}),
}),
);
expect(call?.message).toContain("BOOT.md:");
expect(call?.message).toContain(content);
expect(call?.message).toContain("NO_REPLY");
await fs.rm(workspaceDir, { recursive: true, force: true });
});
});
-92
View File
@@ -1,92 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import type { CliDeps } from "../cli/deps.js";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveMainSessionKey } from "../config/sessions/main-session.js";
import { agentCommand } from "../commands/agent.js";
import { createSubsystemLogger } from "../logging.js";
import { type RuntimeEnv, defaultRuntime } from "../runtime.js";
const log = createSubsystemLogger("gateway/boot");
const BOOT_FILENAME = "BOOT.md";
export type BootRunResult =
| { status: "skipped"; reason: "missing" | "empty" }
| { status: "ran" }
| { status: "failed"; reason: string };
function buildBootPrompt(content: string) {
return [
"You are running a boot check. Follow BOOT.md instructions exactly.",
"",
"BOOT.md:",
content,
"",
"If BOOT.md asks you to send a message, use the message tool (action=send with channel + target).",
"Use the `target` field (not `to`) for message tool destinations.",
`After sending with the message tool, reply with ONLY: ${SILENT_REPLY_TOKEN}.`,
`If nothing needs attention, reply with ONLY: ${SILENT_REPLY_TOKEN}.`,
].join("\n");
}
async function loadBootFile(
workspaceDir: string,
): Promise<{ content?: string; status: "ok" | "missing" | "empty" }> {
const bootPath = path.join(workspaceDir, BOOT_FILENAME);
try {
const content = await fs.readFile(bootPath, "utf-8");
const trimmed = content.trim();
if (!trimmed) return { status: "empty" };
return { status: "ok", content: trimmed };
} catch (err) {
const anyErr = err as { code?: string };
if (anyErr.code === "ENOENT") return { status: "missing" };
throw err;
}
}
export async function runBootOnce(params: {
cfg: ClawdbotConfig;
deps: CliDeps;
workspaceDir: string;
}): Promise<BootRunResult> {
const bootRuntime: RuntimeEnv = {
log: () => {},
error: (message) => log.error(String(message)),
exit: defaultRuntime.exit,
};
let result: Awaited<ReturnType<typeof loadBootFile>>;
try {
result = await loadBootFile(params.workspaceDir);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.error(`boot: failed to read ${BOOT_FILENAME}: ${message}`);
return { status: "failed", reason: message };
}
if (result.status === "missing" || result.status === "empty") {
return { status: "skipped", reason: result.status };
}
const sessionKey = resolveMainSessionKey(params.cfg);
const message = buildBootPrompt(result.content ?? "");
try {
await agentCommand(
{
message,
sessionKey,
deliver: false,
},
bootRuntime,
params.deps,
);
return { status: "ran" };
} catch (err) {
const messageText = err instanceof Error ? err.message : String(err);
log.error(`boot: agent run failed: ${messageText}`);
return { status: "failed", reason: messageText };
}
}
-10
View File
@@ -58,10 +58,6 @@ import {
CronUpdateParamsSchema, CronUpdateParamsSchema,
type ExecApprovalsGetParams, type ExecApprovalsGetParams,
ExecApprovalsGetParamsSchema, ExecApprovalsGetParamsSchema,
type ExecApprovalsNodeGetParams,
ExecApprovalsNodeGetParamsSchema,
type ExecApprovalsNodeSetParams,
ExecApprovalsNodeSetParamsSchema,
type ExecApprovalsSetParams, type ExecApprovalsSetParams,
ExecApprovalsSetParamsSchema, ExecApprovalsSetParamsSchema,
type ExecApprovalsSnapshot, type ExecApprovalsSnapshot,
@@ -245,12 +241,6 @@ export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>( export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
ExecApprovalsSetParamsSchema, ExecApprovalsSetParamsSchema,
); );
export const validateExecApprovalsNodeGetParams = ajv.compile<ExecApprovalsNodeGetParams>(
ExecApprovalsNodeGetParamsSchema,
);
export const validateExecApprovalsNodeSetParams = ajv.compile<ExecApprovalsNodeSetParams>(
ExecApprovalsNodeSetParamsSchema,
);
export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema); export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema);
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema); export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema); export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
@@ -70,19 +70,3 @@ export const ExecApprovalsSetParamsSchema = Type.Object(
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );
export const ExecApprovalsNodeGetParamsSchema = Type.Object(
{
nodeId: NonEmptyString,
},
{ additionalProperties: false },
);
export const ExecApprovalsNodeSetParamsSchema = Type.Object(
{
nodeId: NonEmptyString,
file: ExecApprovalsFileSchema,
baseHash: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
@@ -49,8 +49,6 @@ import {
} from "./cron.js"; } from "./cron.js";
import { import {
ExecApprovalsGetParamsSchema, ExecApprovalsGetParamsSchema,
ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSetParamsSchema, ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema, ExecApprovalsSnapshotSchema,
} from "./exec-approvals.js"; } from "./exec-approvals.js";
@@ -179,8 +177,6 @@ export const ProtocolSchemas: Record<string, TSchema> = {
LogsTailResult: LogsTailResultSchema, LogsTailResult: LogsTailResultSchema,
ExecApprovalsGetParams: ExecApprovalsGetParamsSchema, ExecApprovalsGetParams: ExecApprovalsGetParamsSchema,
ExecApprovalsSetParams: ExecApprovalsSetParamsSchema, ExecApprovalsSetParams: ExecApprovalsSetParamsSchema,
ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema, ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
ChatHistoryParams: ChatHistoryParamsSchema, ChatHistoryParams: ChatHistoryParamsSchema,
ChatSendParams: ChatSendParamsSchema, ChatSendParams: ChatSendParamsSchema,
-4
View File
@@ -47,8 +47,6 @@ import type {
} from "./cron.js"; } from "./cron.js";
import type { import type {
ExecApprovalsGetParamsSchema, ExecApprovalsGetParamsSchema,
ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSetParamsSchema, ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema, ExecApprovalsSnapshotSchema,
} from "./exec-approvals.js"; } from "./exec-approvals.js";
@@ -172,8 +170,6 @@ export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
export type LogsTailResult = Static<typeof LogsTailResultSchema>; export type LogsTailResult = Static<typeof LogsTailResultSchema>;
export type ExecApprovalsGetParams = Static<typeof ExecApprovalsGetParamsSchema>; export type ExecApprovalsGetParams = Static<typeof ExecApprovalsGetParamsSchema>;
export type ExecApprovalsSetParams = Static<typeof ExecApprovalsSetParamsSchema>; export type ExecApprovalsSetParams = Static<typeof ExecApprovalsSetParamsSchema>;
export type ExecApprovalsNodeGetParams = Static<typeof ExecApprovalsNodeGetParamsSchema>;
export type ExecApprovalsNodeSetParams = Static<typeof ExecApprovalsNodeSetParamsSchema>;
export type ExecApprovalsSnapshot = Static<typeof ExecApprovalsSnapshotSchema>; export type ExecApprovalsSnapshot = Static<typeof ExecApprovalsSnapshotSchema>;
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>; export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>; export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
-2
View File
@@ -8,7 +8,6 @@ import type { WebSocketServer } from "ws";
import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
import type { CanvasHostHandler } from "../canvas-host/server.js"; import type { CanvasHostHandler } from "../canvas-host/server.js";
import type { createSubsystemLogger } from "../logging.js"; import type { createSubsystemLogger } from "../logging.js";
import { handleSlackHttpRequest } from "../slack/http/index.js";
import { handleControlUiHttpRequest } from "./control-ui.js"; import { handleControlUiHttpRequest } from "./control-ui.js";
import { import {
extractHookToken, extractHookToken,
@@ -209,7 +208,6 @@ export function createGatewayHttpServer(opts: {
void (async () => { void (async () => {
if (await handleHooksRequest(req, res)) return; if (await handleHooksRequest(req, res)) return;
if (await handleSlackHttpRequest(req, res)) return;
if (handlePluginRequest && (await handlePluginRequest(req, res))) return; if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
if (openAiChatCompletionsEnabled) { if (openAiChatCompletionsEnabled) {
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return; if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return;
-2
View File
@@ -14,8 +14,6 @@ const BASE_METHODS = [
"config.schema", "config.schema",
"exec.approvals.get", "exec.approvals.get",
"exec.approvals.set", "exec.approvals.set",
"exec.approvals.node.get",
"exec.approvals.node.set",
"wizard.start", "wizard.start",
"wizard.next", "wizard.next",
"wizard.cancel", "wizard.cancel",
@@ -12,11 +12,8 @@ import {
errorShape, errorShape,
formatValidationErrors, formatValidationErrors,
validateExecApprovalsGetParams, validateExecApprovalsGetParams,
validateExecApprovalsNodeGetParams,
validateExecApprovalsNodeSetParams,
validateExecApprovalsSetParams, validateExecApprovalsSetParams,
} from "../protocol/index.js"; } from "../protocol/index.js";
import { respondUnavailableOnThrow, safeParseJson } from "./nodes.helpers.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js";
function resolveBaseHash(params: unknown): string | null { function resolveBaseHash(params: unknown): string | null {
@@ -155,94 +152,4 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
undefined, undefined,
); );
}, },
"exec.approvals.node.get": async ({ params, respond, context }) => {
if (!validateExecApprovalsNodeGetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid exec.approvals.node.get params: ${formatValidationErrors(validateExecApprovalsNodeGetParams.errors)}`,
),
);
return;
}
const bridge = context.bridge;
if (!bridge) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
return;
}
const { nodeId } = params as { nodeId: string };
const id = nodeId.trim();
if (!id) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
return;
}
await respondUnavailableOnThrow(respond, async () => {
const res = await bridge.invoke({
nodeId: id,
command: "system.execApprovals.get",
paramsJSON: "{}",
});
if (!res.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", {
details: { nodeError: res.error ?? null },
}),
);
return;
}
const payload = safeParseJson(res.payloadJSON ?? null);
respond(true, payload, undefined);
});
},
"exec.approvals.node.set": async ({ params, respond, context }) => {
if (!validateExecApprovalsNodeSetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid exec.approvals.node.set params: ${formatValidationErrors(validateExecApprovalsNodeSetParams.errors)}`,
),
);
return;
}
const bridge = context.bridge;
if (!bridge) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
return;
}
const { nodeId, file, baseHash } = params as {
nodeId: string;
file: ExecApprovalsFile;
baseHash?: string;
};
const id = nodeId.trim();
if (!id) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
return;
}
await respondUnavailableOnThrow(respond, async () => {
const res = await bridge.invoke({
nodeId: id,
command: "system.execApprovals.set",
paramsJSON: JSON.stringify({ file, baseHash }),
});
if (!res.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", {
details: { nodeError: res.error ?? null },
}),
);
return;
}
const payload = safeParseJson(res.payloadJSON ?? null);
respond(true, payload, undefined);
});
},
}; };
+3 -1
View File
@@ -20,7 +20,9 @@ import type { DedupeEntry } from "./server-shared.js";
import type { PluginRegistry } from "../plugins/registry.js"; import type { PluginRegistry } from "../plugins/registry.js";
export async function createGatewayRuntimeState(params: { export async function createGatewayRuntimeState(params: {
cfg: import("../config/config.js").ClawdbotConfig; cfg: {
canvasHost?: { root?: string; enabled?: boolean; liveReload?: boolean };
};
bindHost: string; bindHost: string;
port: number; port: number;
controlUiEnabled: boolean; controlUiEnabled: boolean;
+1 -16
View File
@@ -8,11 +8,7 @@ import {
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
import type { loadConfig } from "../config/config.js"; import type { loadConfig } from "../config/config.js";
import { startGmailWatcher } from "../hooks/gmail-watcher.js"; import { startGmailWatcher } from "../hooks/gmail-watcher.js";
import { import { clearInternalHooks } from "../hooks/internal-hooks.js";
clearInternalHooks,
createInternalHookEvent,
triggerInternalHook,
} from "../hooks/internal-hooks.js";
import { loadInternalHooks } from "../hooks/loader.js"; import { loadInternalHooks } from "../hooks/loader.js";
import type { loadClawdbotPlugins } from "../plugins/loader.js"; import type { loadClawdbotPlugins } from "../plugins/loader.js";
import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js"; import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js";
@@ -126,17 +122,6 @@ export async function startGatewaySidecars(params: {
); );
} }
if (params.cfg.hooks?.internal?.enabled) {
setTimeout(() => {
const hookEvent = createInternalHookEvent("gateway", "startup", "gateway:startup", {
cfg: params.cfg,
deps: params.deps,
workspaceDir: params.defaultWorkspaceDir,
});
void triggerInternalHook(hookEvent);
}, 250);
}
let pluginServices: PluginServicesHandle | null = null; let pluginServices: PluginServicesHandle | null = null;
try { try {
pluginServices = await startPluginServices({ pluginServices = await startPluginServices({
+1 -16
View File
@@ -47,20 +47,6 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
clawdbot hooks enable soul-evil clawdbot hooks enable soul-evil
``` ```
### 🚀 boot-md
Runs `BOOT.md` whenever the gateway starts (after channels start).
**Events**: `gateway:startup`
**What it does**: Executes BOOT.md instructions via the agent runner.
**Output**: Whatever the instructions request (for example, outbound messages).
**Enable**:
```bash
clawdbot hooks enable boot-md
```
## Hook Structure ## Hook Structure
Each hook is a directory containing: Each hook is a directory containing:
@@ -170,7 +156,6 @@ Currently supported events:
- **command:reset**: `/reset` command - **command:reset**: `/reset` command
- **command:stop**: `/stop` command - **command:stop**: `/stop` command
- **agent:bootstrap**: Before workspace bootstrap files are injected - **agent:bootstrap**: Before workspace bootstrap files are injected
- **gateway:startup**: Gateway startup (after channels start)
More event types coming soon (session lifecycle, agent errors, etc.). More event types coming soon (session lifecycle, agent errors, etc.).
@@ -180,7 +165,7 @@ Hook handlers receive an `InternalHookEvent` object:
```typescript ```typescript
interface InternalHookEvent { interface InternalHookEvent {
type: "command" | "session" | "agent" | "gateway"; type: "command" | "session" | "agent";
action: string; // e.g., 'new', 'reset', 'stop' action: string; // e.g., 'new', 'reset', 'stop'
sessionKey: string; sessionKey: string;
context: Record<string, unknown>; context: Record<string, unknown>;
-19
View File
@@ -1,19 +0,0 @@
---
name: boot-md
description: "Run BOOT.md on gateway startup"
homepage: https://docs.clawd.bot/hooks#boot-md
metadata:
{
"clawdbot":
{
"emoji": "🚀",
"events": ["gateway:startup"],
"requires": { "config": ["workspace.dir"] },
"install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled with Clawdbot" }],
},
}
---
# Boot Checklist Hook
Runs `BOOT.md` every time the gateway starts, if the file exists in the workspace.
-27
View File
@@ -1,27 +0,0 @@
import type { CliDeps } from "../../../cli/deps.js";
import { createDefaultDeps } from "../../../cli/deps.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import { runBootOnce } from "../../../gateway/boot.js";
import type { HookHandler } from "../../hooks.js";
type BootHookContext = {
cfg?: ClawdbotConfig;
workspaceDir?: string;
deps?: CliDeps;
};
const runBootChecklist: HookHandler = async (event) => {
if (event.type !== "gateway" || event.action !== "startup") {
return;
}
const context = (event.context ?? {}) as BootHookContext;
if (!context.cfg || !context.workspaceDir) {
return;
}
const deps = context.deps ?? createDefaultDeps();
await runBootOnce({ cfg: context.cfg, deps, workspaceDir: context.workspaceDir });
};
export default runBootChecklist;
+2 -2
View File
@@ -8,7 +8,7 @@
import type { WorkspaceBootstrapFile } from "../agents/workspace.js"; import type { WorkspaceBootstrapFile } from "../agents/workspace.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
export type InternalHookEventType = "command" | "session" | "agent" | "gateway"; export type InternalHookEventType = "command" | "session" | "agent";
export type AgentBootstrapHookContext = { export type AgentBootstrapHookContext = {
workspaceDir: string; workspaceDir: string;
@@ -26,7 +26,7 @@ export type AgentBootstrapHookEvent = InternalHookEvent & {
}; };
export interface InternalHookEvent { export interface InternalHookEvent {
/** The type of event (command, session, agent, gateway, etc.) */ /** The type of event (command, session, agent, etc.) */
type: InternalHookEventType; type: InternalHookEventType;
/** The specific action within the type (e.g., 'new', 'reset', 'stop') */ /** The specific action within the type (e.g., 'new', 'reset', 'stop') */
action: string; action: string;
-108
View File
@@ -1,108 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
matchAllowlist,
maxAsk,
minSecurity,
resolveCommandResolution,
type ExecAllowlistEntry,
} from "./exec-approvals.js";
function makeTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-exec-approvals-"));
}
describe("exec approvals allowlist matching", () => {
it("matches by executable name (case-insensitive)", () => {
const resolution = {
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
};
const entries: ExecAllowlistEntry[] = [{ pattern: "RG" }];
const match = matchAllowlist(entries, resolution);
expect(match?.pattern).toBe("RG");
});
it("matches by resolved path with **", () => {
const resolution = {
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
};
const entries: ExecAllowlistEntry[] = [{ pattern: "/opt/**/rg" }];
const match = matchAllowlist(entries, resolution);
expect(match?.pattern).toBe("/opt/**/rg");
});
it("does not let * cross path separators", () => {
const resolution = {
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
};
const entries: ExecAllowlistEntry[] = [{ pattern: "/opt/*/rg" }];
const match = matchAllowlist(entries, resolution);
expect(match).toBeNull();
});
it("falls back to raw executable when no resolved path", () => {
const resolution = {
rawExecutable: "bin/rg",
resolvedPath: undefined,
executableName: "rg",
};
const entries: ExecAllowlistEntry[] = [{ pattern: "bin/rg" }];
const match = matchAllowlist(entries, resolution);
expect(match?.pattern).toBe("bin/rg");
});
});
describe("exec approvals command resolution", () => {
it("resolves PATH executables", () => {
const dir = makeTempDir();
const binDir = path.join(dir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const exe = path.join(binDir, "rg");
fs.writeFileSync(exe, "");
const res = resolveCommandResolution("rg -n foo", undefined, { PATH: binDir });
expect(res?.resolvedPath).toBe(exe);
expect(res?.executableName).toBe("rg");
});
it("resolves relative paths against cwd", () => {
const dir = makeTempDir();
const cwd = path.join(dir, "project");
const script = path.join(cwd, "scripts", "run.sh");
fs.mkdirSync(path.dirname(script), { recursive: true });
fs.writeFileSync(script, "");
const res = resolveCommandResolution("./scripts/run.sh --flag", cwd, undefined);
expect(res?.resolvedPath).toBe(script);
});
it("parses quoted executables", () => {
const dir = makeTempDir();
const cwd = path.join(dir, "project");
const script = path.join(cwd, "bin", "tool");
fs.mkdirSync(path.dirname(script), { recursive: true });
fs.writeFileSync(script, "");
const res = resolveCommandResolution("\"./bin/tool\" --version", cwd, undefined);
expect(res?.resolvedPath).toBe(script);
});
});
describe("exec approvals policy helpers", () => {
it("minSecurity returns the more restrictive value", () => {
expect(minSecurity("deny", "full")).toBe("deny");
expect(minSecurity("allowlist", "full")).toBe("allowlist");
});
it("maxAsk returns the more aggressive ask mode", () => {
expect(maxAsk("off", "always")).toBe("always");
expect(maxAsk("on-miss", "off")).toBe("on-miss");
});
});
+1 -1
View File
@@ -242,7 +242,7 @@ function parseFirstToken(command: string): string | null {
if (end > 1) return trimmed.slice(1, end); if (end > 1) return trimmed.slice(1, end);
return trimmed.slice(1); return trimmed.slice(1);
} }
const match = /^[^\s]+/.exec(trimmed); const match = /^[^\\s]+/.exec(trimmed);
return match ? match[0] : null; return match ? match[0] : null;
} }
@@ -1,114 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import type { MsgContext } from "../auto-reply/templating.js";
import {
buildProviderRegistry,
createMediaAttachmentCache,
normalizeMediaAttachments,
runCapability,
} from "./runner.js";
describe("runCapability auto audio entries", () => {
it("uses provider keys to auto-enable audio transcription", async () => {
const tmpPath = path.join(os.tmpdir(), `clawdbot-auto-audio-${Date.now()}.wav`);
await fs.writeFile(tmpPath, Buffer.from("RIFF"));
const ctx: MsgContext = { MediaPath: tmpPath, MediaType: "audio/wav" };
const media = normalizeMediaAttachments(ctx);
const cache = createMediaAttachmentCache(media);
let seenModel: string | undefined;
const providerRegistry = buildProviderRegistry({
openai: {
id: "openai",
capabilities: ["audio"],
transcribeAudio: async (req) => {
seenModel = req.model;
return { text: "ok", model: req.model };
},
},
});
const cfg = {
models: {
providers: {
openai: {
apiKey: "test-key",
models: [],
},
},
},
} as unknown as ClawdbotConfig;
try {
const result = await runCapability({
capability: "audio",
cfg,
ctx,
attachments: cache,
media,
providerRegistry,
});
expect(result.outputs[0]?.text).toBe("ok");
expect(seenModel).toBe("whisper-1");
expect(result.decision.outcome).toBe("success");
} finally {
await cache.cleanup();
await fs.unlink(tmpPath).catch(() => {});
}
});
it("skips auto audio when disabled", async () => {
const tmpPath = path.join(os.tmpdir(), `clawdbot-auto-audio-${Date.now()}.wav`);
await fs.writeFile(tmpPath, Buffer.from("RIFF"));
const ctx: MsgContext = { MediaPath: tmpPath, MediaType: "audio/wav" };
const media = normalizeMediaAttachments(ctx);
const cache = createMediaAttachmentCache(media);
const providerRegistry = buildProviderRegistry({
openai: {
id: "openai",
capabilities: ["audio"],
transcribeAudio: async () => ({ text: "ok", model: "whisper-1" }),
},
});
const cfg = {
models: {
providers: {
openai: {
apiKey: "test-key",
models: [],
},
},
},
tools: {
media: {
audio: {
enabled: false,
},
},
},
} as unknown as ClawdbotConfig;
try {
const result = await runCapability({
capability: "audio",
cfg,
ctx,
attachments: cache,
media,
providerRegistry,
});
expect(result.outputs).toHaveLength(0);
expect(result.decision.outcome).toBe("disabled");
} finally {
await cache.cleanup();
await fs.unlink(tmpPath).catch(() => {});
}
});
});
+2 -35
View File
@@ -39,8 +39,6 @@ import {
import { describeImageWithModel } from "./providers/image.js"; import { describeImageWithModel } from "./providers/image.js";
import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js"; import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js";
const AUTO_AUDIO_PROVIDERS = ["openai", "groq", "deepgram"] as const;
export type ActiveMediaModel = { export type ActiveMediaModel = {
provider: string; provider: string;
model?: string; model?: string;
@@ -67,29 +65,6 @@ export function createMediaAttachmentCache(attachments: MediaAttachment[]): Medi
return new MediaAttachmentCache(attachments); return new MediaAttachmentCache(attachments);
} }
async function resolveAutoAudioEntries(params: {
cfg: ClawdbotConfig;
agentDir?: string;
providerRegistry: ProviderRegistry;
}): Promise<MediaUnderstandingModelConfig[]> {
const entries: MediaUnderstandingModelConfig[] = [];
for (const providerId of AUTO_AUDIO_PROVIDERS) {
const provider = getMediaUnderstandingProvider(providerId, params.providerRegistry);
if (!provider?.transcribeAudio) continue;
try {
await resolveApiKeyForProvider({
provider: providerId,
cfg: params.cfg,
agentDir: params.agentDir,
});
entries.push({ type: "provider", provider: providerId });
} catch {
continue;
}
}
return entries;
}
function trimOutput(text: string, maxChars?: number): string { function trimOutput(text: string, maxChars?: number): string {
const trimmed = text.trim(); const trimmed = text.trim();
if (!maxChars || trimmed.length <= maxChars) return trimmed; if (!maxChars || trimmed.length <= maxChars) return trimmed;
@@ -586,15 +561,7 @@ export async function runCapability(params: {
providerRegistry: params.providerRegistry, providerRegistry: params.providerRegistry,
activeModel: params.activeModel, activeModel: params.activeModel,
}); });
let resolvedEntries = entries; if (entries.length === 0) {
if (resolvedEntries.length === 0 && capability === "audio") {
resolvedEntries = await resolveAutoAudioEntries({
cfg,
agentDir: params.agentDir,
providerRegistry: params.providerRegistry,
});
}
if (resolvedEntries.length === 0) {
return { return {
outputs: [], outputs: [],
decision: { decision: {
@@ -616,7 +583,7 @@ export async function runCapability(params: {
agentDir: params.agentDir, agentDir: params.agentDir,
providerRegistry: params.providerRegistry, providerRegistry: params.providerRegistry,
cache: params.attachments, cache: params.attachments,
entries: resolvedEntries, entries,
config, config,
}); });
if (output) outputs.push(output); if (output) outputs.push(output);
-93
View File
@@ -1,93 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getMemorySearchManager, type MemoryIndexManager } from "./index.js";
import { buildFileEntry } from "./internal.js";
vi.mock("./embeddings.js", () => {
return {
createEmbeddingProvider: async () => ({
requestedProvider: "openai",
provider: {
id: "mock",
model: "mock-embed",
embedQuery: async () => [0.1, 0.2, 0.3],
embedBatch: async (texts: string[]) => texts.map((_, index) => [index + 1, 0, 0]),
},
}),
};
});
describe("memory vector dedupe", () => {
let workspaceDir: string;
let indexPath: string;
let manager: MemoryIndexManager | null = null;
beforeEach(async () => {
workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-mem-"));
indexPath = path.join(workspaceDir, "index.sqlite");
await fs.mkdir(path.join(workspaceDir, "memory"));
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Hello memory.");
});
afterEach(async () => {
if (manager) {
await manager.close();
manager = null;
}
await fs.rm(workspaceDir, { recursive: true, force: true });
});
it("deletes existing vector rows before inserting replacements", async () => {
const cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: indexPath, vector: { enabled: true } },
sync: { watch: false, onSessionStart: false, onSearch: false },
cache: { enabled: false },
},
},
list: [{ id: "main", default: true }],
},
};
const result = await getMemorySearchManager({ cfg, agentId: "main" });
expect(result.manager).not.toBeNull();
if (!result.manager) throw new Error("manager missing");
manager = result.manager;
const db = (manager as unknown as { db: { exec: (sql: string) => void; prepare: (sql: string) => unknown } }).db;
db.exec("CREATE TABLE IF NOT EXISTS chunks_vec (id TEXT PRIMARY KEY, embedding BLOB)");
const sqlSeen: string[] = [];
const originalPrepare = db.prepare.bind(db);
db.prepare = (sql: string) => {
if (sql.includes("chunks_vec")) {
sqlSeen.push(sql);
}
return originalPrepare(sql);
};
(manager as unknown as { ensureVectorReady: (dims?: number) => Promise<boolean> }).ensureVectorReady =
async () => true;
const entry = await buildFileEntry(path.join(workspaceDir, "MEMORY.md"), workspaceDir);
await (manager as unknown as { indexFile: (entry: unknown, options: { source: "memory" }) => Promise<void> }).indexFile(
entry,
{ source: "memory" },
);
const deleteIndex = sqlSeen.findIndex((sql) => sql.includes("DELETE FROM chunks_vec WHERE id = ?"));
const insertIndex = sqlSeen.findIndex((sql) => sql.includes("INSERT INTO chunks_vec"));
expect(deleteIndex).toBeGreaterThan(-1);
expect(insertIndex).toBeGreaterThan(-1);
expect(deleteIndex).toBeLessThan(insertIndex);
});
});
+1 -123
View File
@@ -8,16 +8,10 @@ import type { BridgeInvokeRequestFrame } from "../infra/bridge/server/types.js";
import { import {
addAllowlistEntry, addAllowlistEntry,
matchAllowlist, matchAllowlist,
normalizeExecApprovals,
recordAllowlistUse, recordAllowlistUse,
requestExecApprovalViaSocket, requestExecApprovalViaSocket,
resolveCommandResolution, resolveCommandResolution,
resolveExecApprovals, resolveExecApprovals,
ensureExecApprovals,
readExecApprovalsSnapshot,
resolveExecApprovalsSocketPath,
saveExecApprovals,
type ExecApprovalsFile,
} from "../infra/exec-approvals.js"; } from "../infra/exec-approvals.js";
import { getMachineDisplayName } from "../infra/machine-name.js"; import { getMachineDisplayName } from "../infra/machine-name.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
@@ -49,18 +43,6 @@ type SystemWhichParams = {
bins: string[]; bins: string[];
}; };
type SystemExecApprovalsSetParams = {
file: ExecApprovalsFile;
baseHash?: string | null;
};
type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file: ExecApprovalsFile;
};
type RunResult = { type RunResult = {
exitCode?: number; exitCode?: number;
timedOut: boolean; timedOut: boolean;
@@ -161,31 +143,6 @@ function truncateOutput(raw: string, maxChars: number): { text: string; truncate
return { text: `... (truncated) ${raw.slice(raw.length - maxChars)}`, truncated: true }; return { text: `... (truncated) ${raw.slice(raw.length - maxChars)}`, truncated: true };
} }
function redactExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
const socketPath = file.socket?.path?.trim();
return {
...file,
socket: socketPath ? { path: socketPath } : undefined,
};
}
function requireExecApprovalsBaseHash(
params: SystemExecApprovalsSetParams,
snapshot: ExecApprovalsSnapshot,
) {
if (!snapshot.exists) return;
if (!snapshot.hash) {
throw new Error("INVALID_REQUEST: exec approvals base hash unavailable; reload and retry");
}
const baseHash = typeof params.baseHash === "string" ? params.baseHash.trim() : "";
if (!baseHash) {
throw new Error("INVALID_REQUEST: exec approvals base hash required; reload and retry");
}
if (baseHash !== snapshot.hash) {
throw new Error("INVALID_REQUEST: exec approvals changed; reload and retry");
}
}
async function runCommand( async function runCommand(
argv: string[], argv: string[],
cwd: string | undefined, cwd: string | undefined,
@@ -349,12 +306,7 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
deviceFamily: os.platform(), deviceFamily: os.platform(),
modelIdentifier: os.hostname(), modelIdentifier: os.hostname(),
caps: ["system"], caps: ["system"],
commands: [ commands: ["system.run", "system.which"],
"system.run",
"system.which",
"system.execApprovals.get",
"system.execApprovals.set",
],
onPairToken: async (token) => { onPairToken: async (token) => {
config.token = token; config.token = token;
await saveNodeHostConfig(config); await saveNodeHostConfig(config);
@@ -403,80 +355,6 @@ async function handleInvoke(
skillBins: SkillBinsCache, skillBins: SkillBinsCache,
) { ) {
const command = String(frame.command ?? ""); const command = String(frame.command ?? "");
if (command === "system.execApprovals.get") {
try {
ensureExecApprovals();
const snapshot = readExecApprovalsSnapshot();
const payload: ExecApprovalsSnapshot = {
path: snapshot.path,
exists: snapshot.exists,
hash: snapshot.hash,
file: redactExecApprovals(snapshot.file),
};
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: true,
payloadJSON: JSON.stringify(payload),
});
} catch (err) {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "INVALID_REQUEST", message: String(err) },
});
}
return;
}
if (command === "system.execApprovals.set") {
try {
const params = decodeParams<SystemExecApprovalsSetParams>(frame.paramsJSON);
if (!params.file || typeof params.file !== "object") {
throw new Error("INVALID_REQUEST: exec approvals file required");
}
ensureExecApprovals();
const snapshot = readExecApprovalsSnapshot();
requireExecApprovalsBaseHash(params, snapshot);
const normalized = normalizeExecApprovals(params.file);
const currentSocketPath = snapshot.file.socket?.path?.trim();
const currentToken = snapshot.file.socket?.token?.trim();
const socketPath =
normalized.socket?.path?.trim() ?? currentSocketPath ?? resolveExecApprovalsSocketPath();
const token = normalized.socket?.token?.trim() ?? currentToken ?? "";
const next: ExecApprovalsFile = {
...normalized,
socket: {
path: socketPath,
token,
},
};
saveExecApprovals(next);
const nextSnapshot = readExecApprovalsSnapshot();
const payload: ExecApprovalsSnapshot = {
path: nextSnapshot.path,
exists: nextSnapshot.exists,
hash: nextSnapshot.hash,
file: redactExecApprovals(nextSnapshot.file),
};
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: true,
payloadJSON: JSON.stringify(payload),
});
} catch (err) {
client.sendInvokeResponse({
type: "invoke-res",
id: frame.id,
ok: false,
error: { code: "INVALID_REQUEST", message: String(err) },
});
}
return;
}
if (command === "system.which") { if (command === "system.which") {
try { try {
const params = decodeParams<SystemWhichParams>(frame.paramsJSON); const params = decodeParams<SystemWhichParams>(frame.paramsJSON);
-1
View File
@@ -1 +0,0 @@
export * from "./registry.js";
-87
View File
@@ -1,87 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
handleSlackHttpRequest,
normalizeSlackWebhookPath,
registerSlackHttpHandler,
} from "./registry.js";
describe("normalizeSlackWebhookPath", () => {
it("returns the default path when input is empty", () => {
expect(normalizeSlackWebhookPath()).toBe("/slack/events");
expect(normalizeSlackWebhookPath(" ")).toBe("/slack/events");
});
it("ensures a leading slash", () => {
expect(normalizeSlackWebhookPath("slack/events")).toBe("/slack/events");
expect(normalizeSlackWebhookPath("/hooks/slack")).toBe("/hooks/slack");
});
});
describe("registerSlackHttpHandler", () => {
const unregisters: Array<() => void> = [];
afterEach(() => {
for (const unregister of unregisters.splice(0)) unregister();
});
it("routes requests to a registered handler", async () => {
const handler = vi.fn();
unregisters.push(
registerSlackHttpHandler({
path: "/slack/events",
handler,
}),
);
const req = { url: "/slack/events?foo=bar" } as IncomingMessage;
const res = {} as ServerResponse;
const handled = await handleSlackHttpRequest(req, res);
expect(handled).toBe(true);
expect(handler).toHaveBeenCalledWith(req, res);
});
it("returns false when no handler matches", async () => {
const req = { url: "/slack/other" } as IncomingMessage;
const res = {} as ServerResponse;
const handled = await handleSlackHttpRequest(req, res);
expect(handled).toBe(false);
});
it("logs and ignores duplicate registrations", async () => {
const handler = vi.fn();
const log = vi.fn();
unregisters.push(
registerSlackHttpHandler({
path: "/slack/events",
handler,
log,
accountId: "primary",
}),
);
unregisters.push(
registerSlackHttpHandler({
path: "/slack/events",
handler: vi.fn(),
log,
accountId: "duplicate",
}),
);
const req = { url: "/slack/events" } as IncomingMessage;
const res = {} as ServerResponse;
const handled = await handleSlackHttpRequest(req, res);
expect(handled).toBe(true);
expect(handler).toHaveBeenCalledWith(req, res);
expect(log).toHaveBeenCalledWith(
'slack: webhook path /slack/events already registered for account "duplicate"',
);
});
});
-45
View File
@@ -1,45 +0,0 @@
import type { IncomingMessage, ServerResponse } from "node:http";
export type SlackHttpRequestHandler = (
req: IncomingMessage,
res: ServerResponse,
) => Promise<void> | void;
type RegisterSlackHttpHandlerArgs = {
path?: string | null;
handler: SlackHttpRequestHandler;
log?: (message: string) => void;
accountId?: string;
};
const slackHttpRoutes = new Map<string, SlackHttpRequestHandler>();
export function normalizeSlackWebhookPath(path?: string | null): string {
const trimmed = path?.trim();
if (!trimmed) return "/slack/events";
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
export function registerSlackHttpHandler(params: RegisterSlackHttpHandlerArgs): () => void {
const normalizedPath = normalizeSlackWebhookPath(params.path);
if (slackHttpRoutes.has(normalizedPath)) {
const suffix = params.accountId ? ` for account "${params.accountId}"` : "";
params.log?.(`slack: webhook path ${normalizedPath} already registered${suffix}`);
return () => {};
}
slackHttpRoutes.set(normalizedPath, params.handler);
return () => {
slackHttpRoutes.delete(normalizedPath);
};
}
export async function handleSlackHttpRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const handler = slackHttpRoutes.get(url.pathname);
if (!handler) return false;
await handler(req, res);
return true;
}
+11 -60
View File
@@ -1,6 +1,4 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import { App } from "@slack/bolt";
import SlackBolt from "@slack/bolt";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js"; import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
@@ -16,7 +14,6 @@ import { resolveSlackAccount } from "../accounts.js";
import { resolveSlackChannelAllowlist } from "../resolve-channels.js"; import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
import { resolveSlackUserAllowlist } from "../resolve-users.js"; import { resolveSlackUserAllowlist } from "../resolve-users.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js";
import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js";
import { resolveSlackSlashCommandConfig } from "./commands.js"; import { resolveSlackSlashCommandConfig } from "./commands.js";
import { createSlackMonitorContext } from "./context.js"; import { createSlackMonitorContext } from "./context.js";
import { registerSlackMonitorEvents } from "./events.js"; import { registerSlackMonitorEvents } from "./events.js";
@@ -26,8 +23,6 @@ import { normalizeAllowList } from "./allow-list.js";
import type { MonitorSlackOpts } from "./types.js"; import type { MonitorSlackOpts } from "./types.js";
const { App, HTTPReceiver } = SlackBolt;
function parseApiAppIdFromAppToken(raw?: string) { function parseApiAppIdFromAppToken(raw?: string) {
const token = raw?.trim(); const token = raw?.trim();
if (!token) return undefined; if (!token) return undefined;
@@ -54,21 +49,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const sessionScope: SessionScope = sessionCfg?.scope ?? "per-sender"; const sessionScope: SessionScope = sessionCfg?.scope ?? "per-sender";
const mainKey = normalizeMainKey(sessionCfg?.mainKey); const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const slackMode = opts.mode ?? account.config.mode ?? "socket";
const slackWebhookPath = normalizeSlackWebhookPath(account.config.webhookPath);
const signingSecret = account.config.signingSecret?.trim();
const botToken = resolveSlackBotToken(opts.botToken ?? account.botToken); const botToken = resolveSlackBotToken(opts.botToken ?? account.botToken);
const appToken = resolveSlackAppToken(opts.appToken ?? account.appToken); const appToken = resolveSlackAppToken(opts.appToken ?? account.appToken);
if (!botToken || (slackMode !== "http" && !appToken)) { if (!botToken || !appToken) {
const missing =
slackMode === "http"
? `Slack bot token missing for account "${account.accountId}" (set channels.slack.accounts.${account.accountId}.botToken or SLACK_BOT_TOKEN for default).`
: `Slack bot + app tokens missing for account "${account.accountId}" (set channels.slack.accounts.${account.accountId}.botToken/appToken or SLACK_BOT_TOKEN/SLACK_APP_TOKEN for default).`;
throw new Error(missing);
}
if (slackMode === "http" && !signingSecret) {
throw new Error( throw new Error(
`Slack signing secret missing for account "${account.accountId}" (set channels.slack.signingSecret or channels.slack.accounts.${account.accountId}.signingSecret).`, `Slack bot + app tokens missing for account "${account.accountId}" (set channels.slack.accounts.${account.accountId}.botToken/appToken or SLACK_BOT_TOKEN/SLACK_APP_TOKEN for default).`,
); );
} }
@@ -117,32 +102,11 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024; const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false; const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const receiver = const app = new App({
slackMode === "http" token: botToken,
? new HTTPReceiver({ appToken,
signingSecret: signingSecret ?? "", socketMode: true,
endpoints: slackWebhookPath, });
})
: null;
const app = new App(
slackMode === "socket"
? {
token: botToken,
appToken,
socketMode: true,
}
: {
token: botToken,
receiver: receiver ?? undefined,
},
);
const slackHttpHandler =
slackMode === "http" && receiver
? async (req: IncomingMessage, res: ServerResponse) => {
await Promise.resolve(receiver.requestListener(req, res));
}
: null;
let unregisterHttpHandler: (() => void) | null = null;
let botUserId = ""; let botUserId = "";
let teamId = ""; let teamId = "";
@@ -200,14 +164,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
registerSlackMonitorEvents({ ctx, account, handleSlackMessage }); registerSlackMonitorEvents({ ctx, account, handleSlackMessage });
registerSlackMonitorSlashCommands({ ctx, account }); registerSlackMonitorSlashCommands({ ctx, account });
if (slackMode === "http" && slackHttpHandler) {
unregisterHttpHandler = registerSlackHttpHandler({
path: slackWebhookPath,
handler: slackHttpHandler,
log: runtime.log,
accountId: account.accountId,
});
}
if (resolveToken) { if (resolveToken) {
void (async () => { void (async () => {
@@ -328,17 +284,13 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
} }
const stopOnAbort = () => { const stopOnAbort = () => {
if (opts.abortSignal?.aborted && slackMode === "socket") void app.stop(); if (opts.abortSignal?.aborted) void app.stop();
}; };
opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true }); opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true });
try { try {
if (slackMode === "socket") { await app.start();
await app.start(); runtime.log?.("slack socket mode connected");
runtime.log?.("slack socket mode connected");
} else {
runtime.log?.(`slack http mode listening at ${slackWebhookPath}`);
}
if (opts.abortSignal?.aborted) return; if (opts.abortSignal?.aborted) return;
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
opts.abortSignal?.addEventListener("abort", () => resolve(), { opts.abortSignal?.addEventListener("abort", () => resolve(), {
@@ -347,7 +299,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
}); });
} finally { } finally {
opts.abortSignal?.removeEventListener("abort", stopOnAbort); opts.abortSignal?.removeEventListener("abort", stopOnAbort);
unregisterHttpHandler?.();
await app.stop().catch(() => undefined); await app.stop().catch(() => undefined);
} }
} }
-1
View File
@@ -6,7 +6,6 @@ export type MonitorSlackOpts = {
botToken?: string; botToken?: string;
appToken?: string; appToken?: string;
accountId?: string; accountId?: string;
mode?: "socket" | "http";
config?: ClawdbotConfig; config?: ClawdbotConfig;
runtime?: RuntimeEnv; runtime?: RuntimeEnv;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
+2 -24
View File
@@ -310,17 +310,9 @@ export function renderApp(state: AppViewState) {
execApprovalsSnapshot: state.execApprovalsSnapshot, execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm, execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent, execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state), onRefresh: () => loadNodes(state),
onLoadConfig: () => loadConfig(state), onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => { onLoadExecApprovals: () => loadExecApprovals(state),
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return loadExecApprovals(state, target);
},
onBindDefault: (nodeId) => { onBindDefault: (nodeId) => {
if (nodeId) { if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId); updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
@@ -337,14 +329,6 @@ export function renderApp(state: AppViewState) {
} }
}, },
onSaveBindings: () => saveConfig(state), onSaveBindings: () => saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId;
state.execApprovalsSnapshot = null;
state.execApprovalsForm = null;
state.execApprovalsDirty = false;
state.execApprovalsSelectedAgent = null;
},
onExecApprovalsSelectAgent: (agentId) => { onExecApprovalsSelectAgent: (agentId) => {
state.execApprovalsSelectedAgent = agentId; state.execApprovalsSelectedAgent = agentId;
}, },
@@ -352,13 +336,7 @@ export function renderApp(state: AppViewState) {
updateExecApprovalsFormValue(state, path, value), updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) => onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, path), removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => { onSaveExecApprovals: () => saveExecApprovals(state),
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return saveExecApprovals(state, target);
},
}) })
: nothing} : nothing}
-2
View File
@@ -54,8 +54,6 @@ export type AppViewState = {
execApprovalsSnapshot: ExecApprovalsSnapshot | null; execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null; execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null; execApprovalsSelectedAgent: string | null;
execApprovalsTarget: "gateway" | "node";
execApprovalsTargetNodeId: string | null;
configLoading: boolean; configLoading: boolean;
configRaw: string; configRaw: string;
configValid: boolean | null; configValid: boolean | null;
-2
View File
@@ -114,8 +114,6 @@ export class ClawdbotApp extends LitElement {
@state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null; @state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null;
@state() execApprovalsForm: ExecApprovalsFile | null = null; @state() execApprovalsForm: ExecApprovalsFile | null = null;
@state() execApprovalsSelectedAgent: string | null = null; @state() execApprovalsSelectedAgent: string | null = null;
@state() execApprovalsTarget: "gateway" | "node" = "gateway";
@state() execApprovalsTargetNodeId: string | null = null;
@state() configLoading = false; @state() configLoading = false;
@state() configRaw = "{\n}\n"; @state() configRaw = "{\n}\n";
+8 -49
View File
@@ -33,10 +33,6 @@ export type ExecApprovalsSnapshot = {
file: ExecApprovalsFile; file: ExecApprovalsFile;
}; };
export type ExecApprovalsTarget =
| { kind: "gateway" }
| { kind: "node"; nodeId: string };
export type ExecApprovalsState = { export type ExecApprovalsState = {
client: GatewayBrowserClient | null; client: GatewayBrowserClient | null;
connected: boolean; connected: boolean;
@@ -49,45 +45,16 @@ export type ExecApprovalsState = {
lastError: string | null; lastError: string | null;
}; };
function resolveExecApprovalsRpc(target?: ExecApprovalsTarget | null): { export async function loadExecApprovals(state: ExecApprovalsState) {
method: string;
params: Record<string, unknown>;
} | null {
if (!target || target.kind === "gateway") {
return { method: "exec.approvals.get", params: {} };
}
const nodeId = target.nodeId.trim();
if (!nodeId) return null;
return { method: "exec.approvals.node.get", params: { nodeId } };
}
function resolveExecApprovalsSaveRpc(
target: ExecApprovalsTarget | null | undefined,
params: { file: ExecApprovalsFile; baseHash: string },
): { method: string; params: Record<string, unknown> } | null {
if (!target || target.kind === "gateway") {
return { method: "exec.approvals.set", params };
}
const nodeId = target.nodeId.trim();
if (!nodeId) return null;
return { method: "exec.approvals.node.set", params: { ...params, nodeId } };
}
export async function loadExecApprovals(
state: ExecApprovalsState,
target?: ExecApprovalsTarget | null,
) {
if (!state.client || !state.connected) return; if (!state.client || !state.connected) return;
if (state.execApprovalsLoading) return; if (state.execApprovalsLoading) return;
state.execApprovalsLoading = true; state.execApprovalsLoading = true;
state.lastError = null; state.lastError = null;
try { try {
const rpc = resolveExecApprovalsRpc(target); const res = (await state.client.request(
if (!rpc) { "exec.approvals.get",
state.lastError = "Select a node before loading exec approvals."; {},
return; )) as ExecApprovalsSnapshot;
}
const res = (await state.client.request(rpc.method, rpc.params)) as ExecApprovalsSnapshot;
applyExecApprovalsSnapshot(state, res); applyExecApprovalsSnapshot(state, res);
} catch (err) { } catch (err) {
state.lastError = String(err); state.lastError = String(err);
@@ -106,10 +73,7 @@ export function applyExecApprovalsSnapshot(
} }
} }
export async function saveExecApprovals( export async function saveExecApprovals(state: ExecApprovalsState) {
state: ExecApprovalsState,
target?: ExecApprovalsTarget | null,
) {
if (!state.client || !state.connected) return; if (!state.client || !state.connected) return;
state.execApprovalsSaving = true; state.execApprovalsSaving = true;
state.lastError = null; state.lastError = null;
@@ -123,14 +87,9 @@ export async function saveExecApprovals(
state.execApprovalsForm ?? state.execApprovalsForm ??
state.execApprovalsSnapshot?.file ?? state.execApprovalsSnapshot?.file ??
{}; {};
const rpc = resolveExecApprovalsSaveRpc(target, { file, baseHash }); await state.client.request("exec.approvals.set", { file, baseHash });
if (!rpc) {
state.lastError = "Select a node before saving exec approvals.";
return;
}
await state.client.request(rpc.method, rpc.params);
state.execApprovalsDirty = false; state.execApprovalsDirty = false;
await loadExecApprovals(state, target); await loadExecApprovals(state);
} catch (err) { } catch (err) {
state.lastError = String(err); state.lastError = String(err);
} finally { } finally {
+2 -117
View File
@@ -21,15 +21,12 @@ export type NodesProps = {
execApprovalsSnapshot: ExecApprovalsSnapshot | null; execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null; execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null; execApprovalsSelectedAgent: string | null;
execApprovalsTarget: "gateway" | "node";
execApprovalsTargetNodeId: string | null;
onRefresh: () => void; onRefresh: () => void;
onLoadConfig: () => void; onLoadConfig: () => void;
onLoadExecApprovals: () => void; onLoadExecApprovals: () => void;
onBindDefault: (nodeId: string | null) => void; onBindDefault: (nodeId: string | null) => void;
onBindAgent: (agentIndex: number, nodeId: string | null) => void; onBindAgent: (agentIndex: number, nodeId: string | null) => void;
onSaveBindings: () => void; onSaveBindings: () => void;
onExecApprovalsTargetChange: (kind: "gateway" | "node", nodeId: string | null) => void;
onExecApprovalsSelectAgent: (agentId: string) => void; onExecApprovalsSelectAgent: (agentId: string) => void;
onExecApprovalsPatch: (path: Array<string | number>, value: unknown) => void; onExecApprovalsPatch: (path: Array<string | number>, value: unknown) => void;
onExecApprovalsRemove: (path: Array<string | number>) => void; onExecApprovalsRemove: (path: Array<string | number>) => void;
@@ -106,11 +103,6 @@ type ExecApprovalsAgentOption = {
isDefault?: boolean; isDefault?: boolean;
}; };
type ExecApprovalsTargetNode = {
id: string;
label: string;
};
type ExecApprovalsState = { type ExecApprovalsState = {
ready: boolean; ready: boolean;
disabled: boolean; disabled: boolean;
@@ -123,11 +115,7 @@ type ExecApprovalsState = {
selectedAgent: Record<string, unknown> | null; selectedAgent: Record<string, unknown> | null;
agents: ExecApprovalsAgentOption[]; agents: ExecApprovalsAgentOption[];
allowlist: ExecApprovalsAllowlistEntry[]; allowlist: ExecApprovalsAllowlistEntry[];
target: "gateway" | "node";
targetNodeId: string | null;
targetNodes: ExecApprovalsTargetNode[];
onSelectScope: (agentId: string) => void; onSelectScope: (agentId: string) => void;
onSelectTarget: (kind: "gateway" | "node", nodeId: string | null) => void;
onPatch: (path: Array<string | number>, value: unknown) => void; onPatch: (path: Array<string | number>, value: unknown) => void;
onRemove: (path: Array<string | number>) => void; onRemove: (path: Array<string | number>) => void;
onLoad: () => void; onLoad: () => void;
@@ -249,15 +237,6 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
const ready = Boolean(form); const ready = Boolean(form);
const defaults = resolveExecApprovalsDefaults(form); const defaults = resolveExecApprovalsDefaults(form);
const agents = resolveExecApprovalsAgents(props.configForm, form); const agents = resolveExecApprovalsAgents(props.configForm, form);
const targetNodes = resolveExecApprovalsNodes(props.nodes);
const target = props.execApprovalsTarget;
let targetNodeId =
target === "node" && props.execApprovalsTargetNodeId
? props.execApprovalsTargetNodeId
: null;
if (target === "node" && targetNodeId && !targetNodes.some((node) => node.id === targetNodeId)) {
targetNodeId = null;
}
const selectedScope = resolveExecApprovalsScope(props.execApprovalsSelectedAgent, agents); const selectedScope = resolveExecApprovalsScope(props.execApprovalsSelectedAgent, agents);
const selectedAgent = const selectedAgent =
selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE
@@ -280,11 +259,7 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
selectedAgent, selectedAgent,
agents, agents,
allowlist, allowlist,
target,
targetNodeId,
targetNodes,
onSelectScope: props.onExecApprovalsSelectAgent, onSelectScope: props.onExecApprovalsSelectAgent,
onSelectTarget: props.onExecApprovalsTargetChange,
onPatch: props.onExecApprovalsPatch, onPatch: props.onExecApprovalsPatch,
onRemove: props.onExecApprovalsRemove, onRemove: props.onExecApprovalsRemove,
onLoad: props.onLoadExecApprovals, onLoad: props.onLoadExecApprovals,
@@ -375,7 +350,6 @@ function renderBindings(state: BindingState) {
function renderExecApprovals(state: ExecApprovalsState) { function renderExecApprovals(state: ExecApprovalsState) {
const ready = state.ready; const ready = state.ready;
const targetReady = state.target !== "node" || Boolean(state.targetNodeId);
return html` return html`
<section class="card"> <section class="card">
<div class="row" style="justify-content: space-between; align-items: center;"> <div class="row" style="justify-content: space-between; align-items: center;">
@@ -387,19 +361,17 @@ function renderExecApprovals(state: ExecApprovalsState) {
</div> </div>
<button <button
class="btn" class="btn"
?disabled=${state.disabled || !state.dirty || !targetReady} ?disabled=${state.disabled || !state.dirty}
@click=${state.onSave} @click=${state.onSave}
> >
${state.saving ? "Saving…" : "Save"} ${state.saving ? "Saving…" : "Save"}
</button> </button>
</div> </div>
${renderExecApprovalsTarget(state)}
${!ready ${!ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;"> ? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load exec approvals to edit allowlists.</div> <div class="muted">Load exec approvals to edit allowlists.</div>
<button class="btn" ?disabled=${state.loading || !targetReady} @click=${state.onLoad}> <button class="btn" ?disabled=${state.loading} @click=${state.onLoad}>
${state.loading ? "Loading…" : "Load approvals"} ${state.loading ? "Loading…" : "Load approvals"}
</button> </button>
</div>` </div>`
@@ -414,73 +386,6 @@ function renderExecApprovals(state: ExecApprovalsState) {
`; `;
} }
function renderExecApprovalsTarget(state: ExecApprovalsState) {
const hasNodes = state.targetNodes.length > 0;
const nodeValue = state.targetNodeId ?? "";
return html`
<div class="list" style="margin-top: 12px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Target</div>
<div class="list-sub">
Gateway edits local approvals; node edits the selected node.
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Host</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value;
if (value === "node") {
const first = state.targetNodes[0]?.id ?? null;
state.onSelectTarget("node", nodeValue || first);
} else {
state.onSelectTarget("gateway", null);
}
}}
>
<option value="gateway" ?selected=${state.target === "gateway"}>Gateway</option>
<option value="node" ?selected=${state.target === "node"}>Node</option>
</select>
</label>
${state.target === "node"
? html`
<label class="field">
<span>Node</span>
<select
?disabled=${state.disabled || !hasNodes}
@change=${(event: Event) => {
const target = event.target as HTMLSelectElement;
const value = target.value.trim();
state.onSelectTarget("node", value ? value : null);
}}
>
<option value="" ?selected=${nodeValue === ""}>Select node</option>
${state.targetNodes.map(
(node) =>
html`<option
value=${node.id}
?selected=${nodeValue === node.id}
>
${node.label}
</option>`,
)}
</select>
</label>
`
: nothing}
</div>
</div>
${state.target === "node" && !hasNodes
? html`<div class="muted">No nodes advertise exec approvals yet.</div>`
: nothing}
</div>
`;
}
function renderExecApprovalsTabs(state: ExecApprovalsState) { function renderExecApprovalsTabs(state: ExecApprovalsState) {
return html` return html`
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;"> <div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
@@ -842,26 +747,6 @@ function resolveExecNodes(nodes: Array<Record<string, unknown>>): BindingNode[]
return list; return list;
} }
function resolveExecApprovalsNodes(nodes: Array<Record<string, unknown>>): ExecApprovalsTargetNode[] {
const list: ExecApprovalsTargetNode[] = [];
for (const node of nodes) {
const commands = Array.isArray(node.commands) ? node.commands : [];
const supports = commands.some(
(cmd) => String(cmd) === "system.execApprovals.get" || String(cmd) === "system.execApprovals.set",
);
if (!supports) continue;
const nodeId = typeof node.nodeId === "string" ? node.nodeId.trim() : "";
if (!nodeId) continue;
const displayName =
typeof node.displayName === "string" && node.displayName.trim()
? node.displayName.trim()
: nodeId;
list.push({ id: nodeId, label: displayName === nodeId ? nodeId : `${displayName} · ${nodeId}` });
}
list.sort((a, b) => a.label.localeCompare(b.label));
return list;
}
function resolveAgentBindings(config: Record<string, unknown> | null): { function resolveAgentBindings(config: Record<string, unknown> | null): {
defaultBinding?: string | null; defaultBinding?: string | null;
agents: BindingAgent[]; agents: BindingAgent[];