Compare commits

..

21 Commits

Author SHA1 Message Date
Peter Steinberger
296275b53b fix: boot-md hook + exec approvals parsing (#1164) (thanks @ngutman) 2026-01-18 15:59:39 +00:00
Nimrod Gutman
4c9760bbbc feat(hooks): run boot.md on gateway startup 2026-01-18 15:47:47 +00:00
Peter Steinberger
b546b2a48d fix: stabilize slack http receiver import 2026-01-18 15:44:17 +00:00
Peter Steinberger
c11b016d22 fix: prefer node service naming 2026-01-18 15:33:22 +00:00
Peter Steinberger
3686bde783 feat: add exec approvals tooling and service status 2026-01-18 15:23:41 +00:00
Peter Steinberger
9c06689569 fix: sanitize oversized image payloads 2026-01-18 15:21:38 +00:00
Peter Steinberger
891a2cc64a docs: tighten GitHub newline guidance 2026-01-18 15:20:09 +00:00
Peter Steinberger
01211937fc fix: link bash disabled docs 2026-01-18 15:17:09 +00:00
Peter Steinberger
4726580c7e feat(slack): add HTTP receiver webhook mode (#1143) - thanks @jdrhyne
Co-authored-by: Jonathan Rhyne <jdrhyne@users.noreply.github.com>
2026-01-18 15:04:07 +00:00
Peter Steinberger
e9a08dc507 feat: enrich system prompt docs guidance 2026-01-18 15:00:36 +00:00
Peter Steinberger
f3698e360b docs: add api usage and costs overview 2026-01-18 14:55:09 +00:00
Peter Steinberger
c69947dff8 feat: auto-enable audio understanding when keys exist 2026-01-18 14:55:09 +00:00
Peter Steinberger
173bce34b0 docs: add dep patch approval rule 2026-01-18 14:46:03 +00:00
Peter Steinberger
6a27e385b1 docs: map agent loop hook points 2026-01-18 14:43:35 +00:00
Peter Steinberger
5f0d9c3eb9 docs: expand agent loop overview 2026-01-18 14:30:12 +00:00
Peter Steinberger
0e31c8153c fix: bump Peekaboo revision 2026-01-18 14:26:19 +00:00
Peter Steinberger
9c0773c469 chore: update dependencies 2026-01-18 14:16:04 +00:00
Peter Steinberger
f5533baf61 test: add vector dedupe regression coverage 2026-01-18 14:08:06 +00:00
Peter Steinberger
60bc436e99 Merge pull request #1175 from vrknetha/fix/tool-error-fallback
Agents: surface tool failures without assistant output
2026-01-18 14:08:02 +00:00
Peter Steinberger
741b984a68 docs: fix #1151 changelog attribution 2026-01-18 14:04:38 +00:00
vrknetha
65710932ff Agents: surface tool failures without assistant output 2026-01-18 18:35:03 +05:30
97 changed files with 3743 additions and 1024 deletions

View File

@@ -1,6 +1,6 @@
# Repository Guidelines
- Repo: https://github.com/clawdbot/clawdbot
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
@@ -84,6 +84,7 @@
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); dont hand-roll spinners/bars.
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**

View File

@@ -2,32 +2,30 @@
Docs: https://docs.clawd.bot
## 2026.1.18-4
## 2026.1.17-7
### 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: stop syncing Peekaboo as a git submodule in postinstall.
- Swabble: use the tagged Commander Swift package release.
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
- Memory: add native Gemini embeddings provider for memory search. (#1151) — thanks @gumadeiras.
### 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
- Memory: add native Gemini embeddings provider for memory search. (#1151)
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
- Exec: add host/security/ask routing for gateway + node exec.
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
- macOS: add approvals socket UI server + node exec lifecycle events.
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
- Nodes: add node daemon service install/status/start/stop/restart.
- 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.
- 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.
@@ -35,17 +33,29 @@ Docs: https://docs.clawd.bot
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### 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: 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.
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
## 2026.1.18-2
### Fixes
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
- 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
@@ -73,22 +83,6 @@ Docs: https://docs.clawd.bot
### Fixes
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
## 2026.1.18-1
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
- Memory: add `--verbose` logging for memory status + batch indexing details.
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
### Fixes
- Memory: apply OpenAI batch defaults even without explicit remote config.
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-3
### Changes

View File

@@ -43,7 +43,7 @@
"location" : "https://github.com/steipete/Peekaboo.git",
"state" : {
"branch" : "main",
"revision" : "b2d0384d9f0f45b945d5f718f8a865bd574d83c2"
"revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90"
}
},
{

View File

@@ -1,3 +1,4 @@
import CryptoKit
import Foundation
import OSLog
import Security
@@ -121,6 +122,13 @@ struct ExecApprovalsFile: Codable {
var agents: [String: ExecApprovalsAgent]?
}
struct ExecApprovalsSnapshot: Codable {
var path: String
var exists: Bool
var hash: String
var file: ExecApprovalsFile
}
struct ExecApprovalsResolved {
let url: URL
let socketPath: String
@@ -153,6 +161,58 @@ enum ExecApprovalsStore {
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
}
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return ExecApprovalsFile(
version: 1,
socket: ExecApprovalsSocketConfig(
path: socketPath.isEmpty ? nil : socketPath,
token: token.isEmpty ? nil : token),
defaults: file.defaults,
agents: file.agents)
}
static func readSnapshot() -> ExecApprovalsSnapshot {
let url = self.fileURL()
guard FileManager.default.fileExists(atPath: url.path) else {
return ExecApprovalsSnapshot(
path: url.path,
exists: false,
hash: self.hashRaw(nil),
file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]))
}
let raw = try? String(contentsOf: url, encoding: .utf8)
let data = raw.flatMap { $0.data(using: .utf8) }
let decoded: ExecApprovalsFile = {
if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 {
return file
}
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
}()
return ExecApprovalsSnapshot(
path: url.path,
exists: true,
hash: self.hashRaw(raw),
file: decoded)
}
static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if socketPath.isEmpty {
return ExecApprovalsFile(
version: file.version,
socket: nil,
defaults: file.defaults,
agents: file.agents)
}
return ExecApprovalsFile(
version: file.version,
socket: ExecApprovalsSocketConfig(path: socketPath, token: nil),
defaults: file.defaults,
agents: file.agents)
}
static func loadFile() -> ExecApprovalsFile {
let url = self.fileURL()
guard FileManager.default.fileExists(atPath: url.path) else {
@@ -372,6 +432,12 @@ enum ExecApprovalsStore {
return UUID().uuidString
}
private static func hashRaw(_ raw: String?) -> String {
let data = Data((raw ?? "").utf8)
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private static func expandPath(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "~" {

View File

@@ -158,6 +158,8 @@ final class MacNodeModeCoordinator {
ClawdbotSystemCommand.notify.rawValue,
ClawdbotSystemCommand.which.rawValue,
ClawdbotSystemCommand.run.rawValue,
ClawdbotSystemCommand.execApprovalsGet.rawValue,
ClawdbotSystemCommand.execApprovalsSet.rawValue,
]
let capsSet = Set(caps)

View File

@@ -64,6 +64,10 @@ actor MacNodeRuntime {
return try await self.handleSystemWhich(req)
case ClawdbotSystemCommand.notify.rawValue:
return try await self.handleSystemNotify(req)
case ClawdbotSystemCommand.execApprovalsGet.rawValue:
return try await self.handleSystemExecApprovalsGet(req)
case ClawdbotSystemCommand.execApprovalsSet.rawValue:
return try await self.handleSystemExecApprovalsSet(req)
default:
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
}
@@ -676,6 +680,72 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
_ = ExecApprovalsStore.ensureFile()
let snapshot = ExecApprovalsStore.readSnapshot()
let redacted = ExecApprovalsSnapshot(
path: snapshot.path,
exists: snapshot.exists,
hash: snapshot.hash,
file: ExecApprovalsStore.redactForSnapshot(snapshot.file))
let payload = try Self.encodePayload(redacted)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func handleSystemExecApprovalsSet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
struct SetParams: Decodable {
var file: ExecApprovalsFile
var baseHash: String?
}
let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON)
let current = ExecApprovalsStore.ensureFile()
let snapshot = ExecApprovalsStore.readSnapshot()
if snapshot.exists {
if snapshot.hash.isEmpty {
return Self.errorResponse(
req,
code: .invalidRequest,
message: "INVALID_REQUEST: exec approvals base hash unavailable; reload and retry")
}
let baseHash = params.baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if baseHash.isEmpty {
return Self.errorResponse(
req,
code: .invalidRequest,
message: "INVALID_REQUEST: exec approvals base hash required; reload and retry")
}
if baseHash != snapshot.hash {
return Self.errorResponse(
req,
code: .invalidRequest,
message: "INVALID_REQUEST: exec approvals changed; reload and retry")
}
}
var normalized = ExecApprovalsStore.normalizeIncoming(params.file)
let socketPath = normalized.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines)
let token = normalized.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines)
let resolvedPath = (socketPath?.isEmpty == false)
? socketPath!
: current.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ??
ExecApprovalsStore.socketPath()
let resolvedToken = (token?.isEmpty == false)
? token!
: current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken)
ExecApprovalsStore.saveFile(normalized)
let nextSnapshot = ExecApprovalsStore.readSnapshot()
let redacted = ExecApprovalsSnapshot(
path: nextSnapshot.path,
exists: nextSnapshot.exists,
hash: nextSnapshot.hash,
file: ExecApprovalsStore.redactForSnapshot(nextSnapshot.file))
let payload = try Self.encodePayload(redacted)
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
guard let sender = self.eventSender else { return }
guard let data = try? JSONEncoder().encode(payload),

View File

@@ -4,6 +4,8 @@ public enum ClawdbotSystemCommand: String, Codable, Sendable {
case run = "system.run"
case which = "system.which"
case notify = "system.notify"
case execApprovalsGet = "system.execApprovals.get"
case execApprovalsSet = "system.execApprovals.set"
}
public enum ClawdbotNotificationPriority: String, Codable, Sendable {

View File

@@ -1,11 +1,13 @@
---
summary: "Slack socket mode setup and Clawdbot config"
read_when: "Setting up Slack or debugging Slack socket mode"
summary: "Slack setup for socket or HTTP webhook mode"
read_when: "Setting up Slack or debugging Slack socket/HTTP mode"
---
# Slack (socket mode)
# Slack
## Quick setup (beginner)
## Socket mode (default)
### Quick setup (beginner)
1) Create a Slack app and enable **Socket Mode**.
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
3) Set tokens for Clawdbot and start the gateway.
@@ -23,7 +25,7 @@ Minimal config:
}
```
## Setup
### Setup
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
2) **Socket Mode** → toggle on. Then go to **Basic Information****App-Level Tokens****Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
@@ -43,7 +45,7 @@ Use the manifest below so scopes and events stay in sync.
Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
## Clawdbot config (minimal)
### Clawdbot config (minimal)
Set tokens via env vars (recommended):
- `SLACK_APP_TOKEN=xapp-...`
@@ -63,7 +65,7 @@ Or via config:
}
```
## User token (optional)
### User token (optional)
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
pins, reactions, emoji, member info). By default this stays read-only: reads
prefer the user token when present, and writes still use the bot token unless
@@ -102,18 +104,51 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
}
```
### Token usage
#### Token usage
- Read operations (history, reactions list, pins list, emoji list, member info,
search) prefer the user token when configured, otherwise the bot token.
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
no bot token is available, Clawdbot falls back to the user token.
## History context
### History context
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
## Manifest (optional)
## HTTP mode (Events API)
Use HTTP webhook mode when your Gateway is reachable by Slack over HTTPS (typical for server deployments).
HTTP mode uses the Events API + Interactivity + Slash Commands with a shared request URL.
### Setup
1) Create a Slack app and **disable Socket Mode** (optional if you only use HTTP).
2) **Basic Information** → copy the **Signing Secret**.
3) **OAuth & Permissions** → install the app and copy the **Bot User OAuth Token** (`xoxb-...`).
4) **Event Subscriptions** → enable events and set the **Request URL** to your gateway webhook path (default `/slack/events`).
5) **Interactivity & Shortcuts** → enable and set the same **Request URL**.
6) **Slash Commands** → set the same **Request URL** for your command(s).
Example request URL:
`https://gateway-host/slack/events`
### Clawdbot config (minimal)
```json5
{
channels: {
slack: {
enabled: true,
mode: "http",
botToken: "xoxb-...",
signingSecret: "your-signing-secret",
webhookPath: "/slack/events"
}
}
}
```
Multi-account HTTP mode: set `channels.slack.accounts.<id>.mode = "http"` and provide a unique
`webhookPath` per account so each Slack app can point to its own URL.
### Manifest (optional)
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
user scopes if you plan to configure a user token.

44
docs/cli/approvals.md Normal file
View File

@@ -0,0 +1,44 @@
---
summary: "CLI reference for `clawdbot approvals` (exec approvals for gateway or node hosts)"
read_when:
- You want to edit exec approvals from the CLI
- You need to manage allowlists on gateway or node hosts
---
# `clawdbot approvals`
Manage exec approvals for the **gateway host** or a **node host**.
By default, commands target the gateway. Use `--node` to edit a nodes 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`.

View File

@@ -9,6 +9,9 @@ read_when:
Manage the Gateway daemon (background service).
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains
as a legacy alias for compatibility.
Related:
- Gateway CLI: [Gateway](/cli/gateway)
- macOS platform notes: [macOS](/platforms/macos)

View File

@@ -7,7 +7,7 @@ read_when:
# `clawdbot hooks`
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, and gateway startup).
Related:
- Hooks: [Hooks](/hooks)
@@ -29,9 +29,10 @@ List all discovered hooks from workspace, managed, and bundled directories.
**Example output:**
```
Hooks (3/3 ready)
Hooks (4/4 ready)
Ready:
🚀 boot-md ✓ - Run BOOT.md on gateway startup
📝 command-logger ✓ - Log all command events to a centralized audit file
💾 session-memory ✓ - Save session context to memory when /new command is issued
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
@@ -107,8 +108,8 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
```
Hooks Status
Total hooks: 2
Ready: 2
Total hooks: 4
Ready: 4
Not ready: 0
```
@@ -273,3 +274,17 @@ clawdbot hooks enable soul-evil
```
**See:** [SOUL Evil Hook](/hooks/soul-evil)
### boot-md
Runs `BOOT.md` when the gateway starts (after channels start).
**Events**: `gateway:startup`
**Enable**:
```bash
clawdbot hooks enable boot-md
```
**See:** [boot-md documentation](/hooks#boot-md)

View File

@@ -29,11 +29,13 @@ This page describes the current CLI behavior. If commands change, update this do
- [`sessions`](/cli/sessions)
- [`gateway`](/cli/gateway)
- [`daemon`](/cli/daemon)
- [`service`](/cli/service)
- [`logs`](/cli/logs)
- [`models`](/cli/models)
- [`memory`](/cli/memory)
- [`nodes`](/cli/nodes)
- [`node`](/cli/node)
- [`approvals`](/cli/approvals)
- [`sandbox`](/cli/sandbox)
- [`tui`](/cli/tui)
- [`browser`](/cli/browser)
@@ -143,6 +145,21 @@ clawdbot [--dev] [--profile <name>] <command>
start
stop
restart
service
gateway
status
install
uninstall
start
stop
restart
node
status
install
uninstall
start
stop
restart
logs
models
list
@@ -180,6 +197,10 @@ clawdbot [--dev] [--profile <name>] <command>
start
stop
restart
approvals
get
set
allowlist add|remove
browser
status
start
@@ -520,6 +541,9 @@ Options:
- `--verbose`
- `--debug` (alias for `--verbose`)
Notes:
- Overview includes Gateway + Node service status when available.
### Usage tracking
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
@@ -781,12 +805,15 @@ All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
Subcommands:
- `node start --host <gateway-host> --port 18790`
- `node daemon status`
- `node daemon install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
- `node daemon uninstall`
- `node daemon start`
- `node daemon stop`
- `node daemon restart`
- `node service status`
- `node service install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
- `node service uninstall`
- `node service start`
- `node service stop`
- `node service restart`
Legacy alias:
- `node daemon …` (same as `node service …`)
## Nodes

View File

@@ -37,12 +37,14 @@ Options:
- `--node-id <id>`: Override node id (clears pairing token)
- `--display-name <name>`: Override the node display name
## Daemon (background service)
## Service (background)
Install a headless node host as a user service.
```bash
clawdbot node daemon install --host <gateway-host> --port 18790
clawdbot node service install --host <gateway-host> --port 18790
# or
clawdbot service node install --host <gateway-host> --port 18790
```
Options:
@@ -57,12 +59,20 @@ Options:
Manage the service:
```bash
clawdbot node status
clawdbot service node status
clawdbot node service status
clawdbot node service start
clawdbot node service stop
clawdbot node service restart
clawdbot node service uninstall
```
Legacy alias:
```bash
clawdbot node daemon status
clawdbot node daemon start
clawdbot node daemon stop
clawdbot node daemon restart
clawdbot node daemon uninstall
```
## Pairing
@@ -83,3 +93,4 @@ The node host stores its node id + token in `~/.clawdbot/node.json`.
- `~/.clawdbot/exec-approvals.json`
- [Exec approvals](/tools/exec-approvals)
- `clawdbot approvals --node <id|name|ip>` (edit from the Gateway)

51
docs/cli/service.md Normal file
View File

@@ -0,0 +1,51 @@
---
summary: "CLI reference for `clawdbot service` (manage gateway + node services)"
read_when:
- You want to manage Gateway or node services cross-platform
- You want a single surface for start/stop/install/uninstall
---
# `clawdbot service`
Manage the **Gateway** service and **node host** services.
Related:
- Gateway daemon (legacy alias): [Daemon](/cli/daemon)
- Node host: [Node](/cli/node)
## Gateway service
```bash
clawdbot service gateway status
clawdbot service gateway install --port 18789
clawdbot service gateway start
clawdbot service gateway stop
clawdbot service gateway restart
clawdbot service gateway uninstall
```
Notes:
- `service gateway status` supports `--json` and `--deep` for system checks.
- `service gateway install` supports `--runtime node|bun` and `--token`.
## Node host service
```bash
clawdbot service node status
clawdbot service node install --host <gateway-host> --port 18790
clawdbot service node start
clawdbot service node stop
clawdbot service node restart
clawdbot service node uninstall
```
Notes:
- `service node install` supports `--runtime node|bun`, `--node-id`, `--display-name`,
and TLS options (`--tls`, `--tls-fingerprint`).
## Aliases
- `clawdbot daemon …``clawdbot service gateway …`
- `clawdbot node service …``clawdbot service node …`
- `clawdbot node status``clawdbot service node status`
- `clawdbot node daemon …``clawdbot service node …` (legacy)

View File

@@ -19,4 +19,5 @@ clawdbot status --usage
Notes:
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
- Output includes per-agent session stores when multiple agents are configured.
- Overview includes Gateway + Node service install/runtime status when available.
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).

View File

@@ -5,13 +5,19 @@ read_when:
---
# Agent Loop (Clawdbot)
Short, exact flow of one agent run.
An agentic loop is the full “real” run of an agent: intake → context assembly → model inference →
tool execution → streaming replies → persistence. Its the authoritative path that turns a message
into actions and a final reply, while keeping session state consistent.
In Clawdbot, a loop is a single, serialized run per session that emits lifecycle and stream events
as the model thinks, calls tools, and streams output. This doc explains how that authentic loop is
wired end-to-end.
## Entry points
- Gateway RPC: `agent` and `agent.wait`.
- CLI: `agent` command.
## High-level flow
## How it works (high-level)
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
2) `agentCommand` runs the agent:
- resolves model + thinking/verbose defaults
@@ -19,8 +25,9 @@ Short, exact flow of one agent run.
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
- emits **lifecycle end/error** if the embedded loop does not emit one
3) `runEmbeddedPiAgent`:
- builds `AgentSession` and subscribes to pi events
- streams assistant deltas + tool events
- serializes runs via per-session + global queues
- resolves model + auth profile and builds the pi session
- subscribes to pi events and streams assistant/tool deltas
- enforces timeout -> aborts run if exceeded
- returns payloads + usage metadata
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream:
@@ -31,6 +38,73 @@ Short, exact flow of one agent run.
- waits for **lifecycle end/error** for `runId`
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
## Queueing + concurrency
- Runs are serialized per session key (session lane) and optionally through a global lane.
- This prevents tool/session races and keeps session history consistent.
- Messaging channels can choose queue modes (collect/steer/followup) that feed this lane system.
See [Command Queue](/concepts/queue).
## Session + workspace preparation
- Workspace is resolved and created; sandboxed runs may redirect to a sandbox workspace root.
- Skills are loaded (or reused from a snapshot) and injected into env and prompt.
- Bootstrap/context files are resolved and injected into the system prompt report.
- A session write lock is acquired; `SessionManager` is opened and prepared before streaming.
## Prompt assembly + system prompt
- System prompt is built from Clawdbots base prompt, skills prompt, bootstrap context, and per-run overrides.
- Model-specific limits and compaction reserve tokens are enforced.
- See [System prompt](/concepts/system-prompt) for what the model sees.
## Hook points (where you can intercept)
Clawdbot has two hook systems:
- **Internal hooks** (Gateway hooks): event-driven scripts for commands and lifecycle events.
- **Plugin hooks**: extension points inside the agent/tool lifecycle and gateway pipeline.
### Internal hooks (Gateway hooks)
- **`agent:bootstrap`**: runs while building bootstrap files before the system prompt is finalized.
Use this to add/remove bootstrap context files.
- **Command hooks**: `/new`, `/reset`, `/stop`, and other command events (see Hooks doc).
See [Hooks](/hooks) for setup and examples.
### Plugin hooks (agent + gateway lifecycle)
These run inside the agent loop or gateway pipeline:
- **`before_agent_start`**: inject context or override system prompt before the run starts.
- **`agent_end`**: inspect the final message list and run metadata after completion.
- **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles.
- **`before_tool_call` / `after_tool_call`**: intercept tool params/results.
- **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks.
- **`session_start` / `session_end`**: session lifecycle boundaries.
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
See [Plugins](/plugin#plugin-hooks) for the hook API and registration details.
## Streaming + partial replies
- Assistant deltas are streamed from pi-agent-core and emitted as `assistant` events.
- Block streaming can emit partial replies either on `text_end` or `message_end`.
- Reasoning streaming can be emitted as a separate stream or as block replies.
- See [Streaming](/concepts/streaming) for chunking and block reply behavior.
## Tool execution + messaging tools
- Tool start/update/end events are emitted on the `tool` stream.
- Tool results are sanitized for size and image payloads before logging/emitting.
- Messaging tool sends are tracked to suppress duplicate assistant confirmations.
## Reply shaping + suppression
- Final payloads are assembled from:
- assistant text (and optional reasoning)
- inline tool summaries (when verbose + allowed)
- assistant error text when the model errors
- `NO_REPLY` is treated as a silent token and filtered from outgoing payloads.
- Messaging tool duplicates are removed from the final payload list.
- If no renderable payloads remain and a tool errored, a fallback tool error reply is emitted
(unless a messaging tool already sent a user-visible reply).
## Compaction + retries
- Auto-compaction emits `compaction` stream events and can trigger a retry.
- On retry, in-memory buffers and tool summaries are reset to avoid duplicate output.
- See [Compaction](/concepts/compaction) for the compaction pipeline.
## Event streams (today)
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
- `assistant`: streamed deltas from pi-agent-core

View File

@@ -86,6 +86,10 @@ These are the standard files Clawdbot expects inside the workspace:
- Optional tiny checklist for heartbeat runs.
- Keep it short to avoid token burn.
- `BOOT.md`
- Optional startup checklist executed on gateway restart when internal hooks are enabled.
- Keep it short; use the message tool for outbound sends.
- `BOOTSTRAP.md`
- One-time first-run ritual.
- Only created for a brand-new workspace.

View File

@@ -18,6 +18,7 @@ The prompt is intentionally compact and uses fixed sections:
- **Skills** (when available): tells the model how to load skill instructions on demand.
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
- **Workspace**: working directory (`agents.defaults.workspace`).
- **Documentation**: local path to Clawdbot docs (repo or npm package) and when to read them.
- **Workspace Files (injected)**: indicates bootstrap files are included below.
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
- **Current Date & Time**: user-local time, timezone, and time format.
@@ -98,3 +99,12 @@ Skills section is omitted.
```
This keeps the base prompt small while still enabling targeted skill usage.
## Documentation
When available, the system prompt includes a **Documentation** section that points to the
local Clawdbot docs directory (either `docs/` in the repo workspace or the bundled npm
package docs) and also notes the public mirror, source repo, community Discord, and
ClawdHub (https://clawdhub.com) for skills discovery. The prompt instructs the model to consult local docs first
for Clawdbot behavior, commands, configuration, or architecture, and to run
`clawdbot status` itself when possible (asking the user only when it lacks access).

View File

@@ -657,6 +657,10 @@
"source": "/templates/AGENTS",
"destination": "/reference/templates/AGENTS"
},
{
"source": "/templates/BOOT",
"destination": "/reference/templates/BOOT"
},
{
"source": "/templates/BOOTSTRAP",
"destination": "/reference/templates/BOOTSTRAP"
@@ -822,8 +826,10 @@
"cli/models",
"cli/logs",
"cli/nodes",
"cli/approvals",
"cli/gateway",
"cli/daemon",
"cli/service",
"cli/tui",
"cli/voicecall",
"cli/wake",
@@ -1051,6 +1057,7 @@
"reference/RELEASING",
"reference/AGENTS.default",
"reference/templates/AGENTS",
"reference/templates/BOOT",
"reference/templates/BOOTSTRAP",
"reference/templates/HEARTBEAT",
"reference/templates/IDENTITY",

View File

@@ -37,10 +37,11 @@ The hooks system allows you to:
### Bundled Hooks
Clawdbot ships with three bundled hooks that are automatically discovered:
Clawdbot ships with four bundled hooks that are automatically discovered:
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled)
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
List available hooks:
@@ -195,7 +196,7 @@ Each event includes:
```typescript
{
type: 'command' | 'session' | 'agent',
type: 'command' | 'session' | 'agent' | 'gateway',
action: string, // e.g., 'new', 'reset', 'stop'
sessionKey: string, // Session identifier
timestamp: Date, // When the event occurred
@@ -228,6 +229,12 @@ Triggered when agent commands are issued:
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
### Gateway Events
Triggered when the gateway starts:
- **`gateway:startup`**: After channels start and hooks are loaded
### Future Events
Planned event types:
@@ -542,6 +549,26 @@ 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
### Keep Handlers Fast
@@ -614,6 +641,7 @@ The gateway logs hook loading at startup:
```
Registered hook: session-memory -> command:new
Registered hook: command-logger -> command
Registered hook: boot-md -> gateway:startup
```
### Check Discovery

View File

@@ -149,8 +149,8 @@ Notes:
## System commands (node host / mac node)
The macOS node exposes `system.run` and `system.notify`. The headless node host
exposes `system.run` and `system.which`.
The macOS node exposes `system.run`, `system.notify`, and `system.execApprovals.get/set`.
The headless node host exposes `system.run`, `system.which`, and `system.execApprovals.get/set`.
Examples:

View File

@@ -104,6 +104,29 @@ Rules:
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
**active reply model** when its provider supports the capability.
### Auto-enable audio (when keys exist)
If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
audio provider keys configured, Clawdbot will **auto-enable audio transcription**
even when you havent listed models explicitly.
Providers checked (in order):
1) OpenAI
2) Groq
3) Deepgram
To disable this behavior, set:
```json5
{
tools: {
media: {
audio: {
enabled: false
}
}
}
}
```
## Capabilities (optional)
If you set `capabilities`, the entry only runs for those media types. For shared
lists, Clawdbot can infer defaults:

View File

@@ -0,0 +1,116 @@
---
summary: "Audit what can spend money, which keys are used, and how to view usage"
read_when:
- You want to understand which features may call paid APIs
- You need to audit keys, costs, and usage visibility
- Youre 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 isnt 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 skills provider.
See [Skills](/tools/skills).

View File

@@ -0,0 +1,9 @@
---
summary: "Workspace template for BOOT.md"
read_when:
- Adding a BOOT.md checklist
---
# BOOT.md
Add short, explicit instructions for what Clawdbot should do on startup (enable `hooks.internal.enabled`).
If the task sends a message, use the message tool and then reply with NO_REPLY.

View File

@@ -107,8 +107,12 @@ overrides, and allowlists. Pick a scope (Defaults or an agent), tweak the policy
add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata
per pattern so you can keep the list tidy.
Note: the Control UI edits the approvals file on the **Gateway host**. For a
headless node host, edit its local `~/.clawdbot/exec-approvals.json` directly.
The target selector chooses **Gateway** (local approvals) or a **Node**. Nodes
must advertise `system.execApprovals.get/set` (macOS app or headless node host).
If a node does not advertise exec approvals yet, edit its local
`~/.clawdbot/exec-approvals.json` directly.
CLI: `clawdbot approvals` supports gateway or node editing (see [Approvals CLI](/cli/approvals)).
## Approval flow

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.*`)
- Skills: status, enable/disable, install, API key updates (`skills.*`)
- Nodes: list + caps (`node.list`)
- Exec approvals: edit allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
- Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
- Config: apply + restart with validation (`config.apply`) and wake the last active session
- Config writes include a base-hash guard to prevent clobbering concurrent edits

View File

@@ -1,5 +1,4 @@
import os from "node:os";
import { beforeEach, describe, expect, it } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";

View File

@@ -4,11 +4,13 @@
"type": "module",
"description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture",
"dependencies": {
"@sinclair/typebox": "0.34.47",
"@lancedb/lancedb": "^0.15.0",
"openai": "^4.77.0"
"@sinclair/typebox": "0.34.47",
"openai": "^4.104.0"
},
"clawdbot": {
"extensions": ["./index.ts"]
"extensions": [
"./index.ts"
]
}
}

View File

@@ -9,10 +9,10 @@
]
},
"dependencies": {
"@microsoft/agents-hosting": "^1.2.2",
"@microsoft/agents-hosting-express": "^1.2.2",
"@microsoft/agents-hosting-extensions-teams": "^1.2.2",
"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",
"proper-lockfile": "^4.1.2"
}

View File

@@ -168,7 +168,7 @@
"dotenv": "^17.2.3",
"express": "^5.2.1",
"file-type": "^21.3.0",
"grammy": "^1.39.2",
"grammy": "^1.39.3",
"hono": "4.11.4",
"jiti": "^2.6.1",
"json5": "^2.2.3",
@@ -182,7 +182,7 @@
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
"sqlite-vec": "0.1.7-alpha.2",
"tar": "^7.5.3",
"tar": "7.5.3",
"tslog": "^4.10.2",
"undici": "^7.18.2",
"ws": "^8.19.0",
@@ -200,24 +200,24 @@
"@types/body-parser": "^1.19.6",
"@types/express": "^5.0.6",
"@types/markdown-it": "^14.1.2",
"@types/node": "^25.0.6",
"@types/node": "^25.0.9",
"@types/proper-lockfile": "^4.1.4",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
"@vitest/coverage-v8": "^4.0.16",
"@vitest/coverage-v8": "^4.0.17",
"docx-preview": "^0.3.7",
"lit": "^3.3.2",
"lucide": "^0.562.0",
"ollama": "^0.6.3",
"oxfmt": "0.24.0",
"oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.0",
"oxlint-tsgolint": "^0.11.1",
"quicktype-core": "^23.2.6",
"rolldown": "1.0.0-beta.59",
"signal-utils": "^0.21.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.0.16",
"vitest": "^4.0.17",
"wireit": "^0.14.12"
},
"pnpm": {

1435
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -50,15 +50,15 @@ import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
const DEFAULT_MAX_OUTPUT = clampNumber(
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
30_000,
200_000,
1_000,
150_000,
200_000,
);
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(
readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"),
30_000,
200_000,
1_000,
150_000,
200_000,
);
const DEFAULT_PATH =
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";

View File

@@ -6,6 +6,7 @@ import { shouldLogVerbose } from "../globals.js";
import { createSubsystemLogger } from "../logging.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import { resolveClawdbotDocsPath } from "./docs-path.js";
import { resolveSessionAgentIds } from "./agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
import { resolveCliBackendConfig } from "./cli-backends.js";
@@ -83,6 +84,12 @@ export async function runCliAgent(params: {
sessionAgentId === defaultAgentId
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined;
const docsPath = await resolveClawdbotDocsPath({
workspaceDir,
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const systemPrompt = buildSystemPrompt({
workspaceDir,
config: params.config,
@@ -90,6 +97,7 @@ export async function runCliAgent(params: {
extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
heartbeatPrompt,
docsPath: docsPath ?? undefined,
tools: [],
contextFiles,
modelDisplay,

View File

@@ -168,6 +168,7 @@ export function buildSystemPrompt(params: {
extraSystemPrompt?: string;
ownerNumbers?: string[];
heartbeatPrompt?: string;
docsPath?: string;
tools: AgentTool[];
contextFiles?: EmbeddedContextFile[];
modelDisplay: string;
@@ -182,6 +183,7 @@ export function buildSystemPrompt(params: {
ownerNumbers: params.ownerNumbers,
reasoningTagHint: false,
heartbeatPrompt: params.heartbeatPrompt,
docsPath: params.docsPath,
runtimeInfo: {
host: "clawdbot",
os: `${os.type()} ${os.release()}`,

27
src/agents/docs-path.ts Normal file
View File

@@ -0,0 +1,27 @@
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;
}

View File

@@ -26,6 +26,11 @@ describe("classifyFailoverReason", () => {
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
expect(classifyFailoverReason("string should match pattern")).toBe("format");
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", () => {
expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe(

View File

@@ -0,0 +1,16 @@
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);
});
});

View File

@@ -23,5 +23,10 @@ describe("isCloudCodeAssistFormatError", () => {
});
it("ignores unrelated errors", () => {
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);
});
});

View File

@@ -21,11 +21,13 @@ export {
isContextOverflowError,
isFailoverAssistantError,
isFailoverErrorMessage,
isImageDimensionErrorMessage,
isOverloadedErrorMessage,
isRawApiErrorPayload,
isRateLimitAssistantError,
isRateLimitErrorMessage,
isTimeoutErrorMessage,
parseImageDimensionError,
} from "./pi-embedded-helpers/errors.js";
export {
downgradeGeminiHistory,

View File

@@ -339,7 +339,6 @@ const ERROR_PATTERNS = {
"no api key found",
],
format: [
"invalid_request_error",
"string should match pattern",
"tool_use.id",
"tool_use_id",
@@ -348,6 +347,10 @@ const ERROR_PATTERNS = {
],
} 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 {
if (!raw) return false;
const value = raw.toLowerCase();
@@ -390,8 +393,31 @@ export function isOverloadedErrorMessage(raw: string): boolean {
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 {
return matchesErrorPatterns(raw, ERROR_PATTERNS.format);
return !isImageDimensionErrorMessage(raw) && matchesErrorPatterns(raw, ERROR_PATTERNS.format);
}
export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean {
@@ -400,6 +426,7 @@ export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean
}
export function classifyFailoverReason(raw: string): FailoverReason | null {
if (isImageDimensionErrorMessage(raw)) return null;
if (isRateLimitErrorMessage(raw)) return "rate_limit";
if (isOverloadedErrorMessage(raw)) return "rate_limit";
if (isCloudCodeAssistFormatError(raw)) return "format";

View File

@@ -17,6 +17,7 @@ import { resolveUserPath } from "../../utils.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { resolveSessionAgentIds } from "../agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
import { resolveClawdbotDocsPath } from "../docs-path.js";
import type { ExecElevatedDefaults } from "../bash-tools.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js";
@@ -250,6 +251,12 @@ export async function compactEmbeddedPiSession(params: {
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
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({
workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel,
@@ -261,6 +268,7 @@ export async function compactEmbeddedPiSession(params: {
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined,
skillsPrompt,
docsPath: docsPath ?? undefined,
promptMode,
runtimeInfo,
sandboxInfo,

View File

@@ -31,6 +31,7 @@ import {
isContextOverflowError,
isFailoverAssistantError,
isFailoverErrorMessage,
parseImageDimensionError,
isRateLimitAssistantError,
isTimeoutErrorMessage,
pickFallbackThinkingLevel,
@@ -357,6 +358,26 @@ export async function runEmbeddedPiAgent(
const failoverFailure = isFailoverAssistantError(lastAssistant);
const assistantFailoverReason = classifyFailoverReason(lastAssistant?.errorMessage ?? "");
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)
const shouldRotate = (!aborted && failoverFailure) || timedOut;
@@ -432,7 +453,6 @@ export async function runEmbeddedPiAgent(
toolMetas: attempt.toolMetas,
lastAssistant: attempt.lastAssistant,
lastToolError: attempt.lastToolError,
didSendViaMessagingTool: attempt.didSendViaMessagingTool,
config: params.config,
sessionKey: params.sessionKey ?? params.sessionId,
verboseLevel: params.verboseLevel,

View File

@@ -18,6 +18,7 @@ import { resolveUserPath } from "../../../utils.js";
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.js";
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
import { resolveClawdbotDocsPath } from "../../docs-path.js";
import { resolveModelAuthMode } from "../../model-auth.js";
import {
isCloudCodeAssistFormatError,
@@ -216,6 +217,12 @@ export async function runEmbeddedAttempt(
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
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({
workspaceDir: effectiveWorkspace,
@@ -228,6 +235,7 @@ export async function runEmbeddedAttempt(
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined,
skillsPrompt,
docsPath: docsPath ?? undefined,
reactionGuidance,
promptMode,
runtimeInfo,

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
import type { ImageContent } from "@mariozechner/pi-ai";
import { assertSandboxPath } from "../../sandbox-paths.js";
import { sanitizeImageBlocks } from "../../tool-images.js";
import { extractTextFromMessage } from "../../../tui/tui-formatters.js";
import { loadWebMedia } from "../../../web/media.js";
import { resolveUserPath } from "../../../utils.js";
@@ -48,6 +49,17 @@ function isImageExtension(filePath: string): boolean {
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.
*
@@ -392,9 +404,18 @@ 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 {
images: promptImages,
historyImagesByIndex,
images: sanitizedPromptImages,
historyImagesByIndex: sanitizedHistoryImagesByIndex,
detectedRefs: allRefs,
loadedCount,
skippedCount,

View File

@@ -147,40 +147,4 @@ describe("buildEmbeddedRunPayloads", () => {
expect(payloads).toHaveLength(1);
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);
});
});

View File

@@ -24,7 +24,6 @@ export function buildEmbeddedRunPayloads(params: {
toolMetas: ToolMetaEntry[];
lastAssistant: AssistantMessage | undefined;
lastToolError?: { toolName: string; meta?: string; error?: string };
didSendViaMessagingTool?: boolean;
config?: ClawdbotConfig;
sessionKey: string;
verboseLevel?: VerboseLevel;
@@ -157,46 +156,34 @@ export function buildEmbeddedRunPayloads(params: {
});
}
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
) {
if (replyItems.length === 0 && params.lastToolError) {
const toolSummary = formatToolAggregate(
params.lastToolError.toolName,
params.lastToolError.meta ? [params.lastToolError.meta] : undefined,
{ markdown: useMarkdown },
);
const errorSuffix = params.lastToolError.error ? `: ${params.lastToolError.error}` : "";
payloads = buildPayloads([
{
text: `⚠️ ${toolSummary} failed${errorSuffix}`,
isError: true,
},
]);
replyItems.push({
text: `⚠️ ${toolSummary} failed${errorSuffix}`,
isError: true,
});
}
return payloads;
const hasAudioAsVoiceTag = replyItems.some((item) => item.audioAsVoice);
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;
});
}

View File

@@ -15,6 +15,7 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: boolean;
heartbeatPrompt?: string;
skillsPrompt?: string;
docsPath?: string;
reactionGuidance?: {
level: "minimal" | "extensive";
channel: string;
@@ -48,6 +49,7 @@ export function buildEmbeddedSystemPrompt(params: {
reasoningTagHint: params.reasoningTagHint,
heartbeatPrompt: params.heartbeatPrompt,
skillsPrompt: params.skillsPrompt,
docsPath: params.docsPath,
reactionGuidance: params.reactionGuidance,
promptMode: params.promptMode,
runtimeInfo: params.runtimeInfo,

View File

@@ -32,12 +32,14 @@ describe("buildAgentSystemPrompt", () => {
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
heartbeatPrompt: "ping",
toolNames: ["message", "memory_search"],
docsPath: "/tmp/clawd/docs",
extraSystemPrompt: "Subagent details",
});
expect(prompt).not.toContain("## User Identity");
expect(prompt).not.toContain("## Skills");
expect(prompt).not.toContain("## Memory Recall");
expect(prompt).not.toContain("## Documentation");
expect(prompt).not.toContain("## Reply Tags");
expect(prompt).not.toContain("## Messaging");
expect(prompt).not.toContain("## Silent Replies");
@@ -86,6 +88,7 @@ describe("buildAgentSystemPrompt", () => {
toolNames: ["Read", "Exec", "process"],
skillsPrompt:
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
docsPath: "/tmp/clawd/docs",
});
expect(prompt).toContain("- Read: Read file contents");
@@ -93,6 +96,20 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain(
"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)", () => {

View File

@@ -109,6 +109,26 @@ 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: {
workspaceDir: string;
defaultThinkLevel?: ThinkLevel;
@@ -125,6 +145,7 @@ export function buildAgentSystemPrompt(params: {
contextFiles?: EmbeddedContextFile[];
skillsPrompt?: string;
heartbeatPrompt?: string;
docsPath?: string;
/** Controls which hardcoded sections to include. Defaults to "full". */
promptMode?: PromptMode;
runtimeInfo?: {
@@ -295,6 +316,11 @@ export function buildAgentSystemPrompt(params: {
readToolName,
});
const memorySection = buildMemorySection({ isMinimal, availableTools });
const docsSection = buildDocsSection({
docsPath: params.docsPath,
isMinimal,
readToolName,
});
// For "none" mode, return just the basic identity line
if (promptMode === "none") {
@@ -371,6 +397,7 @@ export function buildAgentSystemPrompt(params: {
`Your working directory is: ${params.workspaceDir}`,
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
"",
...docsSection,
params.sandboxInfo?.enabled ? "## Sandbox" : "",
params.sandboxInfo?.enabled
? [

View File

@@ -1,7 +1,7 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { sanitizeContentBlocksImages } from "./tool-images.js";
import { sanitizeContentBlocksImages, sanitizeImageBlocks } from "./tool-images.js";
describe("tool image sanitizing", () => {
it("shrinks oversized images to <=5MB", async () => {
@@ -33,6 +33,56 @@ describe("tool image sanitizing", () => {
expect(image.mimeType).toBe("image/jpeg");
}, 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 () => {
const jpeg = await sharp({
create: {

View File

@@ -1,5 +1,7 @@
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";
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
@@ -14,6 +16,7 @@ type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
// and recompress base64 image blocks when they exceed these limits.
const MAX_IMAGE_DIMENSION_PX = 2000;
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const log = createSubsystemLogger("agents/tool-images");
function isImageBlock(block: unknown): block is ImageContentBlock {
if (!block || typeof block !== "object") return false;
@@ -41,26 +44,41 @@ async function resizeImageBase64IfNeeded(params: {
mimeType: string;
maxDimensionPx: number;
maxBytes: number;
}): Promise<{ base64: string; mimeType: string; resized: boolean }> {
label?: string;
}): Promise<{
base64: string;
mimeType: string;
resized: boolean;
width?: number;
height?: number;
}> {
const buf = Buffer.from(params.base64, "base64");
const meta = await getImageMetadata(buf);
const width = meta?.width;
const height = meta?.height;
const overBytes = buf.byteLength > params.maxBytes;
const maxDim = Math.max(width ?? 0, height ?? 0);
if (typeof width !== "number" || typeof height !== "number") {
if (!overBytes) {
return {
base64: params.base64,
mimeType: params.mimeType,
resized: false,
};
}
} else if (!overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
return { base64: params.base64, mimeType: params.mimeType, resized: false };
const hasDimensions = typeof width === "number" && typeof height === "number";
if (hasDimensions && !overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
return {
base64: params.base64,
mimeType: params.mimeType,
resized: false,
width,
height,
};
}
if (hasDimensions && (width > params.maxDimensionPx || height > params.maxDimensionPx || overBytes)) {
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 maxDim = hasDimensions ? Math.max(width ?? 0, height ?? 0) : params.maxDimensionPx;
const sideStart = maxDim > 0 ? Math.min(params.maxDimensionPx, maxDim) : params.maxDimensionPx;
const sideGrid = [sideStart, 1800, 1600, 1400, 1200, 1000, 800]
.map((v) => Math.min(params.maxDimensionPx, v))
@@ -80,10 +98,23 @@ async function resizeImageBase64IfNeeded(params: {
smallest = { buffer: out, size: out.byteLength };
}
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 {
base64: out.toString("base64"),
mimeType: "image/jpeg",
resized: true,
width,
height,
};
}
}
@@ -127,6 +158,7 @@ export async function sanitizeContentBlocksImages(
mimeType,
maxDimensionPx,
maxBytes,
label,
});
out.push({
...block,
@@ -144,6 +176,17 @@ export async function sanitizeContentBlocksImages(
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(
result: AgentToolResult<unknown>,
label: string,

View File

@@ -189,7 +189,7 @@ export async function handleBashChatCommand(params: {
}): Promise<ReplyPayload> {
if (params.cfg.commands?.bash !== true) {
return {
text: "⚠️ bash is disabled. Set commands.bash=true to enable.",
text: "⚠️ bash is disabled. Set commands.bash=true to enable. Docs: https://docs.clawd.bot/tools/slash-commands#config",
};
}

View File

@@ -0,0 +1,87 @@
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);
});
});

View File

@@ -0,0 +1,243 @@
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);
}

View File

@@ -46,7 +46,7 @@ type NodeDaemonStatusOptions = {
};
function renderNodeServiceStartHints(): string[] {
const base = ["clawdbot node daemon install", "clawdbot node start"];
const base = ["clawdbot node service install", "clawdbot node start"];
switch (process.platform) {
case "darwin":
return [
@@ -133,7 +133,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
};
if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; daemon install is disabled.");
fail("Nix mode detected; service install is disabled.");
return;
}
@@ -168,7 +168,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
});
if (!json) {
defaultRuntime.log(`Node service already ${service.loadedText}.`);
defaultRuntime.log("Reinstall with: clawdbot node daemon install --force");
defaultRuntime.log("Reinstall with: clawdbot node service install --force");
}
return;
}
@@ -244,7 +244,7 @@ export async function runNodeDaemonUninstall(opts: NodeDaemonLifecycleOptions =
};
if (resolveIsNixMode(process.env)) {
fail("Nix mode detected; daemon uninstall is disabled.");
fail("Nix mode detected; service uninstall is disabled.");
return;
}

View File

@@ -51,63 +51,71 @@ 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
.command("daemon")
.description("Manage the headless node daemon service (launchd/systemd/schtasks)");
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);
});
.command("daemon", { hidden: true })
.description("Legacy alias for node service commands");
registerNodeServiceCommands(daemon);
}

View File

@@ -8,6 +8,7 @@ import { registerDaemonCli } from "../daemon-cli.js";
import { registerDnsCli } from "../dns-cli.js";
import { registerDirectoryCli } from "../directory-cli.js";
import { registerDocsCli } from "../docs-cli.js";
import { registerExecApprovalsCli } from "../exec-approvals-cli.js";
import { registerGatewayCli } from "../gateway-cli.js";
import { registerHooksCli } from "../hooks-cli.js";
import { registerWebhooksCli } from "../webhooks-cli.js";
@@ -19,6 +20,7 @@ import { registerPairingCli } from "../pairing-cli.js";
import { registerPluginsCli } from "../plugins-cli.js";
import { registerSandboxCli } from "../sandbox-cli.js";
import { registerSecurityCli } from "../security-cli.js";
import { registerServiceCli } from "../service-cli.js";
import { registerSkillsCli } from "../skills-cli.js";
import { registerTuiCli } from "../tui-cli.js";
import { registerUpdateCli } from "../update-cli.js";
@@ -27,8 +29,10 @@ export function registerSubCliCommands(program: Command) {
registerAcpCli(program);
registerDaemonCli(program);
registerGatewayCli(program);
registerServiceCli(program);
registerLogsCli(program);
registerModelsCli(program);
registerExecApprovalsCli(program);
registerNodesCli(program);
registerNodeCli(program);
registerSandboxCli(program);

View File

@@ -0,0 +1,59 @@
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
src/cli/service-cli.ts Normal file
View File

@@ -0,0 +1,157 @@
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();
}

View File

@@ -2,7 +2,9 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig, readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import type { GatewayService } 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 { normalizeControlUiBasePath } from "../gateway/control-ui.js";
import { probeGateway } from "../gateway/probe.js";
@@ -130,10 +132,9 @@ export async function statusAllCommand(
const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null);
progress.tick();
progress.setLabel("Checking daemon…");
const daemon = await (async () => {
progress.setLabel("Checking services…");
const readServiceSummary = async (service: GatewayService) => {
try {
const service = resolveGatewayService();
const [loaded, runtimeInfo, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined),
@@ -150,7 +151,9 @@ export async function statusAllCommand(
} catch {
return null;
}
})();
};
const daemon = await readServiceSummary(resolveGatewayService());
const nodeService = await readServiceSummary(resolveNodeService());
progress.tick();
progress.setLabel("Scanning agents…");
@@ -340,13 +343,22 @@ export async function statusAllCommand(
: { Item: "Gateway self", Value: "unknown" },
daemon
? {
Item: "Daemon",
Item: "Gateway service",
Value:
daemon.installed === false
? `${daemon.label} not installed`
: `${daemon.label} ${daemon.installed ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`,
}
: { Item: "Daemon", Value: "unknown" },
: { Item: "Gateway service", 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",
Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`,

View File

@@ -15,7 +15,7 @@ import {
} from "../memory/status-format.js";
import { formatHealthChannelLines, type HealthSummary } from "./health.js";
import { resolveControlUiLinks } from "./onboard-helpers.js";
import { getDaemonStatusSummary } from "./status.daemon.js";
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
import {
formatAge,
formatDuration,
@@ -116,6 +116,10 @@ export async function statusCommand(
: undefined;
if (opts.json) {
const [daemon, nodeDaemon] = await Promise.all([
getDaemonStatusSummary(),
getNodeDaemonStatusSummary(),
]);
runtime.log(
JSON.stringify(
{
@@ -134,6 +138,8 @@ export async function statusCommand(
self: gatewaySelf,
error: gatewayProbe?.error ?? null,
},
gatewayService: daemon,
nodeService: nodeDaemon,
agents: agentStatus,
securityAudit,
...(health || usage ? { health, usage } : {}),
@@ -210,12 +216,20 @@ export async function statusCommand(
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
})();
const daemon = await getDaemonStatusSummary();
const [daemon, nodeDaemon] = await Promise.all([
getDaemonStatusSummary(),
getNodeDaemonStatusSummary(),
]);
const daemonValue = (() => {
if (daemon.installed === false) return `${daemon.label} not installed`;
const installedPrefix = daemon.installed === true ? "installed · " : "";
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 defaultCtx = defaults.contextTokens
@@ -298,7 +312,8 @@ export async function statusCommand(
Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine,
},
{ Item: "Gateway", Value: gatewayValue },
{ Item: "Daemon", Value: daemonValue },
{ Item: "Gateway service", Value: daemonValue },
{ Item: "Node service", Value: nodeDaemonValue },
{ Item: "Agents", Value: agentsValue },
{ Item: "Memory", Value: memoryValue },
{ Item: "Probes", Value: probesValue },

View File

@@ -1,14 +1,20 @@
import type { GatewayService } from "../daemon/service.js";
import { resolveGatewayService } from "../daemon/service.js";
import { resolveNodeService } from "../daemon/node-service.js";
import { formatDaemonRuntimeShort } from "./status.format.js";
export async function getDaemonStatusSummary(): Promise<{
type DaemonStatusSummary = {
label: string;
installed: boolean | null;
loadedText: string;
runtimeShort: string | null;
}> {
};
async function buildDaemonStatusSummary(
service: GatewayService,
fallbackLabel: string,
): Promise<DaemonStatusSummary> {
try {
const service = resolveGatewayService();
const [loaded, runtime, command] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined),
@@ -20,10 +26,18 @@ export async function getDaemonStatusSummary(): Promise<{
return { label: service.label, installed, loadedText, runtimeShort };
} catch {
return {
label: "Daemon",
label: fallbackLabel,
installed: null,
loadedText: "unknown",
runtimeShort: null,
};
}
}
export async function getDaemonStatusSummary(): Promise<DaemonStatusSummary> {
return await buildDaemonStatusSummary(resolveGatewayService(), "Daemon");
}
export async function getNodeDaemonStatusSummary(): Promise<DaemonStatusSummary> {
return await buildDaemonStatusSummary(resolveNodeService(), "Node");
}

View File

@@ -243,6 +243,19 @@ 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", () => ({
runSecurityAudit: mocks.runSecurityAudit,
}));
@@ -273,6 +286,8 @@ describe("statusCommand", () => {
expect(payload.sessions.recent[0].flags).toContain("verbose:on");
expect(payload.securityAudit.summary.critical).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 () => {

View File

@@ -0,0 +1,65 @@
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");
}
});
});

View File

@@ -70,6 +70,12 @@ export type SlackThreadConfig = {
export type SlackAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
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. */
capabilities?: string[];
/** Override native command registration for Slack (bool or "auto"). */

View File

@@ -258,6 +258,9 @@ export const SlackThreadSchema = z.object({
export const SlackAccountSchema = z.object({
name: z.string().optional(),
mode: z.enum(["socket", "http"]).optional(),
signingSecret: z.string().optional(),
webhookPath: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
commands: ProviderCommandsSchema,
@@ -305,7 +308,35 @@ export const SlackAccountSchema = z.object({
});
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(),
}).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({

71
src/gateway/boot.test.ts Normal file
View File

@@ -0,0 +1,71 @@
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
src/gateway/boot.ts Normal file
View File

@@ -0,0 +1,92 @@
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 };
}
}

View File

@@ -58,6 +58,10 @@ import {
CronUpdateParamsSchema,
type ExecApprovalsGetParams,
ExecApprovalsGetParamsSchema,
type ExecApprovalsNodeGetParams,
ExecApprovalsNodeGetParamsSchema,
type ExecApprovalsNodeSetParams,
ExecApprovalsNodeSetParamsSchema,
type ExecApprovalsSetParams,
ExecApprovalsSetParamsSchema,
type ExecApprovalsSnapshot,
@@ -241,6 +245,12 @@ export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
ExecApprovalsSetParamsSchema,
);
export const validateExecApprovalsNodeGetParams = ajv.compile<ExecApprovalsNodeGetParams>(
ExecApprovalsNodeGetParamsSchema,
);
export const validateExecApprovalsNodeSetParams = ajv.compile<ExecApprovalsNodeSetParams>(
ExecApprovalsNodeSetParamsSchema,
);
export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema);
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);

View File

@@ -70,3 +70,19 @@ export const ExecApprovalsSetParamsSchema = Type.Object(
},
{ 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 },
);

View File

@@ -49,6 +49,8 @@ import {
} from "./cron.js";
import {
ExecApprovalsGetParamsSchema,
ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema,
} from "./exec-approvals.js";
@@ -177,6 +179,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
LogsTailResult: LogsTailResultSchema,
ExecApprovalsGetParams: ExecApprovalsGetParamsSchema,
ExecApprovalsSetParams: ExecApprovalsSetParamsSchema,
ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
ChatHistoryParams: ChatHistoryParamsSchema,
ChatSendParams: ChatSendParamsSchema,

View File

@@ -47,6 +47,8 @@ import type {
} from "./cron.js";
import type {
ExecApprovalsGetParamsSchema,
ExecApprovalsNodeGetParamsSchema,
ExecApprovalsNodeSetParamsSchema,
ExecApprovalsSetParamsSchema,
ExecApprovalsSnapshotSchema,
} from "./exec-approvals.js";
@@ -170,6 +172,8 @@ export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
export type LogsTailResult = Static<typeof LogsTailResultSchema>;
export type ExecApprovalsGetParams = Static<typeof ExecApprovalsGetParamsSchema>;
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 ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;

View File

@@ -8,6 +8,7 @@ import type { WebSocketServer } from "ws";
import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
import type { CanvasHostHandler } from "../canvas-host/server.js";
import type { createSubsystemLogger } from "../logging.js";
import { handleSlackHttpRequest } from "../slack/http/index.js";
import { handleControlUiHttpRequest } from "./control-ui.js";
import {
extractHookToken,
@@ -208,6 +209,7 @@ export function createGatewayHttpServer(opts: {
void (async () => {
if (await handleHooksRequest(req, res)) return;
if (await handleSlackHttpRequest(req, res)) return;
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
if (openAiChatCompletionsEnabled) {
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return;

View File

@@ -14,6 +14,8 @@ const BASE_METHODS = [
"config.schema",
"exec.approvals.get",
"exec.approvals.set",
"exec.approvals.node.get",
"exec.approvals.node.set",
"wizard.start",
"wizard.next",
"wizard.cancel",

View File

@@ -12,8 +12,11 @@ import {
errorShape,
formatValidationErrors,
validateExecApprovalsGetParams,
validateExecApprovalsNodeGetParams,
validateExecApprovalsNodeSetParams,
validateExecApprovalsSetParams,
} from "../protocol/index.js";
import { respondUnavailableOnThrow, safeParseJson } from "./nodes.helpers.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
function resolveBaseHash(params: unknown): string | null {
@@ -152,4 +155,94 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
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);
});
},
};

View File

@@ -20,9 +20,7 @@ import type { DedupeEntry } from "./server-shared.js";
import type { PluginRegistry } from "../plugins/registry.js";
export async function createGatewayRuntimeState(params: {
cfg: {
canvasHost?: { root?: string; enabled?: boolean; liveReload?: boolean };
};
cfg: import("../config/config.js").ClawdbotConfig;
bindHost: string;
port: number;
controlUiEnabled: boolean;

View File

@@ -8,7 +8,11 @@ import {
import type { CliDeps } from "../cli/deps.js";
import type { loadConfig } from "../config/config.js";
import { startGmailWatcher } from "../hooks/gmail-watcher.js";
import { clearInternalHooks } from "../hooks/internal-hooks.js";
import {
clearInternalHooks,
createInternalHookEvent,
triggerInternalHook,
} from "../hooks/internal-hooks.js";
import { loadInternalHooks } from "../hooks/loader.js";
import type { loadClawdbotPlugins } from "../plugins/loader.js";
import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js";
@@ -122,6 +126,17 @@ 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;
try {
pluginServices = await startPluginServices({

View File

@@ -47,6 +47,20 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
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
Each hook is a directory containing:
@@ -156,6 +170,7 @@ Currently supported events:
- **command:reset**: `/reset` command
- **command:stop**: `/stop` command
- **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.).
@@ -165,7 +180,7 @@ Hook handlers receive an `InternalHookEvent` object:
```typescript
interface InternalHookEvent {
type: "command" | "session" | "agent";
type: "command" | "session" | "agent" | "gateway";
action: string; // e.g., 'new', 'reset', 'stop'
sessionKey: string;
context: Record<string, unknown>;

View File

@@ -0,0 +1,19 @@
---
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.

View File

@@ -0,0 +1,27 @@
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;

View File

@@ -8,7 +8,7 @@
import type { WorkspaceBootstrapFile } from "../agents/workspace.js";
import type { ClawdbotConfig } from "../config/config.js";
export type InternalHookEventType = "command" | "session" | "agent";
export type InternalHookEventType = "command" | "session" | "agent" | "gateway";
export type AgentBootstrapHookContext = {
workspaceDir: string;
@@ -26,7 +26,7 @@ export type AgentBootstrapHookEvent = InternalHookEvent & {
};
export interface InternalHookEvent {
/** The type of event (command, session, agent, etc.) */
/** The type of event (command, session, agent, gateway, etc.) */
type: InternalHookEventType;
/** The specific action within the type (e.g., 'new', 'reset', 'stop') */
action: string;

View File

@@ -0,0 +1,108 @@
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");
});
});

View File

@@ -242,7 +242,7 @@ function parseFirstToken(command: string): string | null {
if (end > 1) return trimmed.slice(1, end);
return trimmed.slice(1);
}
const match = /^[^\\s]+/.exec(trimmed);
const match = /^[^\s]+/.exec(trimmed);
return match ? match[0] : null;
}

View File

@@ -0,0 +1,114 @@
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(() => {});
}
});
});

View File

@@ -39,6 +39,8 @@ import {
import { describeImageWithModel } from "./providers/image.js";
import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js";
const AUTO_AUDIO_PROVIDERS = ["openai", "groq", "deepgram"] as const;
export type ActiveMediaModel = {
provider: string;
model?: string;
@@ -65,6 +67,29 @@ export function createMediaAttachmentCache(attachments: MediaAttachment[]): Medi
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 {
const trimmed = text.trim();
if (!maxChars || trimmed.length <= maxChars) return trimmed;
@@ -561,7 +586,15 @@ export async function runCapability(params: {
providerRegistry: params.providerRegistry,
activeModel: params.activeModel,
});
if (entries.length === 0) {
let resolvedEntries = entries;
if (resolvedEntries.length === 0 && capability === "audio") {
resolvedEntries = await resolveAutoAudioEntries({
cfg,
agentDir: params.agentDir,
providerRegistry: params.providerRegistry,
});
}
if (resolvedEntries.length === 0) {
return {
outputs: [],
decision: {
@@ -583,7 +616,7 @@ export async function runCapability(params: {
agentDir: params.agentDir,
providerRegistry: params.providerRegistry,
cache: params.attachments,
entries,
entries: resolvedEntries,
config,
});
if (output) outputs.push(output);

View File

@@ -0,0 +1,93 @@
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);
});
});

View File

@@ -8,10 +8,16 @@ import type { BridgeInvokeRequestFrame } from "../infra/bridge/server/types.js";
import {
addAllowlistEntry,
matchAllowlist,
normalizeExecApprovals,
recordAllowlistUse,
requestExecApprovalViaSocket,
resolveCommandResolution,
resolveExecApprovals,
ensureExecApprovals,
readExecApprovalsSnapshot,
resolveExecApprovalsSocketPath,
saveExecApprovals,
type ExecApprovalsFile,
} from "../infra/exec-approvals.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { VERSION } from "../version.js";
@@ -43,6 +49,18 @@ type SystemWhichParams = {
bins: string[];
};
type SystemExecApprovalsSetParams = {
file: ExecApprovalsFile;
baseHash?: string | null;
};
type ExecApprovalsSnapshot = {
path: string;
exists: boolean;
hash: string;
file: ExecApprovalsFile;
};
type RunResult = {
exitCode?: number;
timedOut: boolean;
@@ -143,6 +161,31 @@ function truncateOutput(raw: string, maxChars: number): { text: string; truncate
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(
argv: string[],
cwd: string | undefined,
@@ -306,7 +349,12 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
deviceFamily: os.platform(),
modelIdentifier: os.hostname(),
caps: ["system"],
commands: ["system.run", "system.which"],
commands: [
"system.run",
"system.which",
"system.execApprovals.get",
"system.execApprovals.set",
],
onPairToken: async (token) => {
config.token = token;
await saveNodeHostConfig(config);
@@ -355,6 +403,80 @@ async function handleInvoke(
skillBins: SkillBinsCache,
) {
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") {
try {
const params = decodeParams<SystemWhichParams>(frame.paramsJSON);

1
src/slack/http/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from "./registry.js";

View File

@@ -0,0 +1,87 @@
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"',
);
});
});

View File

@@ -0,0 +1,45 @@
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;
}

View File

@@ -1,4 +1,6 @@
import { App } from "@slack/bolt";
import type { IncomingMessage, ServerResponse } from "node:http";
import SlackBolt from "@slack/bolt";
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
@@ -14,6 +16,7 @@ import { resolveSlackAccount } from "../accounts.js";
import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
import { resolveSlackUserAllowlist } from "../resolve-users.js";
import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js";
import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js";
import { resolveSlackSlashCommandConfig } from "./commands.js";
import { createSlackMonitorContext } from "./context.js";
import { registerSlackMonitorEvents } from "./events.js";
@@ -23,6 +26,8 @@ import { normalizeAllowList } from "./allow-list.js";
import type { MonitorSlackOpts } from "./types.js";
const { App, HTTPReceiver } = SlackBolt;
function parseApiAppIdFromAppToken(raw?: string) {
const token = raw?.trim();
if (!token) return undefined;
@@ -49,11 +54,21 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const sessionScope: SessionScope = sessionCfg?.scope ?? "per-sender";
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 appToken = resolveSlackAppToken(opts.appToken ?? account.appToken);
if (!botToken || !appToken) {
if (!botToken || (slackMode !== "http" && !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(
`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).`,
`Slack signing secret missing for account "${account.accountId}" (set channels.slack.signingSecret or channels.slack.accounts.${account.accountId}.signingSecret).`,
);
}
@@ -102,11 +117,32 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const app = new App({
token: botToken,
appToken,
socketMode: true,
});
const receiver =
slackMode === "http"
? new HTTPReceiver({
signingSecret: signingSecret ?? "",
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 teamId = "";
@@ -164,6 +200,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
registerSlackMonitorEvents({ ctx, account, handleSlackMessage });
registerSlackMonitorSlashCommands({ ctx, account });
if (slackMode === "http" && slackHttpHandler) {
unregisterHttpHandler = registerSlackHttpHandler({
path: slackWebhookPath,
handler: slackHttpHandler,
log: runtime.log,
accountId: account.accountId,
});
}
if (resolveToken) {
void (async () => {
@@ -284,13 +328,17 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
}
const stopOnAbort = () => {
if (opts.abortSignal?.aborted) void app.stop();
if (opts.abortSignal?.aborted && slackMode === "socket") void app.stop();
};
opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true });
try {
await app.start();
runtime.log?.("slack socket mode connected");
if (slackMode === "socket") {
await app.start();
runtime.log?.("slack socket mode connected");
} else {
runtime.log?.(`slack http mode listening at ${slackWebhookPath}`);
}
if (opts.abortSignal?.aborted) return;
await new Promise<void>((resolve) => {
opts.abortSignal?.addEventListener("abort", () => resolve(), {
@@ -299,6 +347,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
});
} finally {
opts.abortSignal?.removeEventListener("abort", stopOnAbort);
unregisterHttpHandler?.();
await app.stop().catch(() => undefined);
}
}

View File

@@ -6,6 +6,7 @@ export type MonitorSlackOpts = {
botToken?: string;
appToken?: string;
accountId?: string;
mode?: "socket" | "http";
config?: ClawdbotConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;

View File

@@ -310,9 +310,17 @@ export function renderApp(state: AppViewState) {
execApprovalsSnapshot: state.execApprovalsSnapshot,
execApprovalsForm: state.execApprovalsForm,
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state),
onLoadConfig: () => loadConfig(state),
onLoadExecApprovals: () => loadExecApprovals(state),
onLoadExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return loadExecApprovals(state, target);
},
onBindDefault: (nodeId) => {
if (nodeId) {
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
@@ -329,6 +337,14 @@ export function renderApp(state: AppViewState) {
}
},
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) => {
state.execApprovalsSelectedAgent = agentId;
},
@@ -336,7 +352,13 @@ export function renderApp(state: AppViewState) {
updateExecApprovalsFormValue(state, path, value),
onExecApprovalsRemove: (path) =>
removeExecApprovalsFormValue(state, path),
onSaveExecApprovals: () => saveExecApprovals(state),
onSaveExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return saveExecApprovals(state, target);
},
})
: nothing}

View File

@@ -54,6 +54,8 @@ export type AppViewState = {
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null;
execApprovalsTarget: "gateway" | "node";
execApprovalsTargetNodeId: string | null;
configLoading: boolean;
configRaw: string;
configValid: boolean | null;

View File

@@ -114,6 +114,8 @@ export class ClawdbotApp extends LitElement {
@state() execApprovalsSnapshot: ExecApprovalsSnapshot | null = null;
@state() execApprovalsForm: ExecApprovalsFile | null = null;
@state() execApprovalsSelectedAgent: string | null = null;
@state() execApprovalsTarget: "gateway" | "node" = "gateway";
@state() execApprovalsTargetNodeId: string | null = null;
@state() configLoading = false;
@state() configRaw = "{\n}\n";

View File

@@ -33,6 +33,10 @@ export type ExecApprovalsSnapshot = {
file: ExecApprovalsFile;
};
export type ExecApprovalsTarget =
| { kind: "gateway" }
| { kind: "node"; nodeId: string };
export type ExecApprovalsState = {
client: GatewayBrowserClient | null;
connected: boolean;
@@ -45,16 +49,45 @@ export type ExecApprovalsState = {
lastError: string | null;
};
export async function loadExecApprovals(state: ExecApprovalsState) {
function resolveExecApprovalsRpc(target?: ExecApprovalsTarget | null): {
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.execApprovalsLoading) return;
state.execApprovalsLoading = true;
state.lastError = null;
try {
const res = (await state.client.request(
"exec.approvals.get",
{},
)) as ExecApprovalsSnapshot;
const rpc = resolveExecApprovalsRpc(target);
if (!rpc) {
state.lastError = "Select a node before loading exec approvals.";
return;
}
const res = (await state.client.request(rpc.method, rpc.params)) as ExecApprovalsSnapshot;
applyExecApprovalsSnapshot(state, res);
} catch (err) {
state.lastError = String(err);
@@ -73,7 +106,10 @@ export function applyExecApprovalsSnapshot(
}
}
export async function saveExecApprovals(state: ExecApprovalsState) {
export async function saveExecApprovals(
state: ExecApprovalsState,
target?: ExecApprovalsTarget | null,
) {
if (!state.client || !state.connected) return;
state.execApprovalsSaving = true;
state.lastError = null;
@@ -87,9 +123,14 @@ export async function saveExecApprovals(state: ExecApprovalsState) {
state.execApprovalsForm ??
state.execApprovalsSnapshot?.file ??
{};
await state.client.request("exec.approvals.set", { file, baseHash });
const rpc = resolveExecApprovalsSaveRpc(target, { 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;
await loadExecApprovals(state);
await loadExecApprovals(state, target);
} catch (err) {
state.lastError = String(err);
} finally {

View File

@@ -21,12 +21,15 @@ export type NodesProps = {
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
execApprovalsForm: ExecApprovalsFile | null;
execApprovalsSelectedAgent: string | null;
execApprovalsTarget: "gateway" | "node";
execApprovalsTargetNodeId: string | null;
onRefresh: () => void;
onLoadConfig: () => void;
onLoadExecApprovals: () => void;
onBindDefault: (nodeId: string | null) => void;
onBindAgent: (agentIndex: number, nodeId: string | null) => void;
onSaveBindings: () => void;
onExecApprovalsTargetChange: (kind: "gateway" | "node", nodeId: string | null) => void;
onExecApprovalsSelectAgent: (agentId: string) => void;
onExecApprovalsPatch: (path: Array<string | number>, value: unknown) => void;
onExecApprovalsRemove: (path: Array<string | number>) => void;
@@ -103,6 +106,11 @@ type ExecApprovalsAgentOption = {
isDefault?: boolean;
};
type ExecApprovalsTargetNode = {
id: string;
label: string;
};
type ExecApprovalsState = {
ready: boolean;
disabled: boolean;
@@ -115,7 +123,11 @@ type ExecApprovalsState = {
selectedAgent: Record<string, unknown> | null;
agents: ExecApprovalsAgentOption[];
allowlist: ExecApprovalsAllowlistEntry[];
target: "gateway" | "node";
targetNodeId: string | null;
targetNodes: ExecApprovalsTargetNode[];
onSelectScope: (agentId: string) => void;
onSelectTarget: (kind: "gateway" | "node", nodeId: string | null) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
onRemove: (path: Array<string | number>) => void;
onLoad: () => void;
@@ -237,6 +249,15 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
const ready = Boolean(form);
const defaults = resolveExecApprovalsDefaults(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 selectedAgent =
selectedScope !== EXEC_APPROVALS_DEFAULT_SCOPE
@@ -259,7 +280,11 @@ function resolveExecApprovalsState(props: NodesProps): ExecApprovalsState {
selectedAgent,
agents,
allowlist,
target,
targetNodeId,
targetNodes,
onSelectScope: props.onExecApprovalsSelectAgent,
onSelectTarget: props.onExecApprovalsTargetChange,
onPatch: props.onExecApprovalsPatch,
onRemove: props.onExecApprovalsRemove,
onLoad: props.onLoadExecApprovals,
@@ -350,6 +375,7 @@ function renderBindings(state: BindingState) {
function renderExecApprovals(state: ExecApprovalsState) {
const ready = state.ready;
const targetReady = state.target !== "node" || Boolean(state.targetNodeId);
return html`
<section class="card">
<div class="row" style="justify-content: space-between; align-items: center;">
@@ -361,17 +387,19 @@ function renderExecApprovals(state: ExecApprovalsState) {
</div>
<button
class="btn"
?disabled=${state.disabled || !state.dirty}
?disabled=${state.disabled || !state.dirty || !targetReady}
@click=${state.onSave}
>
${state.saving ? "Saving…" : "Save"}
</button>
</div>
${renderExecApprovalsTarget(state)}
${!ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load exec approvals to edit allowlists.</div>
<button class="btn" ?disabled=${state.loading} @click=${state.onLoad}>
<button class="btn" ?disabled=${state.loading || !targetReady} @click=${state.onLoad}>
${state.loading ? "Loading…" : "Load approvals"}
</button>
</div>`
@@ -386,6 +414,73 @@ 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) {
return html`
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
@@ -747,6 +842,26 @@ function resolveExecNodes(nodes: Array<Record<string, unknown>>): BindingNode[]
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): {
defaultBinding?: string | null;
agents: BindingAgent[];