Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
296275b53b | ||
|
|
4c9760bbbc | ||
|
|
b546b2a48d | ||
|
|
c11b016d22 | ||
|
|
3686bde783 | ||
|
|
9c06689569 | ||
|
|
891a2cc64a | ||
|
|
01211937fc | ||
|
|
4726580c7e | ||
|
|
e9a08dc507 | ||
|
|
f3698e360b | ||
|
|
c69947dff8 | ||
|
|
173bce34b0 | ||
|
|
6a27e385b1 | ||
|
|
5f0d9c3eb9 | ||
|
|
0e31c8153c | ||
|
|
9c0773c469 | ||
|
|
f5533baf61 | ||
|
|
60bc436e99 | ||
|
|
741b984a68 | ||
|
|
858a5f48d8 | ||
|
|
20c26eb303 | ||
|
|
234fe5b5cd | ||
|
|
65710932ff |
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
- Repo: https://github.com/clawdbot/clawdbot
|
- Repo: https://github.com/clawdbot/clawdbot
|
||||||
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
|
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Structure & Module Organization
|
||||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||||
@@ -84,6 +84,7 @@
|
|||||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||||
- Never update the Carbon dependency.
|
- Never update the Carbon dependency.
|
||||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||||
|
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
||||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||||
|
|||||||
+27
-32
@@ -2,31 +2,30 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.18-4
|
## 2026.1.17-7
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
- Exec approvals: add `clawdbot approvals` CLI for viewing and updating gateway/node allowlists.
|
||||||
|
- CLI: add `clawdbot service` gateway/node management and a `clawdbot node status` alias.
|
||||||
|
- Status: show gateway + node service summaries in `clawdbot status` and `status --all`.
|
||||||
|
- Control UI: add gateway/node target selector for exec approvals.
|
||||||
|
- Docs: add approvals/service references and refresh node/control UI docs.
|
||||||
|
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
|
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
|
||||||
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
|
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
|
||||||
- Swabble: use the tagged Commander Swift package release.
|
- Swabble: use the tagged Commander Swift package release.
|
||||||
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
||||||
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
||||||
- Memory: add native Gemini embeddings provider for memory search. (#1151) — thanks @gumadeiras.
|
- 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.
|
||||||
### Fixes
|
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
|
||||||
- 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.
|
|
||||||
- Matrix: avoid redeclaring `allowFrom` in the monitor provider. (#1176) — thanks @sibbl.
|
|
||||||
|
|
||||||
## 2026.1.18-3
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Exec: add host/security/ask routing for gateway + node exec.
|
- Exec: add host/security/ask routing for gateway + node exec.
|
||||||
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
||||||
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
|
||||||
- macOS: add approvals socket UI server + node exec lifecycle events.
|
- macOS: add approvals socket UI server + node exec lifecycle events.
|
||||||
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
|
- Nodes: add headless node host (`clawdbot node start`) for `system.run`/`system.which`.
|
||||||
- Nodes: add node daemon service install/status/start/stop/restart.
|
- Nodes: add node daemon service install/status/start/stop/restart.
|
||||||
|
- Hooks: run `BOOT.md` on gateway startup with the boot-md hook. (#1164) — thanks @ngutman.
|
||||||
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
- Bridge: add `skills.bins` RPC to support node host auto-allow skill bins.
|
||||||
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
|
||||||
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
- Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) — thanks @austinm911.
|
||||||
@@ -34,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: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
|
||||||
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
|
- Docs: add node host CLI + update exec approvals/bridge protocol docs. https://docs.clawd.bot/cli/node
|
||||||
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
|
- ACP: add experimental ACP support for IDE integrations (`clawdbot acp`). Thanks @visionik.
|
||||||
|
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
||||||
|
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
||||||
|
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
||||||
|
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
||||||
|
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
||||||
|
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
||||||
|
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
||||||
|
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
|
||||||
|
- macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)
|
||||||
|
- Memory: index atomically so failed reindex preserves the previous memory database. (#1151)
|
||||||
|
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
||||||
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
- Exec approvals: enforce allowlist when ask is off; prefer raw command for node approvals/events.
|
||||||
|
- Exec approvals: parse command tokens correctly for PATH and relative resolution.
|
||||||
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
- Tools: return a companion-app-required message when node exec is requested with no paired node.
|
||||||
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
- Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147) — thanks @alauppe.
|
||||||
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
|
- Model fallback: treat timeout aborts as failover while preserving user aborts. (#1137) — thanks @cheeeee.
|
||||||
|
|
||||||
## 2026.1.18-2
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
||||||
|
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
||||||
|
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
||||||
|
- Discord: only emit slow listener warnings after 30s.
|
||||||
|
|
||||||
## 2026.1.17-6
|
## 2026.1.17-6
|
||||||
|
|
||||||
@@ -72,22 +83,6 @@ Docs: https://docs.clawd.bot
|
|||||||
### Fixes
|
### Fixes
|
||||||
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
|
||||||
|
|
||||||
## 2026.1.18-1
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
|
|
||||||
- Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers.
|
|
||||||
- Models: add Qwen Portal OAuth provider support. (#1120) — thanks @mukhtharcm.
|
|
||||||
- Memory: add `--verbose` logging for memory status + batch indexing details.
|
|
||||||
- Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2).
|
|
||||||
- macOS: add per-agent exec approvals with allowlists, skill CLI auto-allow, and settings UI.
|
|
||||||
- Docs: add exec approvals guide and link from tools index. https://docs.clawd.bot/tools/exec-approvals
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Memory: apply OpenAI batch defaults even without explicit remote config.
|
|
||||||
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
|
|
||||||
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
|
|
||||||
- Discord: only emit slow listener warnings after 30s.
|
|
||||||
## 2026.1.17-3
|
## 2026.1.17-3
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
"location" : "https://github.com/steipete/Peekaboo.git",
|
"location" : "https://github.com/steipete/Peekaboo.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"branch" : "main",
|
"branch" : "main",
|
||||||
"revision" : "b2d0384d9f0f45b945d5f718f8a865bd574d83c2"
|
"revision" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import Security
|
import Security
|
||||||
@@ -121,6 +122,13 @@ struct ExecApprovalsFile: Codable {
|
|||||||
var agents: [String: ExecApprovalsAgent]?
|
var agents: [String: ExecApprovalsAgent]?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ExecApprovalsSnapshot: Codable {
|
||||||
|
var path: String
|
||||||
|
var exists: Bool
|
||||||
|
var hash: String
|
||||||
|
var file: ExecApprovalsFile
|
||||||
|
}
|
||||||
|
|
||||||
struct ExecApprovalsResolved {
|
struct ExecApprovalsResolved {
|
||||||
let url: URL
|
let url: URL
|
||||||
let socketPath: String
|
let socketPath: String
|
||||||
@@ -153,6 +161,58 @@ enum ExecApprovalsStore {
|
|||||||
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
|
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||||
|
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return ExecApprovalsFile(
|
||||||
|
version: 1,
|
||||||
|
socket: ExecApprovalsSocketConfig(
|
||||||
|
path: socketPath.isEmpty ? nil : socketPath,
|
||||||
|
token: token.isEmpty ? nil : token),
|
||||||
|
defaults: file.defaults,
|
||||||
|
agents: file.agents)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||||
|
let url = self.fileURL()
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
|
return ExecApprovalsSnapshot(
|
||||||
|
path: url.path,
|
||||||
|
exists: false,
|
||||||
|
hash: self.hashRaw(nil),
|
||||||
|
file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]))
|
||||||
|
}
|
||||||
|
let raw = try? String(contentsOf: url, encoding: .utf8)
|
||||||
|
let data = raw.flatMap { $0.data(using: .utf8) }
|
||||||
|
let decoded: ExecApprovalsFile = {
|
||||||
|
if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 {
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
||||||
|
}()
|
||||||
|
return ExecApprovalsSnapshot(
|
||||||
|
path: url.path,
|
||||||
|
exists: true,
|
||||||
|
hash: self.hashRaw(raw),
|
||||||
|
file: decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||||
|
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if socketPath.isEmpty {
|
||||||
|
return ExecApprovalsFile(
|
||||||
|
version: file.version,
|
||||||
|
socket: nil,
|
||||||
|
defaults: file.defaults,
|
||||||
|
agents: file.agents)
|
||||||
|
}
|
||||||
|
return ExecApprovalsFile(
|
||||||
|
version: file.version,
|
||||||
|
socket: ExecApprovalsSocketConfig(path: socketPath, token: nil),
|
||||||
|
defaults: file.defaults,
|
||||||
|
agents: file.agents)
|
||||||
|
}
|
||||||
|
|
||||||
static func loadFile() -> ExecApprovalsFile {
|
static func loadFile() -> ExecApprovalsFile {
|
||||||
let url = self.fileURL()
|
let url = self.fileURL()
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
@@ -372,6 +432,12 @@ enum ExecApprovalsStore {
|
|||||||
return UUID().uuidString
|
return UUID().uuidString
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func hashRaw(_ raw: String?) -> String {
|
||||||
|
let data = Data((raw ?? "").utf8)
|
||||||
|
let digest = SHA256.hash(data: data)
|
||||||
|
return digest.map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
private static func expandPath(_ raw: String) -> String {
|
private static func expandPath(_ raw: String) -> String {
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed == "~" {
|
if trimmed == "~" {
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ final class MacNodeModeCoordinator {
|
|||||||
ClawdbotSystemCommand.notify.rawValue,
|
ClawdbotSystemCommand.notify.rawValue,
|
||||||
ClawdbotSystemCommand.which.rawValue,
|
ClawdbotSystemCommand.which.rawValue,
|
||||||
ClawdbotSystemCommand.run.rawValue,
|
ClawdbotSystemCommand.run.rawValue,
|
||||||
|
ClawdbotSystemCommand.execApprovalsGet.rawValue,
|
||||||
|
ClawdbotSystemCommand.execApprovalsSet.rawValue,
|
||||||
]
|
]
|
||||||
|
|
||||||
let capsSet = Set(caps)
|
let capsSet = Set(caps)
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ actor MacNodeRuntime {
|
|||||||
return try await self.handleSystemWhich(req)
|
return try await self.handleSystemWhich(req)
|
||||||
case ClawdbotSystemCommand.notify.rawValue:
|
case ClawdbotSystemCommand.notify.rawValue:
|
||||||
return try await self.handleSystemNotify(req)
|
return try await self.handleSystemNotify(req)
|
||||||
|
case ClawdbotSystemCommand.execApprovalsGet.rawValue:
|
||||||
|
return try await self.handleSystemExecApprovalsGet(req)
|
||||||
|
case ClawdbotSystemCommand.execApprovalsSet.rawValue:
|
||||||
|
return try await self.handleSystemExecApprovalsSet(req)
|
||||||
default:
|
default:
|
||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
|
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
|
||||||
}
|
}
|
||||||
@@ -676,6 +680,72 @@ actor MacNodeRuntime {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
|
_ = ExecApprovalsStore.ensureFile()
|
||||||
|
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||||
|
let redacted = ExecApprovalsSnapshot(
|
||||||
|
path: snapshot.path,
|
||||||
|
exists: snapshot.exists,
|
||||||
|
hash: snapshot.hash,
|
||||||
|
file: ExecApprovalsStore.redactForSnapshot(snapshot.file))
|
||||||
|
let payload = try Self.encodePayload(redacted)
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleSystemExecApprovalsSet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||||
|
struct SetParams: Decodable {
|
||||||
|
var file: ExecApprovalsFile
|
||||||
|
var baseHash: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON)
|
||||||
|
let current = ExecApprovalsStore.ensureFile()
|
||||||
|
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||||
|
if snapshot.exists {
|
||||||
|
if snapshot.hash.isEmpty {
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .invalidRequest,
|
||||||
|
message: "INVALID_REQUEST: exec approvals base hash unavailable; reload and retry")
|
||||||
|
}
|
||||||
|
let baseHash = params.baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
if baseHash.isEmpty {
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .invalidRequest,
|
||||||
|
message: "INVALID_REQUEST: exec approvals base hash required; reload and retry")
|
||||||
|
}
|
||||||
|
if baseHash != snapshot.hash {
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .invalidRequest,
|
||||||
|
message: "INVALID_REQUEST: exec approvals changed; reload and retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = ExecApprovalsStore.normalizeIncoming(params.file)
|
||||||
|
let socketPath = normalized.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let token = normalized.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let resolvedPath = (socketPath?.isEmpty == false)
|
||||||
|
? socketPath!
|
||||||
|
: current.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ??
|
||||||
|
ExecApprovalsStore.socketPath()
|
||||||
|
let resolvedToken = (token?.isEmpty == false)
|
||||||
|
? token!
|
||||||
|
: current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken)
|
||||||
|
|
||||||
|
ExecApprovalsStore.saveFile(normalized)
|
||||||
|
let nextSnapshot = ExecApprovalsStore.readSnapshot()
|
||||||
|
let redacted = ExecApprovalsSnapshot(
|
||||||
|
path: nextSnapshot.path,
|
||||||
|
exists: nextSnapshot.exists,
|
||||||
|
hash: nextSnapshot.hash,
|
||||||
|
file: ExecApprovalsStore.redactForSnapshot(nextSnapshot.file))
|
||||||
|
let payload = try Self.encodePayload(redacted)
|
||||||
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
|
}
|
||||||
|
|
||||||
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
|
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
|
||||||
guard let sender = self.eventSender else { return }
|
guard let sender = self.eventSender else { return }
|
||||||
guard let data = try? JSONEncoder().encode(payload),
|
guard let data = try? JSONEncoder().encode(payload),
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ public enum ClawdbotSystemCommand: String, Codable, Sendable {
|
|||||||
case run = "system.run"
|
case run = "system.run"
|
||||||
case which = "system.which"
|
case which = "system.which"
|
||||||
case notify = "system.notify"
|
case notify = "system.notify"
|
||||||
|
case execApprovalsGet = "system.execApprovals.get"
|
||||||
|
case execApprovalsSet = "system.execApprovals.set"
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ClawdbotNotificationPriority: String, Codable, Sendable {
|
public enum ClawdbotNotificationPriority: String, Codable, Sendable {
|
||||||
|
|||||||
+45
-10
@@ -1,11 +1,13 @@
|
|||||||
---
|
---
|
||||||
summary: "Slack socket mode setup and Clawdbot config"
|
summary: "Slack setup for socket or HTTP webhook mode"
|
||||||
read_when: "Setting up Slack or debugging Slack socket mode"
|
read_when: "Setting up Slack or debugging Slack socket/HTTP mode"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Slack (socket mode)
|
# Slack
|
||||||
|
|
||||||
## Quick setup (beginner)
|
## Socket mode (default)
|
||||||
|
|
||||||
|
### Quick setup (beginner)
|
||||||
1) Create a Slack app and enable **Socket Mode**.
|
1) Create a Slack app and enable **Socket Mode**.
|
||||||
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
|
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
|
||||||
3) Set tokens for Clawdbot and start the gateway.
|
3) Set tokens for Clawdbot and start the gateway.
|
||||||
@@ -23,7 +25,7 @@ Minimal config:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup
|
### Setup
|
||||||
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
||||||
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
||||||
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||||
@@ -43,7 +45,7 @@ Use the manifest below so scopes and events stay in sync.
|
|||||||
|
|
||||||
Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||||
|
|
||||||
## Clawdbot config (minimal)
|
### Clawdbot config (minimal)
|
||||||
|
|
||||||
Set tokens via env vars (recommended):
|
Set tokens via env vars (recommended):
|
||||||
- `SLACK_APP_TOKEN=xapp-...`
|
- `SLACK_APP_TOKEN=xapp-...`
|
||||||
@@ -63,7 +65,7 @@ Or via config:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## User token (optional)
|
### User token (optional)
|
||||||
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
|
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
|
||||||
pins, reactions, emoji, member info). By default this stays read-only: reads
|
pins, reactions, emoji, member info). By default this stays read-only: reads
|
||||||
prefer the user token when present, and writes still use the bot token unless
|
prefer the user token when present, and writes still use the bot token unless
|
||||||
@@ -102,18 +104,51 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Token usage
|
#### Token usage
|
||||||
- Read operations (history, reactions list, pins list, emoji list, member info,
|
- Read operations (history, reactions list, pins list, emoji list, member info,
|
||||||
search) prefer the user token when configured, otherwise the bot token.
|
search) prefer the user token when configured, otherwise the bot token.
|
||||||
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
|
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
|
||||||
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
|
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
|
||||||
no bot token is available, Clawdbot falls back to the user token.
|
no bot token is available, Clawdbot falls back to the user token.
|
||||||
|
|
||||||
## History context
|
### History context
|
||||||
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
||||||
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||||
|
|
||||||
## Manifest (optional)
|
## HTTP mode (Events API)
|
||||||
|
Use HTTP webhook mode when your Gateway is reachable by Slack over HTTPS (typical for server deployments).
|
||||||
|
HTTP mode uses the Events API + Interactivity + Slash Commands with a shared request URL.
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
1) Create a Slack app and **disable Socket Mode** (optional if you only use HTTP).
|
||||||
|
2) **Basic Information** → copy the **Signing Secret**.
|
||||||
|
3) **OAuth & Permissions** → install the app and copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||||
|
4) **Event Subscriptions** → enable events and set the **Request URL** to your gateway webhook path (default `/slack/events`).
|
||||||
|
5) **Interactivity & Shortcuts** → enable and set the same **Request URL**.
|
||||||
|
6) **Slash Commands** → set the same **Request URL** for your command(s).
|
||||||
|
|
||||||
|
Example request URL:
|
||||||
|
`https://gateway-host/slack/events`
|
||||||
|
|
||||||
|
### Clawdbot config (minimal)
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
channels: {
|
||||||
|
slack: {
|
||||||
|
enabled: true,
|
||||||
|
mode: "http",
|
||||||
|
botToken: "xoxb-...",
|
||||||
|
signingSecret: "your-signing-secret",
|
||||||
|
webhookPath: "/slack/events"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-account HTTP mode: set `channels.slack.accounts.<id>.mode = "http"` and provide a unique
|
||||||
|
`webhookPath` per account so each Slack app can point to its own URL.
|
||||||
|
|
||||||
|
### Manifest (optional)
|
||||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
|
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
|
||||||
user scopes if you plan to configure a user token.
|
user scopes if you plan to configure a user token.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
summary: "CLI reference for `clawdbot approvals` (exec approvals for gateway or node hosts)"
|
||||||
|
read_when:
|
||||||
|
- You want to edit exec approvals from the CLI
|
||||||
|
- You need to manage allowlists on gateway or node hosts
|
||||||
|
---
|
||||||
|
|
||||||
|
# `clawdbot approvals`
|
||||||
|
|
||||||
|
Manage exec approvals for the **gateway host** or a **node host**.
|
||||||
|
By default, commands target the gateway. Use `--node` to edit a node’s approvals.
|
||||||
|
|
||||||
|
Related:
|
||||||
|
- Exec approvals: [Exec approvals](/tools/exec-approvals)
|
||||||
|
- Nodes: [Nodes](/nodes)
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot approvals get
|
||||||
|
clawdbot approvals get --node <id|name|ip>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Replace approvals from a file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot approvals set --file ./exec-approvals.json
|
||||||
|
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Allowlist helpers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot approvals allowlist add "~/Projects/**/bin/rg"
|
||||||
|
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"
|
||||||
|
|
||||||
|
clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix).
|
||||||
|
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
|
||||||
|
- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`.
|
||||||
@@ -9,6 +9,9 @@ read_when:
|
|||||||
|
|
||||||
Manage the Gateway daemon (background service).
|
Manage the Gateway daemon (background service).
|
||||||
|
|
||||||
|
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains
|
||||||
|
as a legacy alias for compatibility.
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Gateway CLI: [Gateway](/cli/gateway)
|
- Gateway CLI: [Gateway](/cli/gateway)
|
||||||
- macOS platform notes: [macOS](/platforms/macos)
|
- macOS platform notes: [macOS](/platforms/macos)
|
||||||
|
|||||||
+19
-4
@@ -7,7 +7,7 @@ read_when:
|
|||||||
|
|
||||||
# `clawdbot hooks`
|
# `clawdbot hooks`
|
||||||
|
|
||||||
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
|
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, and gateway startup).
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Hooks: [Hooks](/hooks)
|
- Hooks: [Hooks](/hooks)
|
||||||
@@ -29,9 +29,10 @@ List all discovered hooks from workspace, managed, and bundled directories.
|
|||||||
**Example output:**
|
**Example output:**
|
||||||
|
|
||||||
```
|
```
|
||||||
Hooks (3/3 ready)
|
Hooks (4/4 ready)
|
||||||
|
|
||||||
Ready:
|
Ready:
|
||||||
|
🚀 boot-md ✓ - Run BOOT.md on gateway startup
|
||||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
📝 command-logger ✓ - Log all command events to a centralized audit file
|
||||||
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
||||||
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
|
😈 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
|
Hooks Status
|
||||||
|
|
||||||
Total hooks: 2
|
Total hooks: 4
|
||||||
Ready: 2
|
Ready: 4
|
||||||
Not ready: 0
|
Not ready: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -273,3 +274,17 @@ clawdbot hooks enable soul-evil
|
|||||||
```
|
```
|
||||||
|
|
||||||
**See:** [SOUL Evil Hook](/hooks/soul-evil)
|
**See:** [SOUL Evil Hook](/hooks/soul-evil)
|
||||||
|
|
||||||
|
### boot-md
|
||||||
|
|
||||||
|
Runs `BOOT.md` when the gateway starts (after channels start).
|
||||||
|
|
||||||
|
**Events**: `gateway:startup`
|
||||||
|
|
||||||
|
**Enable**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot hooks enable boot-md
|
||||||
|
```
|
||||||
|
|
||||||
|
**See:** [boot-md documentation](/hooks#boot-md)
|
||||||
+33
-6
@@ -29,11 +29,13 @@ This page describes the current CLI behavior. If commands change, update this do
|
|||||||
- [`sessions`](/cli/sessions)
|
- [`sessions`](/cli/sessions)
|
||||||
- [`gateway`](/cli/gateway)
|
- [`gateway`](/cli/gateway)
|
||||||
- [`daemon`](/cli/daemon)
|
- [`daemon`](/cli/daemon)
|
||||||
|
- [`service`](/cli/service)
|
||||||
- [`logs`](/cli/logs)
|
- [`logs`](/cli/logs)
|
||||||
- [`models`](/cli/models)
|
- [`models`](/cli/models)
|
||||||
- [`memory`](/cli/memory)
|
- [`memory`](/cli/memory)
|
||||||
- [`nodes`](/cli/nodes)
|
- [`nodes`](/cli/nodes)
|
||||||
- [`node`](/cli/node)
|
- [`node`](/cli/node)
|
||||||
|
- [`approvals`](/cli/approvals)
|
||||||
- [`sandbox`](/cli/sandbox)
|
- [`sandbox`](/cli/sandbox)
|
||||||
- [`tui`](/cli/tui)
|
- [`tui`](/cli/tui)
|
||||||
- [`browser`](/cli/browser)
|
- [`browser`](/cli/browser)
|
||||||
@@ -143,6 +145,21 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
start
|
start
|
||||||
stop
|
stop
|
||||||
restart
|
restart
|
||||||
|
service
|
||||||
|
gateway
|
||||||
|
status
|
||||||
|
install
|
||||||
|
uninstall
|
||||||
|
start
|
||||||
|
stop
|
||||||
|
restart
|
||||||
|
node
|
||||||
|
status
|
||||||
|
install
|
||||||
|
uninstall
|
||||||
|
start
|
||||||
|
stop
|
||||||
|
restart
|
||||||
logs
|
logs
|
||||||
models
|
models
|
||||||
list
|
list
|
||||||
@@ -180,6 +197,10 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
start
|
start
|
||||||
stop
|
stop
|
||||||
restart
|
restart
|
||||||
|
approvals
|
||||||
|
get
|
||||||
|
set
|
||||||
|
allowlist add|remove
|
||||||
browser
|
browser
|
||||||
status
|
status
|
||||||
start
|
start
|
||||||
@@ -520,6 +541,9 @@ Options:
|
|||||||
- `--verbose`
|
- `--verbose`
|
||||||
- `--debug` (alias for `--verbose`)
|
- `--debug` (alias for `--verbose`)
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Overview includes Gateway + Node service status when available.
|
||||||
|
|
||||||
### Usage tracking
|
### Usage tracking
|
||||||
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
||||||
|
|
||||||
@@ -781,12 +805,15 @@ All `cron` commands accept `--url`, `--token`, `--timeout`, `--expect-final`.
|
|||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
- `node start --host <gateway-host> --port 18790`
|
- `node start --host <gateway-host> --port 18790`
|
||||||
- `node daemon status`
|
- `node service status`
|
||||||
- `node daemon install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
- `node service install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
||||||
- `node daemon uninstall`
|
- `node service uninstall`
|
||||||
- `node daemon start`
|
- `node service start`
|
||||||
- `node daemon stop`
|
- `node service stop`
|
||||||
- `node daemon restart`
|
- `node service restart`
|
||||||
|
|
||||||
|
Legacy alias:
|
||||||
|
- `node daemon …` (same as `node service …`)
|
||||||
|
|
||||||
## Nodes
|
## Nodes
|
||||||
|
|
||||||
|
|||||||
+17
-6
@@ -37,12 +37,14 @@ Options:
|
|||||||
- `--node-id <id>`: Override node id (clears pairing token)
|
- `--node-id <id>`: Override node id (clears pairing token)
|
||||||
- `--display-name <name>`: Override the node display name
|
- `--display-name <name>`: Override the node display name
|
||||||
|
|
||||||
## Daemon (background service)
|
## Service (background)
|
||||||
|
|
||||||
Install a headless node host as a user service.
|
Install a headless node host as a user service.
|
||||||
|
|
||||||
```bash
|
```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:
|
Options:
|
||||||
@@ -57,12 +59,20 @@ Options:
|
|||||||
|
|
||||||
Manage the service:
|
Manage the service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot node status
|
||||||
|
clawdbot service node status
|
||||||
|
clawdbot node service status
|
||||||
|
clawdbot node service start
|
||||||
|
clawdbot node service stop
|
||||||
|
clawdbot node service restart
|
||||||
|
clawdbot node service uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy alias:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot node daemon status
|
clawdbot node daemon status
|
||||||
clawdbot node daemon start
|
|
||||||
clawdbot node daemon stop
|
|
||||||
clawdbot node daemon restart
|
|
||||||
clawdbot node daemon uninstall
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pairing
|
## Pairing
|
||||||
@@ -83,3 +93,4 @@ The node host stores its node id + token in `~/.clawdbot/node.json`.
|
|||||||
|
|
||||||
- `~/.clawdbot/exec-approvals.json`
|
- `~/.clawdbot/exec-approvals.json`
|
||||||
- [Exec approvals](/tools/exec-approvals)
|
- [Exec approvals](/tools/exec-approvals)
|
||||||
|
- `clawdbot approvals --node <id|name|ip>` (edit from the Gateway)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
summary: "CLI reference for `clawdbot service` (manage gateway + node services)"
|
||||||
|
read_when:
|
||||||
|
- You want to manage Gateway or node services cross-platform
|
||||||
|
- You want a single surface for start/stop/install/uninstall
|
||||||
|
---
|
||||||
|
|
||||||
|
# `clawdbot service`
|
||||||
|
|
||||||
|
Manage the **Gateway** service and **node host** services.
|
||||||
|
|
||||||
|
Related:
|
||||||
|
- Gateway daemon (legacy alias): [Daemon](/cli/daemon)
|
||||||
|
- Node host: [Node](/cli/node)
|
||||||
|
|
||||||
|
## Gateway service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot service gateway status
|
||||||
|
clawdbot service gateway install --port 18789
|
||||||
|
clawdbot service gateway start
|
||||||
|
clawdbot service gateway stop
|
||||||
|
clawdbot service gateway restart
|
||||||
|
clawdbot service gateway uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `service gateway status` supports `--json` and `--deep` for system checks.
|
||||||
|
- `service gateway install` supports `--runtime node|bun` and `--token`.
|
||||||
|
|
||||||
|
## Node host service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot service node status
|
||||||
|
clawdbot service node install --host <gateway-host> --port 18790
|
||||||
|
clawdbot service node start
|
||||||
|
clawdbot service node stop
|
||||||
|
clawdbot service node restart
|
||||||
|
clawdbot service node uninstall
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `service node install` supports `--runtime node|bun`, `--node-id`, `--display-name`,
|
||||||
|
and TLS options (`--tls`, `--tls-fingerprint`).
|
||||||
|
|
||||||
|
## Aliases
|
||||||
|
|
||||||
|
- `clawdbot daemon …` → `clawdbot service gateway …`
|
||||||
|
- `clawdbot node service …` → `clawdbot service node …`
|
||||||
|
- `clawdbot node status` → `clawdbot service node status`
|
||||||
|
- `clawdbot node daemon …` → `clawdbot service node …` (legacy)
|
||||||
@@ -19,4 +19,5 @@ clawdbot status --usage
|
|||||||
Notes:
|
Notes:
|
||||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||||
- Output includes per-agent session stores when multiple agents are configured.
|
- Output includes per-agent session stores when multiple agents are configured.
|
||||||
|
- Overview includes Gateway + Node service install/runtime status when available.
|
||||||
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
||||||
@@ -5,13 +5,19 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Agent Loop (Clawdbot)
|
# Agent Loop (Clawdbot)
|
||||||
|
|
||||||
Short, exact flow of one agent run.
|
An agentic loop is the full “real” run of an agent: intake → context assembly → model inference →
|
||||||
|
tool execution → streaming replies → persistence. It’s the authoritative path that turns a message
|
||||||
|
into actions and a final reply, while keeping session state consistent.
|
||||||
|
|
||||||
|
In Clawdbot, a loop is a single, serialized run per session that emits lifecycle and stream events
|
||||||
|
as the model thinks, calls tools, and streams output. This doc explains how that authentic loop is
|
||||||
|
wired end-to-end.
|
||||||
|
|
||||||
## Entry points
|
## Entry points
|
||||||
- Gateway RPC: `agent` and `agent.wait`.
|
- Gateway RPC: `agent` and `agent.wait`.
|
||||||
- CLI: `agent` command.
|
- CLI: `agent` command.
|
||||||
|
|
||||||
## High-level flow
|
## How it works (high-level)
|
||||||
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
|
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
|
||||||
2) `agentCommand` runs the agent:
|
2) `agentCommand` runs the agent:
|
||||||
- resolves model + thinking/verbose defaults
|
- resolves model + thinking/verbose defaults
|
||||||
@@ -19,8 +25,9 @@ Short, exact flow of one agent run.
|
|||||||
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
|
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
|
||||||
- emits **lifecycle end/error** if the embedded loop does not emit one
|
- emits **lifecycle end/error** if the embedded loop does not emit one
|
||||||
3) `runEmbeddedPiAgent`:
|
3) `runEmbeddedPiAgent`:
|
||||||
- builds `AgentSession` and subscribes to pi events
|
- serializes runs via per-session + global queues
|
||||||
- streams assistant deltas + tool events
|
- resolves model + auth profile and builds the pi session
|
||||||
|
- subscribes to pi events and streams assistant/tool deltas
|
||||||
- enforces timeout -> aborts run if exceeded
|
- enforces timeout -> aborts run if exceeded
|
||||||
- returns payloads + usage metadata
|
- returns payloads + usage metadata
|
||||||
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream:
|
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream:
|
||||||
@@ -31,6 +38,73 @@ Short, exact flow of one agent run.
|
|||||||
- waits for **lifecycle end/error** for `runId`
|
- waits for **lifecycle end/error** for `runId`
|
||||||
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
|
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
|
||||||
|
|
||||||
|
## Queueing + concurrency
|
||||||
|
- Runs are serialized per session key (session lane) and optionally through a global lane.
|
||||||
|
- This prevents tool/session races and keeps session history consistent.
|
||||||
|
- Messaging channels can choose queue modes (collect/steer/followup) that feed this lane system.
|
||||||
|
See [Command Queue](/concepts/queue).
|
||||||
|
|
||||||
|
## Session + workspace preparation
|
||||||
|
- Workspace is resolved and created; sandboxed runs may redirect to a sandbox workspace root.
|
||||||
|
- Skills are loaded (or reused from a snapshot) and injected into env and prompt.
|
||||||
|
- Bootstrap/context files are resolved and injected into the system prompt report.
|
||||||
|
- A session write lock is acquired; `SessionManager` is opened and prepared before streaming.
|
||||||
|
|
||||||
|
## Prompt assembly + system prompt
|
||||||
|
- System prompt is built from Clawdbot’s base prompt, skills prompt, bootstrap context, and per-run overrides.
|
||||||
|
- Model-specific limits and compaction reserve tokens are enforced.
|
||||||
|
- See [System prompt](/concepts/system-prompt) for what the model sees.
|
||||||
|
|
||||||
|
## Hook points (where you can intercept)
|
||||||
|
Clawdbot has two hook systems:
|
||||||
|
- **Internal hooks** (Gateway hooks): event-driven scripts for commands and lifecycle events.
|
||||||
|
- **Plugin hooks**: extension points inside the agent/tool lifecycle and gateway pipeline.
|
||||||
|
|
||||||
|
### Internal hooks (Gateway hooks)
|
||||||
|
- **`agent:bootstrap`**: runs while building bootstrap files before the system prompt is finalized.
|
||||||
|
Use this to add/remove bootstrap context files.
|
||||||
|
- **Command hooks**: `/new`, `/reset`, `/stop`, and other command events (see Hooks doc).
|
||||||
|
|
||||||
|
See [Hooks](/hooks) for setup and examples.
|
||||||
|
|
||||||
|
### Plugin hooks (agent + gateway lifecycle)
|
||||||
|
These run inside the agent loop or gateway pipeline:
|
||||||
|
- **`before_agent_start`**: inject context or override system prompt before the run starts.
|
||||||
|
- **`agent_end`**: inspect the final message list and run metadata after completion.
|
||||||
|
- **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles.
|
||||||
|
- **`before_tool_call` / `after_tool_call`**: intercept tool params/results.
|
||||||
|
- **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks.
|
||||||
|
- **`session_start` / `session_end`**: session lifecycle boundaries.
|
||||||
|
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
|
||||||
|
|
||||||
|
See [Plugins](/plugin#plugin-hooks) for the hook API and registration details.
|
||||||
|
|
||||||
|
## Streaming + partial replies
|
||||||
|
- Assistant deltas are streamed from pi-agent-core and emitted as `assistant` events.
|
||||||
|
- Block streaming can emit partial replies either on `text_end` or `message_end`.
|
||||||
|
- Reasoning streaming can be emitted as a separate stream or as block replies.
|
||||||
|
- See [Streaming](/concepts/streaming) for chunking and block reply behavior.
|
||||||
|
|
||||||
|
## Tool execution + messaging tools
|
||||||
|
- Tool start/update/end events are emitted on the `tool` stream.
|
||||||
|
- Tool results are sanitized for size and image payloads before logging/emitting.
|
||||||
|
- Messaging tool sends are tracked to suppress duplicate assistant confirmations.
|
||||||
|
|
||||||
|
## Reply shaping + suppression
|
||||||
|
- Final payloads are assembled from:
|
||||||
|
- assistant text (and optional reasoning)
|
||||||
|
- inline tool summaries (when verbose + allowed)
|
||||||
|
- assistant error text when the model errors
|
||||||
|
- `NO_REPLY` is treated as a silent token and filtered from outgoing payloads.
|
||||||
|
- Messaging tool duplicates are removed from the final payload list.
|
||||||
|
- If no renderable payloads remain and a tool errored, a fallback tool error reply is emitted
|
||||||
|
(unless a messaging tool already sent a user-visible reply).
|
||||||
|
|
||||||
|
## Compaction + retries
|
||||||
|
- Auto-compaction emits `compaction` stream events and can trigger a retry.
|
||||||
|
- On retry, in-memory buffers and tool summaries are reset to avoid duplicate output.
|
||||||
|
- See [Compaction](/concepts/compaction) for the compaction pipeline.
|
||||||
|
|
||||||
## Event streams (today)
|
## Event streams (today)
|
||||||
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
|
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
|
||||||
- `assistant`: streamed deltas from pi-agent-core
|
- `assistant`: streamed deltas from pi-agent-core
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ These are the standard files Clawdbot expects inside the workspace:
|
|||||||
- Optional tiny checklist for heartbeat runs.
|
- Optional tiny checklist for heartbeat runs.
|
||||||
- Keep it short to avoid token burn.
|
- Keep it short to avoid token burn.
|
||||||
|
|
||||||
|
- `BOOT.md`
|
||||||
|
- Optional startup checklist executed on gateway restart when internal hooks are enabled.
|
||||||
|
- Keep it short; use the message tool for outbound sends.
|
||||||
|
|
||||||
- `BOOTSTRAP.md`
|
- `BOOTSTRAP.md`
|
||||||
- One-time first-run ritual.
|
- One-time first-run ritual.
|
||||||
- Only created for a brand-new workspace.
|
- Only created for a brand-new workspace.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ The prompt is intentionally compact and uses fixed sections:
|
|||||||
- **Skills** (when available): tells the model how to load skill instructions on demand.
|
- **Skills** (when available): tells the model how to load skill instructions on demand.
|
||||||
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
|
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
|
||||||
- **Workspace**: working directory (`agents.defaults.workspace`).
|
- **Workspace**: working directory (`agents.defaults.workspace`).
|
||||||
|
- **Documentation**: local path to Clawdbot docs (repo or npm package) and when to read them.
|
||||||
- **Workspace Files (injected)**: indicates bootstrap files are included below.
|
- **Workspace Files (injected)**: indicates bootstrap files are included below.
|
||||||
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
|
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
|
||||||
- **Current Date & Time**: user-local time, timezone, and time format.
|
- **Current Date & Time**: user-local time, timezone, and time format.
|
||||||
@@ -98,3 +99,12 @@ Skills section is omitted.
|
|||||||
```
|
```
|
||||||
|
|
||||||
This keeps the base prompt small while still enabling targeted skill usage.
|
This keeps the base prompt small while still enabling targeted skill usage.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
When available, the system prompt includes a **Documentation** section that points to the
|
||||||
|
local Clawdbot docs directory (either `docs/` in the repo workspace or the bundled npm
|
||||||
|
package docs) and also notes the public mirror, source repo, community Discord, and
|
||||||
|
ClawdHub (https://clawdhub.com) for skills discovery. The prompt instructs the model to consult local docs first
|
||||||
|
for Clawdbot behavior, commands, configuration, or architecture, and to run
|
||||||
|
`clawdbot status` itself when possible (asking the user only when it lacks access).
|
||||||
@@ -657,6 +657,10 @@
|
|||||||
"source": "/templates/AGENTS",
|
"source": "/templates/AGENTS",
|
||||||
"destination": "/reference/templates/AGENTS"
|
"destination": "/reference/templates/AGENTS"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"source": "/templates/BOOT",
|
||||||
|
"destination": "/reference/templates/BOOT"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"source": "/templates/BOOTSTRAP",
|
"source": "/templates/BOOTSTRAP",
|
||||||
"destination": "/reference/templates/BOOTSTRAP"
|
"destination": "/reference/templates/BOOTSTRAP"
|
||||||
@@ -822,8 +826,10 @@
|
|||||||
"cli/models",
|
"cli/models",
|
||||||
"cli/logs",
|
"cli/logs",
|
||||||
"cli/nodes",
|
"cli/nodes",
|
||||||
|
"cli/approvals",
|
||||||
"cli/gateway",
|
"cli/gateway",
|
||||||
"cli/daemon",
|
"cli/daemon",
|
||||||
|
"cli/service",
|
||||||
"cli/tui",
|
"cli/tui",
|
||||||
"cli/voicecall",
|
"cli/voicecall",
|
||||||
"cli/wake",
|
"cli/wake",
|
||||||
@@ -1051,6 +1057,7 @@
|
|||||||
"reference/RELEASING",
|
"reference/RELEASING",
|
||||||
"reference/AGENTS.default",
|
"reference/AGENTS.default",
|
||||||
"reference/templates/AGENTS",
|
"reference/templates/AGENTS",
|
||||||
|
"reference/templates/BOOT",
|
||||||
"reference/templates/BOOTSTRAP",
|
"reference/templates/BOOTSTRAP",
|
||||||
"reference/templates/HEARTBEAT",
|
"reference/templates/HEARTBEAT",
|
||||||
"reference/templates/IDENTITY",
|
"reference/templates/IDENTITY",
|
||||||
|
|||||||
+30
-2
@@ -37,10 +37,11 @@ The hooks system allows you to:
|
|||||||
|
|
||||||
### Bundled Hooks
|
### 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`
|
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
|
||||||
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
|
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
|
||||||
|
- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled)
|
||||||
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
|
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
|
||||||
|
|
||||||
List available hooks:
|
List available hooks:
|
||||||
@@ -195,7 +196,7 @@ Each event includes:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
type: 'command' | 'session' | 'agent',
|
type: 'command' | 'session' | 'agent' | 'gateway',
|
||||||
action: string, // e.g., 'new', 'reset', 'stop'
|
action: string, // e.g., 'new', 'reset', 'stop'
|
||||||
sessionKey: string, // Session identifier
|
sessionKey: string, // Session identifier
|
||||||
timestamp: Date, // When the event occurred
|
timestamp: Date, // When the event occurred
|
||||||
@@ -228,6 +229,12 @@ Triggered when agent commands are issued:
|
|||||||
|
|
||||||
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
|
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
|
||||||
|
|
||||||
|
### Gateway Events
|
||||||
|
|
||||||
|
Triggered when the gateway starts:
|
||||||
|
|
||||||
|
- **`gateway:startup`**: After channels start and hooks are loaded
|
||||||
|
|
||||||
### Future Events
|
### Future Events
|
||||||
|
|
||||||
Planned event types:
|
Planned event types:
|
||||||
@@ -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
|
## Best Practices
|
||||||
|
|
||||||
### Keep Handlers Fast
|
### Keep Handlers Fast
|
||||||
@@ -614,6 +641,7 @@ The gateway logs hook loading at startup:
|
|||||||
```
|
```
|
||||||
Registered hook: session-memory -> command:new
|
Registered hook: session-memory -> command:new
|
||||||
Registered hook: command-logger -> command
|
Registered hook: command-logger -> command
|
||||||
|
Registered hook: boot-md -> gateway:startup
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Discovery
|
### Check Discovery
|
||||||
|
|||||||
+2
-2
@@ -149,8 +149,8 @@ Notes:
|
|||||||
|
|
||||||
## System commands (node host / mac node)
|
## System commands (node host / mac node)
|
||||||
|
|
||||||
The macOS node exposes `system.run` and `system.notify`. The headless node host
|
The macOS node exposes `system.run`, `system.notify`, and `system.execApprovals.get/set`.
|
||||||
exposes `system.run` and `system.which`.
|
The headless node host exposes `system.run`, `system.which`, and `system.execApprovals.get/set`.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,29 @@ Rules:
|
|||||||
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
||||||
**active reply model** when its provider supports the capability.
|
**active reply model** when its provider supports the capability.
|
||||||
|
|
||||||
|
### Auto-enable audio (when keys exist)
|
||||||
|
If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
|
||||||
|
audio provider keys configured, Clawdbot will **auto-enable audio transcription**
|
||||||
|
even when you haven’t listed models explicitly.
|
||||||
|
|
||||||
|
Providers checked (in order):
|
||||||
|
1) OpenAI
|
||||||
|
2) Groq
|
||||||
|
3) Deepgram
|
||||||
|
|
||||||
|
To disable this behavior, set:
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
tools: {
|
||||||
|
media: {
|
||||||
|
audio: {
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Capabilities (optional)
|
## Capabilities (optional)
|
||||||
If you set `capabilities`, the entry only runs for those media types. For shared
|
If you set `capabilities`, the entry only runs for those media types. For shared
|
||||||
lists, Clawdbot can infer defaults:
|
lists, Clawdbot can infer defaults:
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
summary: "Audit what can spend money, which keys are used, and how to view usage"
|
||||||
|
read_when:
|
||||||
|
- You want to understand which features may call paid APIs
|
||||||
|
- You need to audit keys, costs, and usage visibility
|
||||||
|
- You’re explaining /status or /usage cost reporting
|
||||||
|
---
|
||||||
|
# API usage & costs
|
||||||
|
|
||||||
|
This doc lists **features that can invoke API keys** and where their costs show up. It focuses on
|
||||||
|
Clawdbot features that can generate provider usage or paid API calls.
|
||||||
|
|
||||||
|
## Where costs show up (chat + CLI)
|
||||||
|
|
||||||
|
**Per-session cost snapshot**
|
||||||
|
- `/status` shows the current session model, context usage, and last response tokens.
|
||||||
|
- If the model uses **API-key auth**, `/status` also shows **estimated cost** for the last reply.
|
||||||
|
|
||||||
|
**Per-message cost footer**
|
||||||
|
- `/usage full` appends a usage footer to every reply, including **estimated cost** (API-key only).
|
||||||
|
- `/usage tokens` shows tokens only; OAuth flows hide dollar cost.
|
||||||
|
|
||||||
|
**CLI usage windows (provider quotas)**
|
||||||
|
- `clawdbot status --usage` and `clawdbot channels list` show provider **usage windows**
|
||||||
|
(quota snapshots, not per-message costs).
|
||||||
|
|
||||||
|
See [Token use & costs](/token-use) for details and examples.
|
||||||
|
|
||||||
|
## How keys are discovered
|
||||||
|
|
||||||
|
Clawdbot can pick up credentials from:
|
||||||
|
- **Auth profiles** (per-agent, stored in `auth-profiles.json`).
|
||||||
|
- **Environment variables** (e.g. `OPENAI_API_KEY`, `BRAVE_API_KEY`, `FIRECRAWL_API_KEY`).
|
||||||
|
- **Config** (`models.providers.*.apiKey`, `tools.web.search.*`, `tools.web.fetch.firecrawl.*`,
|
||||||
|
`memorySearch.*`, `talk.apiKey`).
|
||||||
|
- **Skills** (`skills.entries.<name>.apiKey`) which may export keys to the skill process env.
|
||||||
|
|
||||||
|
## Features that can spend keys
|
||||||
|
|
||||||
|
### 1) Core model responses (chat + tools)
|
||||||
|
Every reply or tool call uses the **current model provider** (OpenAI, Anthropic, etc). This is the
|
||||||
|
primary source of usage and cost.
|
||||||
|
|
||||||
|
See [Models](/providers/models) for pricing config and [Token use & costs](/token-use) for display.
|
||||||
|
|
||||||
|
### 2) Media understanding (audio/image/video)
|
||||||
|
Inbound media can be summarized/transcribed before the reply runs. This uses model/provider APIs.
|
||||||
|
|
||||||
|
- Audio: OpenAI / Groq / Deepgram (now **auto-enabled** when keys exist).
|
||||||
|
- Image: OpenAI / Anthropic / Google.
|
||||||
|
- Video: Google.
|
||||||
|
|
||||||
|
See [Media understanding](/nodes/media-understanding).
|
||||||
|
|
||||||
|
### 3) Memory embeddings + semantic search
|
||||||
|
Semantic memory search uses **embedding APIs** when configured for remote providers:
|
||||||
|
- `memorySearch.provider = "openai"` → OpenAI embeddings
|
||||||
|
- `memorySearch.provider = "gemini"` → Gemini embeddings
|
||||||
|
- Optional fallback to OpenAI if local embeddings fail
|
||||||
|
|
||||||
|
You can keep it local with `memorySearch.provider = "local"` (no API usage).
|
||||||
|
|
||||||
|
See [Memory](/concepts/memory).
|
||||||
|
|
||||||
|
### 4) Web search tool (Brave / Perplexity via OpenRouter)
|
||||||
|
`web_search` uses API keys and may incur usage charges:
|
||||||
|
|
||||||
|
- **Brave Search API**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
|
||||||
|
- **Perplexity** (via OpenRouter): `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||||
|
|
||||||
|
**Brave free tier (generous):**
|
||||||
|
- **2,000 requests/month**
|
||||||
|
- **1 request/second**
|
||||||
|
- **Credit card required** for verification (no charge unless you upgrade)
|
||||||
|
|
||||||
|
See [Web tools](/tools/web).
|
||||||
|
|
||||||
|
### 5) Web fetch tool (Firecrawl)
|
||||||
|
`web_fetch` can call **Firecrawl** when an API key is present:
|
||||||
|
- `FIRECRAWL_API_KEY` or `tools.web.fetch.firecrawl.apiKey`
|
||||||
|
|
||||||
|
If Firecrawl isn’t configured, the tool falls back to direct fetch + readability (no paid API).
|
||||||
|
|
||||||
|
See [Web tools](/tools/web).
|
||||||
|
|
||||||
|
### 6) Provider usage snapshots (status/health)
|
||||||
|
Some status commands call **provider usage endpoints** to display quota windows or auth health.
|
||||||
|
These are typically low-volume calls but still hit provider APIs:
|
||||||
|
- `clawdbot status --usage`
|
||||||
|
- `clawdbot models status --json`
|
||||||
|
|
||||||
|
See [Models CLI](/cli/models).
|
||||||
|
|
||||||
|
### 7) Compaction safeguard summarization
|
||||||
|
The compaction safeguard can summarize session history using the **current model**, which
|
||||||
|
invokes provider APIs when it runs.
|
||||||
|
|
||||||
|
See [Session management + compaction](/reference/session-management-compaction).
|
||||||
|
|
||||||
|
### 8) Model scan / probe
|
||||||
|
`clawdbot models scan` can probe OpenRouter models and uses `OPENROUTER_API_KEY` when
|
||||||
|
probing is enabled.
|
||||||
|
|
||||||
|
See [Models CLI](/cli/models).
|
||||||
|
|
||||||
|
### 9) Talk (speech)
|
||||||
|
Talk mode can invoke **ElevenLabs** when configured:
|
||||||
|
- `ELEVENLABS_API_KEY` or `talk.apiKey`
|
||||||
|
|
||||||
|
See [Talk mode](/nodes/talk).
|
||||||
|
|
||||||
|
### 10) Skills (third-party APIs)
|
||||||
|
Skills can store `apiKey` in `skills.entries.<name>.apiKey`. If a skill uses that key for external
|
||||||
|
APIs, it can incur costs according to the skill’s provider.
|
||||||
|
|
||||||
|
See [Skills](/tools/skills).
|
||||||
@@ -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.
|
||||||
@@ -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
|
add/remove allowlist patterns, then **Save**. The UI shows **last used** metadata
|
||||||
per pattern so you can keep the list tidy.
|
per pattern so you can keep the list tidy.
|
||||||
|
|
||||||
Note: the Control UI edits the approvals file on the **Gateway host**. For a
|
The target selector chooses **Gateway** (local approvals) or a **Node**. Nodes
|
||||||
headless node host, edit its local `~/.clawdbot/exec-approvals.json` directly.
|
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
|
## Approval flow
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
|
|||||||
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
|
||||||
- Skills: status, enable/disable, install, API key updates (`skills.*`)
|
- Skills: status, enable/disable, install, API key updates (`skills.*`)
|
||||||
- Nodes: list + caps (`node.list`)
|
- Nodes: list + caps (`node.list`)
|
||||||
- Exec approvals: edit 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: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
|
||||||
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
- Config: apply + restart with validation (`config.apply`) and wake the last active session
|
||||||
- Config writes include a base-hash guard to prevent clobbering concurrent edits
|
- Config writes include a base-hash guard to prevent clobbering concurrent edits
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os from "node:os";
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||||
@@ -8,7 +9,11 @@ import { setMatrixRuntime } from "./runtime.js";
|
|||||||
|
|
||||||
describe("matrix directory", () => {
|
describe("matrix directory", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setMatrixRuntime({} as PluginRuntime);
|
setMatrixRuntime({
|
||||||
|
state: {
|
||||||
|
resolveStateDir: () => os.tmpdir(),
|
||||||
|
},
|
||||||
|
} as PluginRuntime);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists peers and groups from config", async () => {
|
it("lists peers and groups from config", async () => {
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture",
|
"description": "Clawdbot long-term memory plugin with vector search and seamless auto-recall/capture",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sinclair/typebox": "0.34.47",
|
|
||||||
"@lancedb/lancedb": "^0.15.0",
|
"@lancedb/lancedb": "^0.15.0",
|
||||||
"openai": "^4.77.0"
|
"@sinclair/typebox": "0.34.47",
|
||||||
|
"openai": "^4.104.0"
|
||||||
},
|
},
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,10 +9,10 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@microsoft/agents-hosting": "^1.2.2",
|
||||||
|
"@microsoft/agents-hosting-express": "^1.2.2",
|
||||||
|
"@microsoft/agents-hosting-extensions-teams": "^1.2.2",
|
||||||
"clawdbot": "workspace:*",
|
"clawdbot": "workspace:*",
|
||||||
"@microsoft/agents-hosting": "^1.1.1",
|
|
||||||
"@microsoft/agents-hosting-express": "^1.1.1",
|
|
||||||
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
|
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"proper-lockfile": "^4.1.2"
|
"proper-lockfile": "^4.1.2"
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -168,7 +168,7 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"file-type": "^21.3.0",
|
"file-type": "^21.3.0",
|
||||||
"grammy": "^1.39.2",
|
"grammy": "^1.39.3",
|
||||||
"hono": "4.11.4",
|
"hono": "4.11.4",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
"qrcode-terminal": "^0.12.0",
|
"qrcode-terminal": "^0.12.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"sqlite-vec": "0.1.7-alpha.2",
|
"sqlite-vec": "0.1.7-alpha.2",
|
||||||
"tar": "^7.5.3",
|
"tar": "7.5.3",
|
||||||
"tslog": "^4.10.2",
|
"tslog": "^4.10.2",
|
||||||
"undici": "^7.18.2",
|
"undici": "^7.18.2",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
@@ -200,24 +200,24 @@
|
|||||||
"@types/body-parser": "^1.19.6",
|
"@types/body-parser": "^1.19.6",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/node": "^25.0.6",
|
"@types/node": "^25.0.9",
|
||||||
"@types/proper-lockfile": "^4.1.4",
|
"@types/proper-lockfile": "^4.1.4",
|
||||||
"@types/qrcode-terminal": "^0.12.2",
|
"@types/qrcode-terminal": "^0.12.2",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@vitest/coverage-v8": "^4.0.16",
|
"@vitest/coverage-v8": "^4.0.17",
|
||||||
"docx-preview": "^0.3.7",
|
"docx-preview": "^0.3.7",
|
||||||
"lit": "^3.3.2",
|
"lit": "^3.3.2",
|
||||||
"lucide": "^0.562.0",
|
"lucide": "^0.562.0",
|
||||||
"ollama": "^0.6.3",
|
"ollama": "^0.6.3",
|
||||||
"oxfmt": "0.24.0",
|
"oxfmt": "0.24.0",
|
||||||
"oxlint": "^1.39.0",
|
"oxlint": "^1.39.0",
|
||||||
"oxlint-tsgolint": "^0.11.0",
|
"oxlint-tsgolint": "^0.11.1",
|
||||||
"quicktype-core": "^23.2.6",
|
"quicktype-core": "^23.2.6",
|
||||||
"rolldown": "1.0.0-beta.59",
|
"rolldown": "1.0.0-beta.59",
|
||||||
"signal-utils": "^0.21.1",
|
"signal-utils": "^0.21.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "^4.0.17",
|
||||||
"wireit": "^0.14.12"
|
"wireit": "^0.14.12"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
|||||||
Generated
+699
-736
File diff suppressed because it is too large.
Load diff
@@ -50,15 +50,15 @@ import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
|
|||||||
|
|
||||||
const DEFAULT_MAX_OUTPUT = clampNumber(
|
const DEFAULT_MAX_OUTPUT = clampNumber(
|
||||||
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
|
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
|
||||||
30_000,
|
200_000,
|
||||||
1_000,
|
1_000,
|
||||||
150_000,
|
200_000,
|
||||||
);
|
);
|
||||||
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(
|
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(
|
||||||
readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"),
|
readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"),
|
||||||
30_000,
|
200_000,
|
||||||
1_000,
|
1_000,
|
||||||
150_000,
|
200_000,
|
||||||
);
|
);
|
||||||
const DEFAULT_PATH =
|
const DEFAULT_PATH =
|
||||||
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { shouldLogVerbose } from "../globals.js";
|
|||||||
import { createSubsystemLogger } from "../logging.js";
|
import { createSubsystemLogger } from "../logging.js";
|
||||||
import { runCommandWithTimeout } from "../process/exec.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
|
import { resolveClawdbotDocsPath } from "./docs-path.js";
|
||||||
import { resolveSessionAgentIds } from "./agent-scope.js";
|
import { resolveSessionAgentIds } from "./agent-scope.js";
|
||||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
|
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js";
|
||||||
import { resolveCliBackendConfig } from "./cli-backends.js";
|
import { resolveCliBackendConfig } from "./cli-backends.js";
|
||||||
@@ -83,6 +84,12 @@ export async function runCliAgent(params: {
|
|||||||
sessionAgentId === defaultAgentId
|
sessionAgentId === defaultAgentId
|
||||||
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const docsPath = await resolveClawdbotDocsPath({
|
||||||
|
workspaceDir,
|
||||||
|
argv1: process.argv[1],
|
||||||
|
cwd: process.cwd(),
|
||||||
|
moduleUrl: import.meta.url,
|
||||||
|
});
|
||||||
const systemPrompt = buildSystemPrompt({
|
const systemPrompt = buildSystemPrompt({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
@@ -90,6 +97,7 @@ export async function runCliAgent(params: {
|
|||||||
extraSystemPrompt,
|
extraSystemPrompt,
|
||||||
ownerNumbers: params.ownerNumbers,
|
ownerNumbers: params.ownerNumbers,
|
||||||
heartbeatPrompt,
|
heartbeatPrompt,
|
||||||
|
docsPath: docsPath ?? undefined,
|
||||||
tools: [],
|
tools: [],
|
||||||
contextFiles,
|
contextFiles,
|
||||||
modelDisplay,
|
modelDisplay,
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export function buildSystemPrompt(params: {
|
|||||||
extraSystemPrompt?: string;
|
extraSystemPrompt?: string;
|
||||||
ownerNumbers?: string[];
|
ownerNumbers?: string[];
|
||||||
heartbeatPrompt?: string;
|
heartbeatPrompt?: string;
|
||||||
|
docsPath?: string;
|
||||||
tools: AgentTool[];
|
tools: AgentTool[];
|
||||||
contextFiles?: EmbeddedContextFile[];
|
contextFiles?: EmbeddedContextFile[];
|
||||||
modelDisplay: string;
|
modelDisplay: string;
|
||||||
@@ -182,6 +183,7 @@ export function buildSystemPrompt(params: {
|
|||||||
ownerNumbers: params.ownerNumbers,
|
ownerNumbers: params.ownerNumbers,
|
||||||
reasoningTagHint: false,
|
reasoningTagHint: false,
|
||||||
heartbeatPrompt: params.heartbeatPrompt,
|
heartbeatPrompt: params.heartbeatPrompt,
|
||||||
|
docsPath: params.docsPath,
|
||||||
runtimeInfo: {
|
runtimeInfo: {
|
||||||
host: "clawdbot",
|
host: "clawdbot",
|
||||||
os: `${os.type()} ${os.release()}`,
|
os: `${os.type()} ${os.release()}`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -26,6 +26,11 @@ describe("classifyFailoverReason", () => {
|
|||||||
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
|
expect(classifyFailoverReason("deadline exceeded")).toBe("timeout");
|
||||||
expect(classifyFailoverReason("string should match pattern")).toBe("format");
|
expect(classifyFailoverReason("string should match pattern")).toBe("format");
|
||||||
expect(classifyFailoverReason("bad request")).toBeNull();
|
expect(classifyFailoverReason("bad request")).toBeNull();
|
||||||
|
expect(
|
||||||
|
classifyFailoverReason(
|
||||||
|
"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels",
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
});
|
});
|
||||||
it("classifies OpenAI usage limit errors as rate_limit", () => {
|
it("classifies OpenAI usage limit errors as rate_limit", () => {
|
||||||
expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe(
|
expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,5 +23,10 @@ describe("isCloudCodeAssistFormatError", () => {
|
|||||||
});
|
});
|
||||||
it("ignores unrelated errors", () => {
|
it("ignores unrelated errors", () => {
|
||||||
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
|
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
|
||||||
|
expect(
|
||||||
|
isCloudCodeAssistFormatError(
|
||||||
|
"400 {\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels\"}}",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -21,11 +21,13 @@ export {
|
|||||||
isContextOverflowError,
|
isContextOverflowError,
|
||||||
isFailoverAssistantError,
|
isFailoverAssistantError,
|
||||||
isFailoverErrorMessage,
|
isFailoverErrorMessage,
|
||||||
|
isImageDimensionErrorMessage,
|
||||||
isOverloadedErrorMessage,
|
isOverloadedErrorMessage,
|
||||||
isRawApiErrorPayload,
|
isRawApiErrorPayload,
|
||||||
isRateLimitAssistantError,
|
isRateLimitAssistantError,
|
||||||
isRateLimitErrorMessage,
|
isRateLimitErrorMessage,
|
||||||
isTimeoutErrorMessage,
|
isTimeoutErrorMessage,
|
||||||
|
parseImageDimensionError,
|
||||||
} from "./pi-embedded-helpers/errors.js";
|
} from "./pi-embedded-helpers/errors.js";
|
||||||
export {
|
export {
|
||||||
downgradeGeminiHistory,
|
downgradeGeminiHistory,
|
||||||
|
|||||||
@@ -339,7 +339,6 @@ const ERROR_PATTERNS = {
|
|||||||
"no api key found",
|
"no api key found",
|
||||||
],
|
],
|
||||||
format: [
|
format: [
|
||||||
"invalid_request_error",
|
|
||||||
"string should match pattern",
|
"string should match pattern",
|
||||||
"tool_use.id",
|
"tool_use.id",
|
||||||
"tool_use_id",
|
"tool_use_id",
|
||||||
@@ -348,6 +347,10 @@ const ERROR_PATTERNS = {
|
|||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const IMAGE_DIMENSION_ERROR_RE =
|
||||||
|
/image dimensions exceed max allowed size for many-image requests:\s*(\d+)\s*pixels/i;
|
||||||
|
const IMAGE_DIMENSION_PATH_RE = /messages\.(\d+)\.content\.(\d+)\.image/i;
|
||||||
|
|
||||||
function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean {
|
function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean {
|
||||||
if (!raw) return false;
|
if (!raw) return false;
|
||||||
const value = raw.toLowerCase();
|
const value = raw.toLowerCase();
|
||||||
@@ -390,8 +393,31 @@ export function isOverloadedErrorMessage(raw: string): boolean {
|
|||||||
return matchesErrorPatterns(raw, ERROR_PATTERNS.overloaded);
|
return matchesErrorPatterns(raw, ERROR_PATTERNS.overloaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseImageDimensionError(raw: string): {
|
||||||
|
maxDimensionPx?: number;
|
||||||
|
messageIndex?: number;
|
||||||
|
contentIndex?: number;
|
||||||
|
raw: string;
|
||||||
|
} | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
if (!lower.includes("image dimensions exceed max allowed size")) return null;
|
||||||
|
const limitMatch = raw.match(IMAGE_DIMENSION_ERROR_RE);
|
||||||
|
const pathMatch = raw.match(IMAGE_DIMENSION_PATH_RE);
|
||||||
|
return {
|
||||||
|
maxDimensionPx: limitMatch?.[1] ? Number.parseInt(limitMatch[1], 10) : undefined,
|
||||||
|
messageIndex: pathMatch?.[1] ? Number.parseInt(pathMatch[1], 10) : undefined,
|
||||||
|
contentIndex: pathMatch?.[2] ? Number.parseInt(pathMatch[2], 10) : undefined,
|
||||||
|
raw,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageDimensionErrorMessage(raw: string): boolean {
|
||||||
|
return Boolean(parseImageDimensionError(raw));
|
||||||
|
}
|
||||||
|
|
||||||
export function isCloudCodeAssistFormatError(raw: string): boolean {
|
export function isCloudCodeAssistFormatError(raw: string): boolean {
|
||||||
return matchesErrorPatterns(raw, ERROR_PATTERNS.format);
|
return !isImageDimensionErrorMessage(raw) && matchesErrorPatterns(raw, ERROR_PATTERNS.format);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean {
|
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 {
|
export function classifyFailoverReason(raw: string): FailoverReason | null {
|
||||||
|
if (isImageDimensionErrorMessage(raw)) return null;
|
||||||
if (isRateLimitErrorMessage(raw)) return "rate_limit";
|
if (isRateLimitErrorMessage(raw)) return "rate_limit";
|
||||||
if (isOverloadedErrorMessage(raw)) return "rate_limit";
|
if (isOverloadedErrorMessage(raw)) return "rate_limit";
|
||||||
if (isCloudCodeAssistFormatError(raw)) return "format";
|
if (isCloudCodeAssistFormatError(raw)) return "format";
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { resolveUserPath } from "../../utils.js";
|
|||||||
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
||||||
import { resolveSessionAgentIds } from "../agent-scope.js";
|
import { resolveSessionAgentIds } from "../agent-scope.js";
|
||||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
|
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js";
|
||||||
|
import { resolveClawdbotDocsPath } from "../docs-path.js";
|
||||||
import type { ExecElevatedDefaults } from "../bash-tools.js";
|
import type { ExecElevatedDefaults } from "../bash-tools.js";
|
||||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
|
||||||
import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js";
|
import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js";
|
||||||
@@ -250,6 +251,12 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
});
|
});
|
||||||
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
||||||
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
||||||
|
const docsPath = await resolveClawdbotDocsPath({
|
||||||
|
workspaceDir: effectiveWorkspace,
|
||||||
|
argv1: process.argv[1],
|
||||||
|
cwd: process.cwd(),
|
||||||
|
moduleUrl: import.meta.url,
|
||||||
|
});
|
||||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||||
workspaceDir: effectiveWorkspace,
|
workspaceDir: effectiveWorkspace,
|
||||||
defaultThinkLevel: params.thinkLevel,
|
defaultThinkLevel: params.thinkLevel,
|
||||||
@@ -261,6 +268,7 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
||||||
: undefined,
|
: undefined,
|
||||||
skillsPrompt,
|
skillsPrompt,
|
||||||
|
docsPath: docsPath ?? undefined,
|
||||||
promptMode,
|
promptMode,
|
||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
sandboxInfo,
|
sandboxInfo,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
isContextOverflowError,
|
isContextOverflowError,
|
||||||
isFailoverAssistantError,
|
isFailoverAssistantError,
|
||||||
isFailoverErrorMessage,
|
isFailoverErrorMessage,
|
||||||
|
parseImageDimensionError,
|
||||||
isRateLimitAssistantError,
|
isRateLimitAssistantError,
|
||||||
isTimeoutErrorMessage,
|
isTimeoutErrorMessage,
|
||||||
pickFallbackThinkingLevel,
|
pickFallbackThinkingLevel,
|
||||||
@@ -357,6 +358,26 @@ export async function runEmbeddedPiAgent(
|
|||||||
const failoverFailure = isFailoverAssistantError(lastAssistant);
|
const failoverFailure = isFailoverAssistantError(lastAssistant);
|
||||||
const assistantFailoverReason = classifyFailoverReason(lastAssistant?.errorMessage ?? "");
|
const assistantFailoverReason = classifyFailoverReason(lastAssistant?.errorMessage ?? "");
|
||||||
const cloudCodeAssistFormatError = attempt.cloudCodeAssistFormatError;
|
const cloudCodeAssistFormatError = attempt.cloudCodeAssistFormatError;
|
||||||
|
const imageDimensionError = parseImageDimensionError(lastAssistant?.errorMessage ?? "");
|
||||||
|
|
||||||
|
if (imageDimensionError && lastProfileId) {
|
||||||
|
const details = [
|
||||||
|
imageDimensionError.messageIndex !== undefined
|
||||||
|
? `message=${imageDimensionError.messageIndex}`
|
||||||
|
: null,
|
||||||
|
imageDimensionError.contentIndex !== undefined
|
||||||
|
? `content=${imageDimensionError.contentIndex}`
|
||||||
|
: null,
|
||||||
|
imageDimensionError.maxDimensionPx !== undefined
|
||||||
|
? `limit=${imageDimensionError.maxDimensionPx}px`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
log.warn(
|
||||||
|
`Profile ${lastProfileId} rejected image payload${details ? ` (${details})` : ""}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Treat timeout as potential rate limit (Antigravity hangs on rate limit)
|
// Treat timeout as potential rate limit (Antigravity hangs on rate limit)
|
||||||
const shouldRotate = (!aborted && failoverFailure) || timedOut;
|
const shouldRotate = (!aborted && failoverFailure) || timedOut;
|
||||||
@@ -431,6 +452,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
assistantTexts: attempt.assistantTexts,
|
assistantTexts: attempt.assistantTexts,
|
||||||
toolMetas: attempt.toolMetas,
|
toolMetas: attempt.toolMetas,
|
||||||
lastAssistant: attempt.lastAssistant,
|
lastAssistant: attempt.lastAssistant,
|
||||||
|
lastToolError: attempt.lastToolError,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
sessionKey: params.sessionKey ?? params.sessionId,
|
sessionKey: params.sessionKey ?? params.sessionId,
|
||||||
verboseLevel: params.verboseLevel,
|
verboseLevel: params.verboseLevel,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { resolveUserPath } from "../../../utils.js";
|
|||||||
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
|
import { resolveClawdbotAgentDir } from "../../agent-paths.js";
|
||||||
import { resolveSessionAgentIds } from "../../agent-scope.js";
|
import { resolveSessionAgentIds } from "../../agent-scope.js";
|
||||||
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
|
import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../../bootstrap-files.js";
|
||||||
|
import { resolveClawdbotDocsPath } from "../../docs-path.js";
|
||||||
import { resolveModelAuthMode } from "../../model-auth.js";
|
import { resolveModelAuthMode } from "../../model-auth.js";
|
||||||
import {
|
import {
|
||||||
isCloudCodeAssistFormatError,
|
isCloudCodeAssistFormatError,
|
||||||
@@ -216,6 +217,12 @@ export async function runEmbeddedAttempt(
|
|||||||
});
|
});
|
||||||
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
||||||
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
||||||
|
const docsPath = await resolveClawdbotDocsPath({
|
||||||
|
workspaceDir: effectiveWorkspace,
|
||||||
|
argv1: process.argv[1],
|
||||||
|
cwd: process.cwd(),
|
||||||
|
moduleUrl: import.meta.url,
|
||||||
|
});
|
||||||
|
|
||||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||||
workspaceDir: effectiveWorkspace,
|
workspaceDir: effectiveWorkspace,
|
||||||
@@ -228,6 +235,7 @@ export async function runEmbeddedAttempt(
|
|||||||
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
||||||
: undefined,
|
: undefined,
|
||||||
skillsPrompt,
|
skillsPrompt,
|
||||||
|
docsPath: docsPath ?? undefined,
|
||||||
reactionGuidance,
|
reactionGuidance,
|
||||||
promptMode,
|
promptMode,
|
||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
@@ -433,6 +441,7 @@ export async function runEmbeddedAttempt(
|
|||||||
getMessagingToolSentTexts,
|
getMessagingToolSentTexts,
|
||||||
getMessagingToolSentTargets,
|
getMessagingToolSentTargets,
|
||||||
didSendViaMessagingTool,
|
didSendViaMessagingTool,
|
||||||
|
getLastToolError,
|
||||||
} = subscription;
|
} = subscription;
|
||||||
|
|
||||||
const queueHandle: EmbeddedPiQueueHandle = {
|
const queueHandle: EmbeddedPiQueueHandle = {
|
||||||
@@ -665,6 +674,7 @@ export async function runEmbeddedAttempt(
|
|||||||
assistantTexts,
|
assistantTexts,
|
||||||
toolMetas: toolMetasNormalized,
|
toolMetas: toolMetasNormalized,
|
||||||
lastAssistant,
|
lastAssistant,
|
||||||
|
lastToolError: getLastToolError?.(),
|
||||||
didSendViaMessagingTool: didSendViaMessagingTool(),
|
didSendViaMessagingTool: didSendViaMessagingTool(),
|
||||||
messagingToolSentTexts: getMessagingToolSentTexts(),
|
messagingToolSentTexts: getMessagingToolSentTexts(),
|
||||||
messagingToolSentTargets: getMessagingToolSentTargets(),
|
messagingToolSentTargets: getMessagingToolSentTargets(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
|||||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||||
|
|
||||||
import { assertSandboxPath } from "../../sandbox-paths.js";
|
import { assertSandboxPath } from "../../sandbox-paths.js";
|
||||||
|
import { sanitizeImageBlocks } from "../../tool-images.js";
|
||||||
import { extractTextFromMessage } from "../../../tui/tui-formatters.js";
|
import { extractTextFromMessage } from "../../../tui/tui-formatters.js";
|
||||||
import { loadWebMedia } from "../../../web/media.js";
|
import { loadWebMedia } from "../../../web/media.js";
|
||||||
import { resolveUserPath } from "../../../utils.js";
|
import { resolveUserPath } from "../../../utils.js";
|
||||||
@@ -48,6 +49,17 @@ function isImageExtension(filePath: string): boolean {
|
|||||||
return IMAGE_EXTENSIONS.has(ext);
|
return IMAGE_EXTENSIONS.has(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sanitizeImagesWithLog(
|
||||||
|
images: ImageContent[],
|
||||||
|
label: string,
|
||||||
|
): Promise<ImageContent[]> {
|
||||||
|
const { images: sanitized, dropped } = await sanitizeImageBlocks(images, label);
|
||||||
|
if (dropped > 0) {
|
||||||
|
log.warn(`Native image: dropped ${dropped} image(s) after sanitization (${label}).`);
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detects image references in a user prompt.
|
* Detects image references in a user prompt.
|
||||||
*
|
*
|
||||||
@@ -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 {
|
return {
|
||||||
images: promptImages,
|
images: sanitizedPromptImages,
|
||||||
historyImagesByIndex,
|
historyImagesByIndex: sanitizedHistoryImagesByIndex,
|
||||||
detectedRefs: allRefs,
|
detectedRefs: allRefs,
|
||||||
loadedCount,
|
loadedCount,
|
||||||
skippedCount,
|
skippedCount,
|
||||||
|
|||||||
@@ -111,4 +111,40 @@ describe("buildEmbeddedRunPayloads", () => {
|
|||||||
expect(payloads).toHaveLength(1);
|
expect(payloads).toHaveLength(1);
|
||||||
expect(payloads[0]?.text).toBe(errorJsonPretty.trim());
|
expect(payloads[0]?.text).toBe(errorJsonPretty.trim());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("adds a fallback error when a tool fails and no assistant output exists", () => {
|
||||||
|
const payloads = buildEmbeddedRunPayloads({
|
||||||
|
assistantTexts: [],
|
||||||
|
toolMetas: [],
|
||||||
|
lastAssistant: undefined,
|
||||||
|
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("does not add tool error fallback when assistant output exists", () => {
|
||||||
|
const payloads = buildEmbeddedRunPayloads({
|
||||||
|
assistantTexts: ["All good"],
|
||||||
|
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]?.text).toBe("All good");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -23,6 +23,7 @@ export function buildEmbeddedRunPayloads(params: {
|
|||||||
assistantTexts: string[];
|
assistantTexts: string[];
|
||||||
toolMetas: ToolMetaEntry[];
|
toolMetas: ToolMetaEntry[];
|
||||||
lastAssistant: AssistantMessage | undefined;
|
lastAssistant: AssistantMessage | undefined;
|
||||||
|
lastToolError?: { toolName: string; meta?: string; error?: string };
|
||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
verboseLevel?: VerboseLevel;
|
verboseLevel?: VerboseLevel;
|
||||||
@@ -155,6 +156,19 @@ export function buildEmbeddedRunPayloads(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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}` : "";
|
||||||
|
replyItems.push({
|
||||||
|
text: `⚠️ ${toolSummary} failed${errorSuffix}`,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const hasAudioAsVoiceTag = replyItems.some((item) => item.audioAsVoice);
|
const hasAudioAsVoiceTag = replyItems.some((item) => item.audioAsVoice);
|
||||||
return replyItems
|
return replyItems
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export type EmbeddedRunAttemptResult = {
|
|||||||
assistantTexts: string[];
|
assistantTexts: string[];
|
||||||
toolMetas: Array<{ toolName: string; meta?: string }>;
|
toolMetas: Array<{ toolName: string; meta?: string }>;
|
||||||
lastAssistant: AssistantMessage | undefined;
|
lastAssistant: AssistantMessage | undefined;
|
||||||
|
lastToolError?: { toolName: string; meta?: string; error?: string };
|
||||||
didSendViaMessagingTool: boolean;
|
didSendViaMessagingTool: boolean;
|
||||||
messagingToolSentTexts: string[];
|
messagingToolSentTexts: string[];
|
||||||
messagingToolSentTargets: MessagingToolSend[];
|
messagingToolSentTargets: MessagingToolSend[];
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export function buildEmbeddedSystemPrompt(params: {
|
|||||||
reasoningTagHint: boolean;
|
reasoningTagHint: boolean;
|
||||||
heartbeatPrompt?: string;
|
heartbeatPrompt?: string;
|
||||||
skillsPrompt?: string;
|
skillsPrompt?: string;
|
||||||
|
docsPath?: string;
|
||||||
reactionGuidance?: {
|
reactionGuidance?: {
|
||||||
level: "minimal" | "extensive";
|
level: "minimal" | "extensive";
|
||||||
channel: string;
|
channel: string;
|
||||||
@@ -48,6 +49,7 @@ export function buildEmbeddedSystemPrompt(params: {
|
|||||||
reasoningTagHint: params.reasoningTagHint,
|
reasoningTagHint: params.reasoningTagHint,
|
||||||
heartbeatPrompt: params.heartbeatPrompt,
|
heartbeatPrompt: params.heartbeatPrompt,
|
||||||
skillsPrompt: params.skillsPrompt,
|
skillsPrompt: params.skillsPrompt,
|
||||||
|
docsPath: params.docsPath,
|
||||||
reactionGuidance: params.reactionGuidance,
|
reactionGuidance: params.reactionGuidance,
|
||||||
promptMode: params.promptMode,
|
promptMode: params.promptMode,
|
||||||
runtimeInfo: params.runtimeInfo,
|
runtimeInfo: params.runtimeInfo,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { normalizeTextForComparison } from "./pi-embedded-helpers.js";
|
|||||||
import { isMessagingTool, isMessagingToolSendAction } from "./pi-embedded-messaging.js";
|
import { isMessagingTool, isMessagingToolSendAction } from "./pi-embedded-messaging.js";
|
||||||
import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js";
|
import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js";
|
||||||
import {
|
import {
|
||||||
|
extractToolErrorMessage,
|
||||||
extractToolResultText,
|
extractToolResultText,
|
||||||
extractMessagingToolSend,
|
extractMessagingToolSend,
|
||||||
isToolResultError,
|
isToolResultError,
|
||||||
@@ -154,6 +155,14 @@ export function handleToolExecutionEnd(
|
|||||||
ctx.state.toolMetas.push({ toolName, meta });
|
ctx.state.toolMetas.push({ toolName, meta });
|
||||||
ctx.state.toolMetaById.delete(toolCallId);
|
ctx.state.toolMetaById.delete(toolCallId);
|
||||||
ctx.state.toolSummaryById.delete(toolCallId);
|
ctx.state.toolSummaryById.delete(toolCallId);
|
||||||
|
if (isToolError) {
|
||||||
|
const errorMessage = extractToolErrorMessage(sanitizedResult);
|
||||||
|
ctx.state.lastToolError = {
|
||||||
|
toolName,
|
||||||
|
meta,
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Commit messaging tool text on success, discard on error.
|
// Commit messaging tool text on success, discard on error.
|
||||||
const pendingText = ctx.state.pendingMessagingTexts.get(toolCallId);
|
const pendingText = ctx.state.pendingMessagingTexts.get(toolCallId);
|
||||||
|
|||||||
@@ -14,11 +14,18 @@ export type EmbeddedSubscribeLogger = {
|
|||||||
warn: (message: string) => void;
|
warn: (message: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ToolErrorSummary = {
|
||||||
|
toolName: string;
|
||||||
|
meta?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type EmbeddedPiSubscribeState = {
|
export type EmbeddedPiSubscribeState = {
|
||||||
assistantTexts: string[];
|
assistantTexts: string[];
|
||||||
toolMetas: Array<{ toolName?: string; meta?: string }>;
|
toolMetas: Array<{ toolName?: string; meta?: string }>;
|
||||||
toolMetaById: Map<string, string | undefined>;
|
toolMetaById: Map<string, string | undefined>;
|
||||||
toolSummaryById: Set<string>;
|
toolSummaryById: Set<string>;
|
||||||
|
lastToolError?: ToolErrorSummary;
|
||||||
|
|
||||||
blockReplyBreak: "text_end" | "message_end";
|
blockReplyBreak: "text_end" | "message_end";
|
||||||
reasoningMode: ReasoningLevel;
|
reasoningMode: ReasoningLevel;
|
||||||
|
|||||||
@@ -4,12 +4,44 @@ import { type MessagingToolSend } from "./pi-embedded-messaging.js";
|
|||||||
import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js";
|
import { normalizeTargetForProvider } from "../infra/outbound/target-normalization.js";
|
||||||
|
|
||||||
const TOOL_RESULT_MAX_CHARS = 8000;
|
const TOOL_RESULT_MAX_CHARS = 8000;
|
||||||
|
const TOOL_ERROR_MAX_CHARS = 400;
|
||||||
|
|
||||||
function truncateToolText(text: string): string {
|
function truncateToolText(text: string): string {
|
||||||
if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
|
if (text.length <= TOOL_RESULT_MAX_CHARS) return text;
|
||||||
return `${truncateUtf16Safe(text, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
|
return `${truncateUtf16Safe(text, TOOL_RESULT_MAX_CHARS)}\n…(truncated)…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeToolErrorText(text: string): string | undefined {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
const firstLine = trimmed.split(/\r?\n/)[0]?.trim() ?? "";
|
||||||
|
if (!firstLine) return undefined;
|
||||||
|
return firstLine.length > TOOL_ERROR_MAX_CHARS
|
||||||
|
? `${truncateUtf16Safe(firstLine, TOOL_ERROR_MAX_CHARS)}…`
|
||||||
|
: firstLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readErrorCandidate(value: unknown): string | undefined {
|
||||||
|
if (typeof value === "string") return normalizeToolErrorText(value);
|
||||||
|
if (!value || typeof value !== "object") return undefined;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (typeof record.message === "string") return normalizeToolErrorText(record.message);
|
||||||
|
if (typeof record.error === "string") return normalizeToolErrorText(record.error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorField(value: unknown): string | undefined {
|
||||||
|
if (!value || typeof value !== "object") return undefined;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
const direct =
|
||||||
|
readErrorCandidate(record.error) ??
|
||||||
|
readErrorCandidate(record.message) ??
|
||||||
|
readErrorCandidate(record.reason);
|
||||||
|
if (direct) return direct;
|
||||||
|
const status = typeof record.status === "string" ? record.status.trim() : "";
|
||||||
|
return status ? normalizeToolErrorText(status) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function sanitizeToolResult(result: unknown): unknown {
|
export function sanitizeToolResult(result: unknown): unknown {
|
||||||
if (!result || typeof result !== "object") return result;
|
if (!result || typeof result !== "object") return result;
|
||||||
const record = result as Record<string, unknown>;
|
const record = result as Record<string, unknown>;
|
||||||
@@ -63,6 +95,25 @@ export function isToolResultError(result: unknown): boolean {
|
|||||||
return normalized === "error" || normalized === "timeout";
|
return normalized === "error" || normalized === "timeout";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractToolErrorMessage(result: unknown): string | undefined {
|
||||||
|
if (!result || typeof result !== "object") return undefined;
|
||||||
|
const record = result as Record<string, unknown>;
|
||||||
|
const fromDetails = extractErrorField(record.details);
|
||||||
|
if (fromDetails) return fromDetails;
|
||||||
|
const fromRoot = extractErrorField(record);
|
||||||
|
if (fromRoot) return fromRoot;
|
||||||
|
const text = extractToolResultText(result);
|
||||||
|
if (!text) return undefined;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as unknown;
|
||||||
|
const fromJson = extractErrorField(parsed);
|
||||||
|
if (fromJson) return fromJson;
|
||||||
|
} catch {
|
||||||
|
// Fall through to first-line text fallback.
|
||||||
|
}
|
||||||
|
return normalizeToolErrorText(text);
|
||||||
|
}
|
||||||
|
|
||||||
export function extractMessagingToolSend(
|
export function extractMessagingToolSend(
|
||||||
toolName: string,
|
toolName: string,
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
toolMetas: [],
|
toolMetas: [],
|
||||||
toolMetaById: new Map(),
|
toolMetaById: new Map(),
|
||||||
toolSummaryById: new Set(),
|
toolSummaryById: new Set(),
|
||||||
|
lastToolError: undefined,
|
||||||
blockReplyBreak: params.blockReplyBreak ?? "text_end",
|
blockReplyBreak: params.blockReplyBreak ?? "text_end",
|
||||||
reasoningMode,
|
reasoningMode,
|
||||||
includeReasoning: reasoningMode === "on",
|
includeReasoning: reasoningMode === "on",
|
||||||
@@ -380,6 +381,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
toolMetas.length = 0;
|
toolMetas.length = 0;
|
||||||
toolMetaById.clear();
|
toolMetaById.clear();
|
||||||
toolSummaryById.clear();
|
toolSummaryById.clear();
|
||||||
|
state.lastToolError = undefined;
|
||||||
messagingToolSentTexts.length = 0;
|
messagingToolSentTexts.length = 0;
|
||||||
messagingToolSentTextsNormalized.length = 0;
|
messagingToolSentTextsNormalized.length = 0;
|
||||||
messagingToolSentTargets.length = 0;
|
messagingToolSentTargets.length = 0;
|
||||||
@@ -425,6 +427,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
// Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!")
|
// Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!")
|
||||||
// which is generated AFTER the tool sends the actual answer.
|
// which is generated AFTER the tool sends the actual answer.
|
||||||
didSendViaMessagingTool: () => messagingToolSentTexts.length > 0,
|
didSendViaMessagingTool: () => messagingToolSentTexts.length > 0,
|
||||||
|
getLastToolError: () => (state.lastToolError ? { ...state.lastToolError } : undefined),
|
||||||
waitForCompactionRetry: () => {
|
waitForCompactionRetry: () => {
|
||||||
if (state.compactionInFlight || state.pendingCompactionRetry > 0) {
|
if (state.compactionInFlight || state.pendingCompactionRetry > 0) {
|
||||||
ensureCompactionPromise();
|
ensureCompactionPromise();
|
||||||
|
|||||||
@@ -32,12 +32,14 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
|
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
|
||||||
heartbeatPrompt: "ping",
|
heartbeatPrompt: "ping",
|
||||||
toolNames: ["message", "memory_search"],
|
toolNames: ["message", "memory_search"],
|
||||||
|
docsPath: "/tmp/clawd/docs",
|
||||||
extraSystemPrompt: "Subagent details",
|
extraSystemPrompt: "Subagent details",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prompt).not.toContain("## User Identity");
|
expect(prompt).not.toContain("## User Identity");
|
||||||
expect(prompt).not.toContain("## Skills");
|
expect(prompt).not.toContain("## Skills");
|
||||||
expect(prompt).not.toContain("## Memory Recall");
|
expect(prompt).not.toContain("## Memory Recall");
|
||||||
|
expect(prompt).not.toContain("## Documentation");
|
||||||
expect(prompt).not.toContain("## Reply Tags");
|
expect(prompt).not.toContain("## Reply Tags");
|
||||||
expect(prompt).not.toContain("## Messaging");
|
expect(prompt).not.toContain("## Messaging");
|
||||||
expect(prompt).not.toContain("## Silent Replies");
|
expect(prompt).not.toContain("## Silent Replies");
|
||||||
@@ -86,6 +88,7 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
toolNames: ["Read", "Exec", "process"],
|
toolNames: ["Read", "Exec", "process"],
|
||||||
skillsPrompt:
|
skillsPrompt:
|
||||||
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
|
"<available_skills>\n <skill>\n <name>demo</name>\n </skill>\n</available_skills>",
|
||||||
|
docsPath: "/tmp/clawd/docs",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(prompt).toContain("- Read: Read file contents");
|
expect(prompt).toContain("- Read: Read file contents");
|
||||||
@@ -93,6 +96,20 @@ describe("buildAgentSystemPrompt", () => {
|
|||||||
expect(prompt).toContain(
|
expect(prompt).toContain(
|
||||||
"Use `Read` to load the SKILL.md at the location listed for that skill.",
|
"Use `Read` to load the SKILL.md at the location listed for that skill.",
|
||||||
);
|
);
|
||||||
|
expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes docs guidance when docsPath is provided", () => {
|
||||||
|
const prompt = buildAgentSystemPrompt({
|
||||||
|
workspaceDir: "/tmp/clawd",
|
||||||
|
docsPath: "/tmp/clawd/docs",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prompt).toContain("## Documentation");
|
||||||
|
expect(prompt).toContain("Clawdbot docs: /tmp/clawd/docs");
|
||||||
|
expect(prompt).toContain(
|
||||||
|
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes user time when provided (12-hour)", () => {
|
it("includes user time when provided (12-hour)", () => {
|
||||||
|
|||||||
@@ -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: {
|
export function buildAgentSystemPrompt(params: {
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
defaultThinkLevel?: ThinkLevel;
|
defaultThinkLevel?: ThinkLevel;
|
||||||
@@ -125,6 +145,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
contextFiles?: EmbeddedContextFile[];
|
contextFiles?: EmbeddedContextFile[];
|
||||||
skillsPrompt?: string;
|
skillsPrompt?: string;
|
||||||
heartbeatPrompt?: string;
|
heartbeatPrompt?: string;
|
||||||
|
docsPath?: string;
|
||||||
/** Controls which hardcoded sections to include. Defaults to "full". */
|
/** Controls which hardcoded sections to include. Defaults to "full". */
|
||||||
promptMode?: PromptMode;
|
promptMode?: PromptMode;
|
||||||
runtimeInfo?: {
|
runtimeInfo?: {
|
||||||
@@ -295,6 +316,11 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
readToolName,
|
readToolName,
|
||||||
});
|
});
|
||||||
const memorySection = buildMemorySection({ isMinimal, availableTools });
|
const memorySection = buildMemorySection({ isMinimal, availableTools });
|
||||||
|
const docsSection = buildDocsSection({
|
||||||
|
docsPath: params.docsPath,
|
||||||
|
isMinimal,
|
||||||
|
readToolName,
|
||||||
|
});
|
||||||
|
|
||||||
// For "none" mode, return just the basic identity line
|
// For "none" mode, return just the basic identity line
|
||||||
if (promptMode === "none") {
|
if (promptMode === "none") {
|
||||||
@@ -371,6 +397,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
`Your working directory is: ${params.workspaceDir}`,
|
`Your working directory is: ${params.workspaceDir}`,
|
||||||
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
|
"Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
|
||||||
"",
|
"",
|
||||||
|
...docsSection,
|
||||||
params.sandboxInfo?.enabled ? "## Sandbox" : "",
|
params.sandboxInfo?.enabled ? "## Sandbox" : "",
|
||||||
params.sandboxInfo?.enabled
|
params.sandboxInfo?.enabled
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { sanitizeContentBlocksImages } from "./tool-images.js";
|
import { sanitizeContentBlocksImages, sanitizeImageBlocks } from "./tool-images.js";
|
||||||
|
|
||||||
describe("tool image sanitizing", () => {
|
describe("tool image sanitizing", () => {
|
||||||
it("shrinks oversized images to <=5MB", async () => {
|
it("shrinks oversized images to <=5MB", async () => {
|
||||||
@@ -33,6 +33,56 @@ describe("tool image sanitizing", () => {
|
|||||||
expect(image.mimeType).toBe("image/jpeg");
|
expect(image.mimeType).toBe("image/jpeg");
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
|
it("sanitizes image arrays and reports drops", async () => {
|
||||||
|
const width = 2600;
|
||||||
|
const height = 400;
|
||||||
|
const raw = Buffer.alloc(width * height * 3, 0x7f);
|
||||||
|
const png = await sharp(raw, {
|
||||||
|
raw: { width, height, channels: 3 },
|
||||||
|
})
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const images = [
|
||||||
|
{ type: "image" as const, data: png.toString("base64"), mimeType: "image/png" },
|
||||||
|
];
|
||||||
|
const { images: out, dropped } = await sanitizeImageBlocks(images, "test");
|
||||||
|
expect(dropped).toBe(0);
|
||||||
|
expect(out.length).toBe(1);
|
||||||
|
const meta = await sharp(Buffer.from(out[0].data, "base64")).metadata();
|
||||||
|
expect(meta.width).toBeLessThanOrEqual(2000);
|
||||||
|
expect(meta.height).toBeLessThanOrEqual(2000);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
it("shrinks images that exceed max dimension even if size is small", async () => {
|
||||||
|
const width = 2600;
|
||||||
|
const height = 400;
|
||||||
|
const raw = Buffer.alloc(width * height * 3, 0x7f);
|
||||||
|
const png = await sharp(raw, {
|
||||||
|
raw: { width, height, channels: 3 },
|
||||||
|
})
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
const blocks = [
|
||||||
|
{
|
||||||
|
type: "image" as const,
|
||||||
|
data: png.toString("base64"),
|
||||||
|
mimeType: "image/png",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const out = await sanitizeContentBlocksImages(blocks, "test");
|
||||||
|
const image = out.find((b) => b.type === "image");
|
||||||
|
if (!image || image.type !== "image") {
|
||||||
|
throw new Error("expected image block");
|
||||||
|
}
|
||||||
|
const meta = await sharp(Buffer.from(image.data, "base64")).metadata();
|
||||||
|
expect(meta.width).toBeLessThanOrEqual(2000);
|
||||||
|
expect(meta.height).toBeLessThanOrEqual(2000);
|
||||||
|
expect(image.mimeType).toBe("image/jpeg");
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
it("corrects mismatched jpeg mimeType", async () => {
|
it("corrects mismatched jpeg mimeType", async () => {
|
||||||
const jpeg = await sharp({
|
const jpeg = await sharp({
|
||||||
create: {
|
create: {
|
||||||
|
|||||||
+55
-12
@@ -1,5 +1,7 @@
|
|||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||||
|
|
||||||
|
import { createSubsystemLogger } from "../logging.js";
|
||||||
import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js";
|
import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js";
|
||||||
|
|
||||||
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
|
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
|
||||||
@@ -14,6 +16,7 @@ type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
|
|||||||
// and recompress base64 image blocks when they exceed these limits.
|
// and recompress base64 image blocks when they exceed these limits.
|
||||||
const MAX_IMAGE_DIMENSION_PX = 2000;
|
const MAX_IMAGE_DIMENSION_PX = 2000;
|
||||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||||
|
const log = createSubsystemLogger("agents/tool-images");
|
||||||
|
|
||||||
function isImageBlock(block: unknown): block is ImageContentBlock {
|
function isImageBlock(block: unknown): block is ImageContentBlock {
|
||||||
if (!block || typeof block !== "object") return false;
|
if (!block || typeof block !== "object") return false;
|
||||||
@@ -41,26 +44,41 @@ async function resizeImageBase64IfNeeded(params: {
|
|||||||
mimeType: string;
|
mimeType: string;
|
||||||
maxDimensionPx: number;
|
maxDimensionPx: number;
|
||||||
maxBytes: 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 buf = Buffer.from(params.base64, "base64");
|
||||||
const meta = await getImageMetadata(buf);
|
const meta = await getImageMetadata(buf);
|
||||||
const width = meta?.width;
|
const width = meta?.width;
|
||||||
const height = meta?.height;
|
const height = meta?.height;
|
||||||
const overBytes = buf.byteLength > params.maxBytes;
|
const overBytes = buf.byteLength > params.maxBytes;
|
||||||
const maxDim = Math.max(width ?? 0, height ?? 0);
|
const hasDimensions = typeof width === "number" && typeof height === "number";
|
||||||
if (typeof width !== "number" || typeof height !== "number") {
|
if (hasDimensions && !overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
|
||||||
if (!overBytes) {
|
return {
|
||||||
return {
|
base64: params.base64,
|
||||||
base64: params.base64,
|
mimeType: params.mimeType,
|
||||||
mimeType: params.mimeType,
|
resized: false,
|
||||||
resized: false,
|
width,
|
||||||
};
|
height,
|
||||||
}
|
};
|
||||||
} else if (!overBytes && width <= params.maxDimensionPx && height <= params.maxDimensionPx) {
|
}
|
||||||
return { base64: params.base64, mimeType: params.mimeType, resized: false };
|
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 qualities = [85, 75, 65, 55, 45, 35];
|
||||||
|
const maxDim = hasDimensions ? Math.max(width ?? 0, height ?? 0) : params.maxDimensionPx;
|
||||||
const sideStart = maxDim > 0 ? Math.min(params.maxDimensionPx, maxDim) : params.maxDimensionPx;
|
const sideStart = maxDim > 0 ? Math.min(params.maxDimensionPx, maxDim) : params.maxDimensionPx;
|
||||||
const sideGrid = [sideStart, 1800, 1600, 1400, 1200, 1000, 800]
|
const sideGrid = [sideStart, 1800, 1600, 1400, 1200, 1000, 800]
|
||||||
.map((v) => Math.min(params.maxDimensionPx, v))
|
.map((v) => Math.min(params.maxDimensionPx, v))
|
||||||
@@ -80,10 +98,23 @@ async function resizeImageBase64IfNeeded(params: {
|
|||||||
smallest = { buffer: out, size: out.byteLength };
|
smallest = { buffer: out, size: out.byteLength };
|
||||||
}
|
}
|
||||||
if (out.byteLength <= params.maxBytes) {
|
if (out.byteLength <= params.maxBytes) {
|
||||||
|
log.info("Image resized", {
|
||||||
|
label: params.label,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
maxDimensionPx: params.maxDimensionPx,
|
||||||
|
maxBytes: params.maxBytes,
|
||||||
|
originalBytes: buf.byteLength,
|
||||||
|
resizedBytes: out.byteLength,
|
||||||
|
quality,
|
||||||
|
side,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
base64: out.toString("base64"),
|
base64: out.toString("base64"),
|
||||||
mimeType: "image/jpeg",
|
mimeType: "image/jpeg",
|
||||||
resized: true,
|
resized: true,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +158,7 @@ export async function sanitizeContentBlocksImages(
|
|||||||
mimeType,
|
mimeType,
|
||||||
maxDimensionPx,
|
maxDimensionPx,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
|
label,
|
||||||
});
|
});
|
||||||
out.push({
|
out.push({
|
||||||
...block,
|
...block,
|
||||||
@@ -144,6 +176,17 @@ export async function sanitizeContentBlocksImages(
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sanitizeImageBlocks(
|
||||||
|
images: ImageContent[],
|
||||||
|
label: string,
|
||||||
|
opts: { maxDimensionPx?: number; maxBytes?: number } = {},
|
||||||
|
): Promise<{ images: ImageContent[]; dropped: number }> {
|
||||||
|
if (images.length === 0) return { images, dropped: 0 };
|
||||||
|
const sanitized = await sanitizeContentBlocksImages(images as ToolContentBlock[], label, opts);
|
||||||
|
const next = sanitized.filter(isImageBlock) as ImageContent[];
|
||||||
|
return { images: next, dropped: Math.max(0, images.length - next.length) };
|
||||||
|
}
|
||||||
|
|
||||||
export async function sanitizeToolResultImages(
|
export async function sanitizeToolResultImages(
|
||||||
result: AgentToolResult<unknown>,
|
result: AgentToolResult<unknown>,
|
||||||
label: string,
|
label: string,
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export async function handleBashChatCommand(params: {
|
|||||||
}): Promise<ReplyPayload> {
|
}): Promise<ReplyPayload> {
|
||||||
if (params.cfg.commands?.bash !== true) {
|
if (params.cfg.commands?.bash !== true) {
|
||||||
return {
|
return {
|
||||||
text: "⚠️ bash is disabled. Set commands.bash=true to enable.",
|
text: "⚠️ bash is disabled. Set commands.bash=true to enable. Docs: https://docs.clawd.bot/tools/slash-commands#config",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ type NodeDaemonStatusOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function renderNodeServiceStartHints(): string[] {
|
function renderNodeServiceStartHints(): string[] {
|
||||||
const base = ["clawdbot node daemon install", "clawdbot node start"];
|
const base = ["clawdbot node service install", "clawdbot node start"];
|
||||||
switch (process.platform) {
|
switch (process.platform) {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
return [
|
return [
|
||||||
@@ -133,7 +133,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (resolveIsNixMode(process.env)) {
|
if (resolveIsNixMode(process.env)) {
|
||||||
fail("Nix mode detected; daemon install is disabled.");
|
fail("Nix mode detected; service install is disabled.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
|
|||||||
});
|
});
|
||||||
if (!json) {
|
if (!json) {
|
||||||
defaultRuntime.log(`Node service already ${service.loadedText}.`);
|
defaultRuntime.log(`Node service already ${service.loadedText}.`);
|
||||||
defaultRuntime.log("Reinstall with: clawdbot node daemon install --force");
|
defaultRuntime.log("Reinstall with: clawdbot node service install --force");
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -244,7 +244,7 @@ export async function runNodeDaemonUninstall(opts: NodeDaemonLifecycleOptions =
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (resolveIsNixMode(process.env)) {
|
if (resolveIsNixMode(process.env)) {
|
||||||
fail("Nix mode detected; daemon uninstall is disabled.");
|
fail("Nix mode detected; service uninstall is disabled.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
const daemon = node
|
||||||
.command("daemon")
|
.command("daemon", { hidden: true })
|
||||||
.description("Manage the headless node daemon service (launchd/systemd/schtasks)");
|
.description("Legacy alias for node service commands");
|
||||||
|
registerNodeServiceCommands(daemon);
|
||||||
daemon
|
|
||||||
.command("status")
|
|
||||||
.description("Show node daemon status")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonStatus(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
daemon
|
|
||||||
.command("install")
|
|
||||||
.description("Install the node daemon service (launchd/systemd/schtasks)")
|
|
||||||
.option("--host <host>", "Gateway bridge host")
|
|
||||||
.option("--port <port>", "Gateway bridge port")
|
|
||||||
.option("--tls", "Use TLS for the bridge connection", false)
|
|
||||||
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
|
||||||
.option("--node-id <id>", "Override node id (clears pairing token)")
|
|
||||||
.option("--display-name <name>", "Override node display name")
|
|
||||||
.option("--runtime <runtime>", "Daemon runtime (node|bun). Default: node")
|
|
||||||
.option("--force", "Reinstall/overwrite if already installed", false)
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonInstall(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
daemon
|
|
||||||
.command("uninstall")
|
|
||||||
.description("Uninstall the node daemon service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonUninstall(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
daemon
|
|
||||||
.command("start")
|
|
||||||
.description("Start the node daemon service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonStart(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
daemon
|
|
||||||
.command("stop")
|
|
||||||
.description("Stop the node daemon service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonStop(opts);
|
|
||||||
});
|
|
||||||
|
|
||||||
daemon
|
|
||||||
.command("restart")
|
|
||||||
.description("Restart the node daemon service (launchd/systemd/schtasks)")
|
|
||||||
.option("--json", "Output JSON", false)
|
|
||||||
.action(async (opts) => {
|
|
||||||
await runNodeDaemonRestart(opts);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import { registerDaemonCli } from "../daemon-cli.js";
|
|||||||
import { registerDnsCli } from "../dns-cli.js";
|
import { registerDnsCli } from "../dns-cli.js";
|
||||||
import { registerDirectoryCli } from "../directory-cli.js";
|
import { registerDirectoryCli } from "../directory-cli.js";
|
||||||
import { registerDocsCli } from "../docs-cli.js";
|
import { registerDocsCli } from "../docs-cli.js";
|
||||||
|
import { registerExecApprovalsCli } from "../exec-approvals-cli.js";
|
||||||
import { registerGatewayCli } from "../gateway-cli.js";
|
import { registerGatewayCli } from "../gateway-cli.js";
|
||||||
import { registerHooksCli } from "../hooks-cli.js";
|
import { registerHooksCli } from "../hooks-cli.js";
|
||||||
import { registerWebhooksCli } from "../webhooks-cli.js";
|
import { registerWebhooksCli } from "../webhooks-cli.js";
|
||||||
@@ -19,6 +20,7 @@ import { registerPairingCli } from "../pairing-cli.js";
|
|||||||
import { registerPluginsCli } from "../plugins-cli.js";
|
import { registerPluginsCli } from "../plugins-cli.js";
|
||||||
import { registerSandboxCli } from "../sandbox-cli.js";
|
import { registerSandboxCli } from "../sandbox-cli.js";
|
||||||
import { registerSecurityCli } from "../security-cli.js";
|
import { registerSecurityCli } from "../security-cli.js";
|
||||||
|
import { registerServiceCli } from "../service-cli.js";
|
||||||
import { registerSkillsCli } from "../skills-cli.js";
|
import { registerSkillsCli } from "../skills-cli.js";
|
||||||
import { registerTuiCli } from "../tui-cli.js";
|
import { registerTuiCli } from "../tui-cli.js";
|
||||||
import { registerUpdateCli } from "../update-cli.js";
|
import { registerUpdateCli } from "../update-cli.js";
|
||||||
@@ -27,8 +29,10 @@ export function registerSubCliCommands(program: Command) {
|
|||||||
registerAcpCli(program);
|
registerAcpCli(program);
|
||||||
registerDaemonCli(program);
|
registerDaemonCli(program);
|
||||||
registerGatewayCli(program);
|
registerGatewayCli(program);
|
||||||
|
registerServiceCli(program);
|
||||||
registerLogsCli(program);
|
registerLogsCli(program);
|
||||||
registerModelsCli(program);
|
registerModelsCli(program);
|
||||||
|
registerExecApprovalsCli(program);
|
||||||
registerNodesCli(program);
|
registerNodesCli(program);
|
||||||
registerNodeCli(program);
|
registerNodeCli(program);
|
||||||
registerSandboxCli(program);
|
registerSandboxCli(program);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
|
|||||||
import { withProgress } from "../cli/progress.js";
|
import { withProgress } from "../cli/progress.js";
|
||||||
import { loadConfig, readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
|
import { loadConfig, readConfigFileSnapshot, resolveGatewayPort } from "../config/config.js";
|
||||||
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||||
|
import type { GatewayService } from "../daemon/service.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
|
import { resolveNodeService } from "../daemon/node-service.js";
|
||||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||||
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
|
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
|
||||||
import { probeGateway } from "../gateway/probe.js";
|
import { probeGateway } from "../gateway/probe.js";
|
||||||
@@ -130,10 +132,9 @@ export async function statusAllCommand(
|
|||||||
const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null);
|
const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null);
|
||||||
progress.tick();
|
progress.tick();
|
||||||
|
|
||||||
progress.setLabel("Checking daemon…");
|
progress.setLabel("Checking services…");
|
||||||
const daemon = await (async () => {
|
const readServiceSummary = async (service: GatewayService) => {
|
||||||
try {
|
try {
|
||||||
const service = resolveGatewayService();
|
|
||||||
const [loaded, runtimeInfo, command] = await Promise.all([
|
const [loaded, runtimeInfo, command] = await Promise.all([
|
||||||
service.isLoaded({ env: process.env }).catch(() => false),
|
service.isLoaded({ env: process.env }).catch(() => false),
|
||||||
service.readRuntime(process.env).catch(() => undefined),
|
service.readRuntime(process.env).catch(() => undefined),
|
||||||
@@ -150,7 +151,9 @@ export async function statusAllCommand(
|
|||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
})();
|
};
|
||||||
|
const daemon = await readServiceSummary(resolveGatewayService());
|
||||||
|
const nodeService = await readServiceSummary(resolveNodeService());
|
||||||
progress.tick();
|
progress.tick();
|
||||||
|
|
||||||
progress.setLabel("Scanning agents…");
|
progress.setLabel("Scanning agents…");
|
||||||
@@ -340,13 +343,22 @@ export async function statusAllCommand(
|
|||||||
: { Item: "Gateway self", Value: "unknown" },
|
: { Item: "Gateway self", Value: "unknown" },
|
||||||
daemon
|
daemon
|
||||||
? {
|
? {
|
||||||
Item: "Daemon",
|
Item: "Gateway service",
|
||||||
Value:
|
Value:
|
||||||
daemon.installed === false
|
daemon.installed === false
|
||||||
? `${daemon.label} not installed`
|
? `${daemon.label} not installed`
|
||||||
: `${daemon.label} ${daemon.installed ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`,
|
: `${daemon.label} ${daemon.installed ? "installed · " : ""}${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`,
|
||||||
}
|
}
|
||||||
: { Item: "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",
|
Item: "Agents",
|
||||||
Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`,
|
Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "../memory/status-format.js";
|
} from "../memory/status-format.js";
|
||||||
import { formatHealthChannelLines, type HealthSummary } from "./health.js";
|
import { formatHealthChannelLines, type HealthSummary } from "./health.js";
|
||||||
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
||||||
import { getDaemonStatusSummary } from "./status.daemon.js";
|
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
|
||||||
import {
|
import {
|
||||||
formatAge,
|
formatAge,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
@@ -116,6 +116,10 @@ export async function statusCommand(
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
|
const [daemon, nodeDaemon] = await Promise.all([
|
||||||
|
getDaemonStatusSummary(),
|
||||||
|
getNodeDaemonStatusSummary(),
|
||||||
|
]);
|
||||||
runtime.log(
|
runtime.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
@@ -134,6 +138,8 @@ export async function statusCommand(
|
|||||||
self: gatewaySelf,
|
self: gatewaySelf,
|
||||||
error: gatewayProbe?.error ?? null,
|
error: gatewayProbe?.error ?? null,
|
||||||
},
|
},
|
||||||
|
gatewayService: daemon,
|
||||||
|
nodeService: nodeDaemon,
|
||||||
agents: agentStatus,
|
agents: agentStatus,
|
||||||
securityAudit,
|
securityAudit,
|
||||||
...(health || usage ? { health, usage } : {}),
|
...(health || usage ? { health, usage } : {}),
|
||||||
@@ -210,12 +216,20 @@ export async function statusCommand(
|
|||||||
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
|
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const daemon = await getDaemonStatusSummary();
|
const [daemon, nodeDaemon] = await Promise.all([
|
||||||
|
getDaemonStatusSummary(),
|
||||||
|
getNodeDaemonStatusSummary(),
|
||||||
|
]);
|
||||||
const daemonValue = (() => {
|
const daemonValue = (() => {
|
||||||
if (daemon.installed === false) return `${daemon.label} not installed`;
|
if (daemon.installed === false) return `${daemon.label} not installed`;
|
||||||
const installedPrefix = daemon.installed === true ? "installed · " : "";
|
const installedPrefix = daemon.installed === true ? "installed · " : "";
|
||||||
return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`;
|
return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`;
|
||||||
})();
|
})();
|
||||||
|
const nodeDaemonValue = (() => {
|
||||||
|
if (nodeDaemon.installed === false) return `${nodeDaemon.label} not installed`;
|
||||||
|
const installedPrefix = nodeDaemon.installed === true ? "installed · " : "";
|
||||||
|
return `${nodeDaemon.label} ${installedPrefix}${nodeDaemon.loadedText}${nodeDaemon.runtimeShort ? ` · ${nodeDaemon.runtimeShort}` : ""}`;
|
||||||
|
})();
|
||||||
|
|
||||||
const defaults = summary.sessions.defaults;
|
const defaults = summary.sessions.defaults;
|
||||||
const defaultCtx = defaults.contextTokens
|
const defaultCtx = defaults.contextTokens
|
||||||
@@ -298,7 +312,8 @@ export async function statusCommand(
|
|||||||
Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine,
|
Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine,
|
||||||
},
|
},
|
||||||
{ Item: "Gateway", Value: gatewayValue },
|
{ Item: "Gateway", Value: gatewayValue },
|
||||||
{ Item: "Daemon", Value: daemonValue },
|
{ Item: "Gateway service", Value: daemonValue },
|
||||||
|
{ Item: "Node service", Value: nodeDaemonValue },
|
||||||
{ Item: "Agents", Value: agentsValue },
|
{ Item: "Agents", Value: agentsValue },
|
||||||
{ Item: "Memory", Value: memoryValue },
|
{ Item: "Memory", Value: memoryValue },
|
||||||
{ Item: "Probes", Value: probesValue },
|
{ Item: "Probes", Value: probesValue },
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
|
import type { GatewayService } from "../daemon/service.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
|
import { resolveNodeService } from "../daemon/node-service.js";
|
||||||
import { formatDaemonRuntimeShort } from "./status.format.js";
|
import { formatDaemonRuntimeShort } from "./status.format.js";
|
||||||
|
|
||||||
export async function getDaemonStatusSummary(): Promise<{
|
type DaemonStatusSummary = {
|
||||||
label: string;
|
label: string;
|
||||||
installed: boolean | null;
|
installed: boolean | null;
|
||||||
loadedText: string;
|
loadedText: string;
|
||||||
runtimeShort: string | null;
|
runtimeShort: string | null;
|
||||||
}> {
|
};
|
||||||
|
|
||||||
|
async function buildDaemonStatusSummary(
|
||||||
|
service: GatewayService,
|
||||||
|
fallbackLabel: string,
|
||||||
|
): Promise<DaemonStatusSummary> {
|
||||||
try {
|
try {
|
||||||
const service = resolveGatewayService();
|
|
||||||
const [loaded, runtime, command] = await Promise.all([
|
const [loaded, runtime, command] = await Promise.all([
|
||||||
service.isLoaded({ env: process.env }).catch(() => false),
|
service.isLoaded({ env: process.env }).catch(() => false),
|
||||||
service.readRuntime(process.env).catch(() => undefined),
|
service.readRuntime(process.env).catch(() => undefined),
|
||||||
@@ -20,10 +26,18 @@ export async function getDaemonStatusSummary(): Promise<{
|
|||||||
return { label: service.label, installed, loadedText, runtimeShort };
|
return { label: service.label, installed, loadedText, runtimeShort };
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
label: "Daemon",
|
label: fallbackLabel,
|
||||||
installed: null,
|
installed: null,
|
||||||
loadedText: "unknown",
|
loadedText: "unknown",
|
||||||
runtimeShort: null,
|
runtimeShort: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getDaemonStatusSummary(): Promise<DaemonStatusSummary> {
|
||||||
|
return await buildDaemonStatusSummary(resolveGatewayService(), "Daemon");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNodeDaemonStatusSummary(): Promise<DaemonStatusSummary> {
|
||||||
|
return await buildDaemonStatusSummary(resolveNodeService(), "Node");
|
||||||
|
}
|
||||||
@@ -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", () => ({
|
vi.mock("../security/audit.js", () => ({
|
||||||
runSecurityAudit: mocks.runSecurityAudit,
|
runSecurityAudit: mocks.runSecurityAudit,
|
||||||
}));
|
}));
|
||||||
@@ -273,6 +286,8 @@ describe("statusCommand", () => {
|
|||||||
expect(payload.sessions.recent[0].flags).toContain("verbose:on");
|
expect(payload.sessions.recent[0].flags).toContain("verbose:on");
|
||||||
expect(payload.securityAudit.summary.critical).toBe(1);
|
expect(payload.securityAudit.summary.critical).toBe(1);
|
||||||
expect(payload.securityAudit.summary.warn).toBe(1);
|
expect(payload.securityAudit.summary.warn).toBe(1);
|
||||||
|
expect(payload.gatewayService.label).toBe("LaunchAgent");
|
||||||
|
expect(payload.nodeService.label).toBe("LaunchAgent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prints formatted lines otherwise", async () => {
|
it("prints formatted lines otherwise", async () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -70,6 +70,12 @@ export type SlackThreadConfig = {
|
|||||||
export type SlackAccountConfig = {
|
export type SlackAccountConfig = {
|
||||||
/** Optional display name for this account (used in CLI/UI lists). */
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
name?: string;
|
name?: string;
|
||||||
|
/** Slack connection mode (socket|http). Default: socket. */
|
||||||
|
mode?: "socket" | "http";
|
||||||
|
/** Slack signing secret (required for HTTP mode). */
|
||||||
|
signingSecret?: string;
|
||||||
|
/** Slack Events API webhook path (default: /slack/events). */
|
||||||
|
webhookPath?: string;
|
||||||
/** Optional provider capability tags used for agent/runtime guidance. */
|
/** Optional provider capability tags used for agent/runtime guidance. */
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
/** Override native command registration for Slack (bool or "auto"). */
|
/** Override native command registration for Slack (bool or "auto"). */
|
||||||
|
|||||||
@@ -258,6 +258,9 @@ export const SlackThreadSchema = z.object({
|
|||||||
|
|
||||||
export const SlackAccountSchema = z.object({
|
export const SlackAccountSchema = z.object({
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
|
mode: z.enum(["socket", "http"]).optional(),
|
||||||
|
signingSecret: z.string().optional(),
|
||||||
|
webhookPath: z.string().optional(),
|
||||||
capabilities: z.array(z.string()).optional(),
|
capabilities: z.array(z.string()).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
commands: ProviderCommandsSchema,
|
commands: ProviderCommandsSchema,
|
||||||
@@ -305,7 +308,35 @@ export const SlackAccountSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const SlackConfigSchema = SlackAccountSchema.extend({
|
export const SlackConfigSchema = SlackAccountSchema.extend({
|
||||||
|
mode: z.enum(["socket", "http"]).optional().default("socket"),
|
||||||
|
signingSecret: z.string().optional(),
|
||||||
|
webhookPath: z.string().optional().default("/slack/events"),
|
||||||
accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(),
|
accounts: z.record(z.string(), SlackAccountSchema.optional()).optional(),
|
||||||
|
}).superRefine((value, ctx) => {
|
||||||
|
const baseMode = value.mode ?? "socket";
|
||||||
|
if (baseMode === "http" && !value.signingSecret) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: 'channels.slack.mode="http" requires channels.slack.signingSecret',
|
||||||
|
path: ["signingSecret"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!value.accounts) return;
|
||||||
|
for (const [accountId, account] of Object.entries(value.accounts)) {
|
||||||
|
if (!account) continue;
|
||||||
|
if (account.enabled === false) continue;
|
||||||
|
const accountMode = account.mode ?? baseMode;
|
||||||
|
if (accountMode !== "http") continue;
|
||||||
|
const accountSecret = account.signingSecret ?? value.signingSecret;
|
||||||
|
if (!accountSecret) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message:
|
||||||
|
'channels.slack.accounts.*.mode="http" requires channels.slack.signingSecret or channels.slack.accounts.*.signingSecret',
|
||||||
|
path: ["accounts", accountId, "signingSecret"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SignalAccountSchemaBase = z.object({
|
export const SignalAccountSchemaBase = z.object({
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,10 @@ import {
|
|||||||
CronUpdateParamsSchema,
|
CronUpdateParamsSchema,
|
||||||
type ExecApprovalsGetParams,
|
type ExecApprovalsGetParams,
|
||||||
ExecApprovalsGetParamsSchema,
|
ExecApprovalsGetParamsSchema,
|
||||||
|
type ExecApprovalsNodeGetParams,
|
||||||
|
ExecApprovalsNodeGetParamsSchema,
|
||||||
|
type ExecApprovalsNodeSetParams,
|
||||||
|
ExecApprovalsNodeSetParamsSchema,
|
||||||
type ExecApprovalsSetParams,
|
type ExecApprovalsSetParams,
|
||||||
ExecApprovalsSetParamsSchema,
|
ExecApprovalsSetParamsSchema,
|
||||||
type ExecApprovalsSnapshot,
|
type ExecApprovalsSnapshot,
|
||||||
@@ -241,6 +245,12 @@ export const validateExecApprovalsGetParams = ajv.compile<ExecApprovalsGetParams
|
|||||||
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
|
export const validateExecApprovalsSetParams = ajv.compile<ExecApprovalsSetParams>(
|
||||||
ExecApprovalsSetParamsSchema,
|
ExecApprovalsSetParamsSchema,
|
||||||
);
|
);
|
||||||
|
export const validateExecApprovalsNodeGetParams = ajv.compile<ExecApprovalsNodeGetParams>(
|
||||||
|
ExecApprovalsNodeGetParamsSchema,
|
||||||
|
);
|
||||||
|
export const validateExecApprovalsNodeSetParams = ajv.compile<ExecApprovalsNodeSetParams>(
|
||||||
|
ExecApprovalsNodeSetParamsSchema,
|
||||||
|
);
|
||||||
export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema);
|
export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema);
|
||||||
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
|
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
|
||||||
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
|
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
|
||||||
|
|||||||
@@ -70,3 +70,19 @@ export const ExecApprovalsSetParamsSchema = Type.Object(
|
|||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const ExecApprovalsNodeGetParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
nodeId: NonEmptyString,
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ExecApprovalsNodeSetParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
nodeId: NonEmptyString,
|
||||||
|
file: ExecApprovalsFileSchema,
|
||||||
|
baseHash: Type.Optional(NonEmptyString),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
@@ -49,6 +49,8 @@ import {
|
|||||||
} from "./cron.js";
|
} from "./cron.js";
|
||||||
import {
|
import {
|
||||||
ExecApprovalsGetParamsSchema,
|
ExecApprovalsGetParamsSchema,
|
||||||
|
ExecApprovalsNodeGetParamsSchema,
|
||||||
|
ExecApprovalsNodeSetParamsSchema,
|
||||||
ExecApprovalsSetParamsSchema,
|
ExecApprovalsSetParamsSchema,
|
||||||
ExecApprovalsSnapshotSchema,
|
ExecApprovalsSnapshotSchema,
|
||||||
} from "./exec-approvals.js";
|
} from "./exec-approvals.js";
|
||||||
@@ -177,6 +179,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
LogsTailResult: LogsTailResultSchema,
|
LogsTailResult: LogsTailResultSchema,
|
||||||
ExecApprovalsGetParams: ExecApprovalsGetParamsSchema,
|
ExecApprovalsGetParams: ExecApprovalsGetParamsSchema,
|
||||||
ExecApprovalsSetParams: ExecApprovalsSetParamsSchema,
|
ExecApprovalsSetParams: ExecApprovalsSetParamsSchema,
|
||||||
|
ExecApprovalsNodeGetParams: ExecApprovalsNodeGetParamsSchema,
|
||||||
|
ExecApprovalsNodeSetParams: ExecApprovalsNodeSetParamsSchema,
|
||||||
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
|
ExecApprovalsSnapshot: ExecApprovalsSnapshotSchema,
|
||||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||||
ChatSendParams: ChatSendParamsSchema,
|
ChatSendParams: ChatSendParamsSchema,
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ import type {
|
|||||||
} from "./cron.js";
|
} from "./cron.js";
|
||||||
import type {
|
import type {
|
||||||
ExecApprovalsGetParamsSchema,
|
ExecApprovalsGetParamsSchema,
|
||||||
|
ExecApprovalsNodeGetParamsSchema,
|
||||||
|
ExecApprovalsNodeSetParamsSchema,
|
||||||
ExecApprovalsSetParamsSchema,
|
ExecApprovalsSetParamsSchema,
|
||||||
ExecApprovalsSnapshotSchema,
|
ExecApprovalsSnapshotSchema,
|
||||||
} from "./exec-approvals.js";
|
} from "./exec-approvals.js";
|
||||||
@@ -170,6 +172,8 @@ export type LogsTailParams = Static<typeof LogsTailParamsSchema>;
|
|||||||
export type LogsTailResult = Static<typeof LogsTailResultSchema>;
|
export type LogsTailResult = Static<typeof LogsTailResultSchema>;
|
||||||
export type ExecApprovalsGetParams = Static<typeof ExecApprovalsGetParamsSchema>;
|
export type ExecApprovalsGetParams = Static<typeof ExecApprovalsGetParamsSchema>;
|
||||||
export type ExecApprovalsSetParams = Static<typeof ExecApprovalsSetParamsSchema>;
|
export type ExecApprovalsSetParams = Static<typeof ExecApprovalsSetParamsSchema>;
|
||||||
|
export type ExecApprovalsNodeGetParams = Static<typeof ExecApprovalsNodeGetParamsSchema>;
|
||||||
|
export type ExecApprovalsNodeSetParams = Static<typeof ExecApprovalsNodeSetParamsSchema>;
|
||||||
export type ExecApprovalsSnapshot = Static<typeof ExecApprovalsSnapshotSchema>;
|
export type ExecApprovalsSnapshot = Static<typeof ExecApprovalsSnapshotSchema>;
|
||||||
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
export type ChatAbortParams = Static<typeof ChatAbortParamsSchema>;
|
||||||
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
|
export type ChatInjectParams = Static<typeof ChatInjectParamsSchema>;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { WebSocketServer } from "ws";
|
|||||||
import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
|
import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js";
|
||||||
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
||||||
import type { createSubsystemLogger } from "../logging.js";
|
import type { createSubsystemLogger } from "../logging.js";
|
||||||
|
import { handleSlackHttpRequest } from "../slack/http/index.js";
|
||||||
import { handleControlUiHttpRequest } from "./control-ui.js";
|
import { handleControlUiHttpRequest } from "./control-ui.js";
|
||||||
import {
|
import {
|
||||||
extractHookToken,
|
extractHookToken,
|
||||||
@@ -208,6 +209,7 @@ export function createGatewayHttpServer(opts: {
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (await handleHooksRequest(req, res)) return;
|
if (await handleHooksRequest(req, res)) return;
|
||||||
|
if (await handleSlackHttpRequest(req, res)) return;
|
||||||
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
|
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
|
||||||
if (openAiChatCompletionsEnabled) {
|
if (openAiChatCompletionsEnabled) {
|
||||||
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return;
|
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const BASE_METHODS = [
|
|||||||
"config.schema",
|
"config.schema",
|
||||||
"exec.approvals.get",
|
"exec.approvals.get",
|
||||||
"exec.approvals.set",
|
"exec.approvals.set",
|
||||||
|
"exec.approvals.node.get",
|
||||||
|
"exec.approvals.node.set",
|
||||||
"wizard.start",
|
"wizard.start",
|
||||||
"wizard.next",
|
"wizard.next",
|
||||||
"wizard.cancel",
|
"wizard.cancel",
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ import {
|
|||||||
errorShape,
|
errorShape,
|
||||||
formatValidationErrors,
|
formatValidationErrors,
|
||||||
validateExecApprovalsGetParams,
|
validateExecApprovalsGetParams,
|
||||||
|
validateExecApprovalsNodeGetParams,
|
||||||
|
validateExecApprovalsNodeSetParams,
|
||||||
validateExecApprovalsSetParams,
|
validateExecApprovalsSetParams,
|
||||||
} from "../protocol/index.js";
|
} from "../protocol/index.js";
|
||||||
|
import { respondUnavailableOnThrow, safeParseJson } from "./nodes.helpers.js";
|
||||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||||
|
|
||||||
function resolveBaseHash(params: unknown): string | null {
|
function resolveBaseHash(params: unknown): string | null {
|
||||||
@@ -152,4 +155,94 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
"exec.approvals.node.get": async ({ params, respond, context }) => {
|
||||||
|
if (!validateExecApprovalsNodeGetParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid exec.approvals.node.get params: ${formatValidationErrors(validateExecApprovalsNodeGetParams.errors)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bridge = context.bridge;
|
||||||
|
if (!bridge) {
|
||||||
|
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { nodeId } = params as { nodeId: string };
|
||||||
|
const id = nodeId.trim();
|
||||||
|
if (!id) {
|
||||||
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await respondUnavailableOnThrow(respond, async () => {
|
||||||
|
const res = await bridge.invoke({
|
||||||
|
nodeId: id,
|
||||||
|
command: "system.execApprovals.get",
|
||||||
|
paramsJSON: "{}",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", {
|
||||||
|
details: { nodeError: res.error ?? null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = safeParseJson(res.payloadJSON ?? null);
|
||||||
|
respond(true, payload, undefined);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"exec.approvals.node.set": async ({ params, respond, context }) => {
|
||||||
|
if (!validateExecApprovalsNodeSetParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid exec.approvals.node.set params: ${formatValidationErrors(validateExecApprovalsNodeSetParams.errors)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bridge = context.bridge;
|
||||||
|
if (!bridge) {
|
||||||
|
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { nodeId, file, baseHash } = params as {
|
||||||
|
nodeId: string;
|
||||||
|
file: ExecApprovalsFile;
|
||||||
|
baseHash?: string;
|
||||||
|
};
|
||||||
|
const id = nodeId.trim();
|
||||||
|
if (!id) {
|
||||||
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await respondUnavailableOnThrow(respond, async () => {
|
||||||
|
const res = await bridge.invoke({
|
||||||
|
nodeId: id,
|
||||||
|
command: "system.execApprovals.set",
|
||||||
|
paramsJSON: JSON.stringify({ file, baseHash }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", {
|
||||||
|
details: { nodeError: res.error ?? null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = safeParseJson(res.payloadJSON ?? null);
|
||||||
|
respond(true, payload, undefined);
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -20,9 +20,7 @@ import type { DedupeEntry } from "./server-shared.js";
|
|||||||
import type { PluginRegistry } from "../plugins/registry.js";
|
import type { PluginRegistry } from "../plugins/registry.js";
|
||||||
|
|
||||||
export async function createGatewayRuntimeState(params: {
|
export async function createGatewayRuntimeState(params: {
|
||||||
cfg: {
|
cfg: import("../config/config.js").ClawdbotConfig;
|
||||||
canvasHost?: { root?: string; enabled?: boolean; liveReload?: boolean };
|
|
||||||
};
|
|
||||||
bindHost: string;
|
bindHost: string;
|
||||||
port: number;
|
port: number;
|
||||||
controlUiEnabled: boolean;
|
controlUiEnabled: boolean;
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import {
|
|||||||
import type { CliDeps } from "../cli/deps.js";
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
import type { loadConfig } from "../config/config.js";
|
import type { loadConfig } from "../config/config.js";
|
||||||
import { startGmailWatcher } from "../hooks/gmail-watcher.js";
|
import { startGmailWatcher } from "../hooks/gmail-watcher.js";
|
||||||
import { clearInternalHooks } from "../hooks/internal-hooks.js";
|
import {
|
||||||
|
clearInternalHooks,
|
||||||
|
createInternalHookEvent,
|
||||||
|
triggerInternalHook,
|
||||||
|
} from "../hooks/internal-hooks.js";
|
||||||
import { loadInternalHooks } from "../hooks/loader.js";
|
import { loadInternalHooks } from "../hooks/loader.js";
|
||||||
import type { loadClawdbotPlugins } from "../plugins/loader.js";
|
import type { loadClawdbotPlugins } from "../plugins/loader.js";
|
||||||
import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js";
|
import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js";
|
||||||
@@ -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;
|
let pluginServices: PluginServicesHandle | null = null;
|
||||||
try {
|
try {
|
||||||
pluginServices = await startPluginServices({
|
pluginServices = await startPluginServices({
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by
|
|||||||
clawdbot hooks enable soul-evil
|
clawdbot hooks enable soul-evil
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 🚀 boot-md
|
||||||
|
|
||||||
|
Runs `BOOT.md` whenever the gateway starts (after channels start).
|
||||||
|
|
||||||
|
**Events**: `gateway:startup`
|
||||||
|
**What it does**: Executes BOOT.md instructions via the agent runner.
|
||||||
|
**Output**: Whatever the instructions request (for example, outbound messages).
|
||||||
|
|
||||||
|
**Enable**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot hooks enable boot-md
|
||||||
|
```
|
||||||
|
|
||||||
## Hook Structure
|
## Hook Structure
|
||||||
|
|
||||||
Each hook is a directory containing:
|
Each hook is a directory containing:
|
||||||
@@ -156,6 +170,7 @@ Currently supported events:
|
|||||||
- **command:reset**: `/reset` command
|
- **command:reset**: `/reset` command
|
||||||
- **command:stop**: `/stop` command
|
- **command:stop**: `/stop` command
|
||||||
- **agent:bootstrap**: Before workspace bootstrap files are injected
|
- **agent:bootstrap**: Before workspace bootstrap files are injected
|
||||||
|
- **gateway:startup**: Gateway startup (after channels start)
|
||||||
|
|
||||||
More event types coming soon (session lifecycle, agent errors, etc.).
|
More event types coming soon (session lifecycle, agent errors, etc.).
|
||||||
|
|
||||||
@@ -165,7 +180,7 @@ Hook handlers receive an `InternalHookEvent` object:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface InternalHookEvent {
|
interface InternalHookEvent {
|
||||||
type: "command" | "session" | "agent";
|
type: "command" | "session" | "agent" | "gateway";
|
||||||
action: string; // e.g., 'new', 'reset', 'stop'
|
action: string; // e.g., 'new', 'reset', 'stop'
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
context: Record<string, unknown>;
|
context: Record<string, unknown>;
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
import type { WorkspaceBootstrapFile } from "../agents/workspace.js";
|
import type { WorkspaceBootstrapFile } from "../agents/workspace.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
|
||||||
export type InternalHookEventType = "command" | "session" | "agent";
|
export type InternalHookEventType = "command" | "session" | "agent" | "gateway";
|
||||||
|
|
||||||
export type AgentBootstrapHookContext = {
|
export type AgentBootstrapHookContext = {
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
@@ -26,7 +26,7 @@ export type AgentBootstrapHookEvent = InternalHookEvent & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface InternalHookEvent {
|
export interface InternalHookEvent {
|
||||||
/** The type of event (command, session, agent, etc.) */
|
/** The type of event (command, session, agent, gateway, etc.) */
|
||||||
type: InternalHookEventType;
|
type: InternalHookEventType;
|
||||||
/** The specific action within the type (e.g., 'new', 'reset', 'stop') */
|
/** The specific action within the type (e.g., 'new', 'reset', 'stop') */
|
||||||
action: string;
|
action: string;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -242,7 +242,7 @@ function parseFirstToken(command: string): string | null {
|
|||||||
if (end > 1) return trimmed.slice(1, end);
|
if (end > 1) return trimmed.slice(1, end);
|
||||||
return trimmed.slice(1);
|
return trimmed.slice(1);
|
||||||
}
|
}
|
||||||
const match = /^[^\\s]+/.exec(trimmed);
|
const match = /^[^\s]+/.exec(trimmed);
|
||||||
return match ? match[0] : null;
|
return match ? match[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,8 @@ import {
|
|||||||
import { describeImageWithModel } from "./providers/image.js";
|
import { describeImageWithModel } from "./providers/image.js";
|
||||||
import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js";
|
import { estimateBase64Size, resolveVideoMaxBase64Bytes } from "./video.js";
|
||||||
|
|
||||||
|
const AUTO_AUDIO_PROVIDERS = ["openai", "groq", "deepgram"] as const;
|
||||||
|
|
||||||
export type ActiveMediaModel = {
|
export type ActiveMediaModel = {
|
||||||
provider: string;
|
provider: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
@@ -65,6 +67,29 @@ export function createMediaAttachmentCache(attachments: MediaAttachment[]): Medi
|
|||||||
return new MediaAttachmentCache(attachments);
|
return new MediaAttachmentCache(attachments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveAutoAudioEntries(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
agentDir?: string;
|
||||||
|
providerRegistry: ProviderRegistry;
|
||||||
|
}): Promise<MediaUnderstandingModelConfig[]> {
|
||||||
|
const entries: MediaUnderstandingModelConfig[] = [];
|
||||||
|
for (const providerId of AUTO_AUDIO_PROVIDERS) {
|
||||||
|
const provider = getMediaUnderstandingProvider(providerId, params.providerRegistry);
|
||||||
|
if (!provider?.transcribeAudio) continue;
|
||||||
|
try {
|
||||||
|
await resolveApiKeyForProvider({
|
||||||
|
provider: providerId,
|
||||||
|
cfg: params.cfg,
|
||||||
|
agentDir: params.agentDir,
|
||||||
|
});
|
||||||
|
entries.push({ type: "provider", provider: providerId });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
function trimOutput(text: string, maxChars?: number): string {
|
function trimOutput(text: string, maxChars?: number): string {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!maxChars || trimmed.length <= maxChars) return trimmed;
|
if (!maxChars || trimmed.length <= maxChars) return trimmed;
|
||||||
@@ -561,7 +586,15 @@ export async function runCapability(params: {
|
|||||||
providerRegistry: params.providerRegistry,
|
providerRegistry: params.providerRegistry,
|
||||||
activeModel: params.activeModel,
|
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 {
|
return {
|
||||||
outputs: [],
|
outputs: [],
|
||||||
decision: {
|
decision: {
|
||||||
@@ -583,7 +616,7 @@ export async function runCapability(params: {
|
|||||||
agentDir: params.agentDir,
|
agentDir: params.agentDir,
|
||||||
providerRegistry: params.providerRegistry,
|
providerRegistry: params.providerRegistry,
|
||||||
cache: params.attachments,
|
cache: params.attachments,
|
||||||
entries,
|
entries: resolvedEntries,
|
||||||
config,
|
config,
|
||||||
});
|
});
|
||||||
if (output) outputs.push(output);
|
if (output) outputs.push(output);
|
||||||
|
|||||||
@@ -1406,8 +1406,11 @@ export class MemoryIndexManager {
|
|||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
if (vectorReady && embedding.length > 0) {
|
if (vectorReady && embedding.length > 0) {
|
||||||
|
try {
|
||||||
|
this.db.prepare(`DELETE FROM ${VECTOR_TABLE} WHERE id = ?`).run(id);
|
||||||
|
} catch {}
|
||||||
this.db
|
this.db
|
||||||
.prepare(`INSERT OR REPLACE INTO ${VECTOR_TABLE} (id, embedding) VALUES (?, ?)`)
|
.prepare(`INSERT INTO ${VECTOR_TABLE} (id, embedding) VALUES (?, ?)`)
|
||||||
.run(id, vectorToBlob(embedding));
|
.run(id, vectorToBlob(embedding));
|
||||||
}
|
}
|
||||||
if (this.fts.enabled && this.fts.available) {
|
if (this.fts.enabled && this.fts.available) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+123
-1
@@ -8,10 +8,16 @@ import type { BridgeInvokeRequestFrame } from "../infra/bridge/server/types.js";
|
|||||||
import {
|
import {
|
||||||
addAllowlistEntry,
|
addAllowlistEntry,
|
||||||
matchAllowlist,
|
matchAllowlist,
|
||||||
|
normalizeExecApprovals,
|
||||||
recordAllowlistUse,
|
recordAllowlistUse,
|
||||||
requestExecApprovalViaSocket,
|
requestExecApprovalViaSocket,
|
||||||
resolveCommandResolution,
|
resolveCommandResolution,
|
||||||
resolveExecApprovals,
|
resolveExecApprovals,
|
||||||
|
ensureExecApprovals,
|
||||||
|
readExecApprovalsSnapshot,
|
||||||
|
resolveExecApprovalsSocketPath,
|
||||||
|
saveExecApprovals,
|
||||||
|
type ExecApprovalsFile,
|
||||||
} from "../infra/exec-approvals.js";
|
} from "../infra/exec-approvals.js";
|
||||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
@@ -43,6 +49,18 @@ type SystemWhichParams = {
|
|||||||
bins: string[];
|
bins: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SystemExecApprovalsSetParams = {
|
||||||
|
file: ExecApprovalsFile;
|
||||||
|
baseHash?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExecApprovalsSnapshot = {
|
||||||
|
path: string;
|
||||||
|
exists: boolean;
|
||||||
|
hash: string;
|
||||||
|
file: ExecApprovalsFile;
|
||||||
|
};
|
||||||
|
|
||||||
type RunResult = {
|
type RunResult = {
|
||||||
exitCode?: number;
|
exitCode?: number;
|
||||||
timedOut: boolean;
|
timedOut: boolean;
|
||||||
@@ -143,6 +161,31 @@ function truncateOutput(raw: string, maxChars: number): { text: string; truncate
|
|||||||
return { text: `... (truncated) ${raw.slice(raw.length - maxChars)}`, truncated: true };
|
return { text: `... (truncated) ${raw.slice(raw.length - maxChars)}`, truncated: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function redactExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
|
||||||
|
const socketPath = file.socket?.path?.trim();
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
socket: socketPath ? { path: socketPath } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireExecApprovalsBaseHash(
|
||||||
|
params: SystemExecApprovalsSetParams,
|
||||||
|
snapshot: ExecApprovalsSnapshot,
|
||||||
|
) {
|
||||||
|
if (!snapshot.exists) return;
|
||||||
|
if (!snapshot.hash) {
|
||||||
|
throw new Error("INVALID_REQUEST: exec approvals base hash unavailable; reload and retry");
|
||||||
|
}
|
||||||
|
const baseHash = typeof params.baseHash === "string" ? params.baseHash.trim() : "";
|
||||||
|
if (!baseHash) {
|
||||||
|
throw new Error("INVALID_REQUEST: exec approvals base hash required; reload and retry");
|
||||||
|
}
|
||||||
|
if (baseHash !== snapshot.hash) {
|
||||||
|
throw new Error("INVALID_REQUEST: exec approvals changed; reload and retry");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function runCommand(
|
async function runCommand(
|
||||||
argv: string[],
|
argv: string[],
|
||||||
cwd: string | undefined,
|
cwd: string | undefined,
|
||||||
@@ -306,7 +349,12 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise<void> {
|
|||||||
deviceFamily: os.platform(),
|
deviceFamily: os.platform(),
|
||||||
modelIdentifier: os.hostname(),
|
modelIdentifier: os.hostname(),
|
||||||
caps: ["system"],
|
caps: ["system"],
|
||||||
commands: ["system.run", "system.which"],
|
commands: [
|
||||||
|
"system.run",
|
||||||
|
"system.which",
|
||||||
|
"system.execApprovals.get",
|
||||||
|
"system.execApprovals.set",
|
||||||
|
],
|
||||||
onPairToken: async (token) => {
|
onPairToken: async (token) => {
|
||||||
config.token = token;
|
config.token = token;
|
||||||
await saveNodeHostConfig(config);
|
await saveNodeHostConfig(config);
|
||||||
@@ -355,6 +403,80 @@ async function handleInvoke(
|
|||||||
skillBins: SkillBinsCache,
|
skillBins: SkillBinsCache,
|
||||||
) {
|
) {
|
||||||
const command = String(frame.command ?? "");
|
const command = String(frame.command ?? "");
|
||||||
|
if (command === "system.execApprovals.get") {
|
||||||
|
try {
|
||||||
|
ensureExecApprovals();
|
||||||
|
const snapshot = readExecApprovalsSnapshot();
|
||||||
|
const payload: ExecApprovalsSnapshot = {
|
||||||
|
path: snapshot.path,
|
||||||
|
exists: snapshot.exists,
|
||||||
|
hash: snapshot.hash,
|
||||||
|
file: redactExecApprovals(snapshot.file),
|
||||||
|
};
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: { code: "INVALID_REQUEST", message: String(err) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "system.execApprovals.set") {
|
||||||
|
try {
|
||||||
|
const params = decodeParams<SystemExecApprovalsSetParams>(frame.paramsJSON);
|
||||||
|
if (!params.file || typeof params.file !== "object") {
|
||||||
|
throw new Error("INVALID_REQUEST: exec approvals file required");
|
||||||
|
}
|
||||||
|
ensureExecApprovals();
|
||||||
|
const snapshot = readExecApprovalsSnapshot();
|
||||||
|
requireExecApprovalsBaseHash(params, snapshot);
|
||||||
|
const normalized = normalizeExecApprovals(params.file);
|
||||||
|
const currentSocketPath = snapshot.file.socket?.path?.trim();
|
||||||
|
const currentToken = snapshot.file.socket?.token?.trim();
|
||||||
|
const socketPath =
|
||||||
|
normalized.socket?.path?.trim() ?? currentSocketPath ?? resolveExecApprovalsSocketPath();
|
||||||
|
const token = normalized.socket?.token?.trim() ?? currentToken ?? "";
|
||||||
|
const next: ExecApprovalsFile = {
|
||||||
|
...normalized,
|
||||||
|
socket: {
|
||||||
|
path: socketPath,
|
||||||
|
token,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
saveExecApprovals(next);
|
||||||
|
const nextSnapshot = readExecApprovalsSnapshot();
|
||||||
|
const payload: ExecApprovalsSnapshot = {
|
||||||
|
path: nextSnapshot.path,
|
||||||
|
exists: nextSnapshot.exists,
|
||||||
|
hash: nextSnapshot.hash,
|
||||||
|
file: redactExecApprovals(nextSnapshot.file),
|
||||||
|
};
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: true,
|
||||||
|
payloadJSON: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
client.sendInvokeResponse({
|
||||||
|
type: "invoke-res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: false,
|
||||||
|
error: { code: "INVALID_REQUEST", message: String(err) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (command === "system.which") {
|
if (command === "system.which") {
|
||||||
try {
|
try {
|
||||||
const params = decodeParams<SystemWhichParams>(frame.paramsJSON);
|
const params = decodeParams<SystemWhichParams>(frame.paramsJSON);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./registry.js";
|
||||||
@@ -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"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||||
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
|
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
|
||||||
@@ -14,6 +16,7 @@ import { resolveSlackAccount } from "../accounts.js";
|
|||||||
import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
|
import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
|
||||||
import { resolveSlackUserAllowlist } from "../resolve-users.js";
|
import { resolveSlackUserAllowlist } from "../resolve-users.js";
|
||||||
import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js";
|
import { resolveSlackAppToken, resolveSlackBotToken } from "../token.js";
|
||||||
|
import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js";
|
||||||
import { resolveSlackSlashCommandConfig } from "./commands.js";
|
import { resolveSlackSlashCommandConfig } from "./commands.js";
|
||||||
import { createSlackMonitorContext } from "./context.js";
|
import { createSlackMonitorContext } from "./context.js";
|
||||||
import { registerSlackMonitorEvents } from "./events.js";
|
import { registerSlackMonitorEvents } from "./events.js";
|
||||||
@@ -23,6 +26,8 @@ import { normalizeAllowList } from "./allow-list.js";
|
|||||||
|
|
||||||
import type { MonitorSlackOpts } from "./types.js";
|
import type { MonitorSlackOpts } from "./types.js";
|
||||||
|
|
||||||
|
const { App, HTTPReceiver } = SlackBolt;
|
||||||
|
|
||||||
function parseApiAppIdFromAppToken(raw?: string) {
|
function parseApiAppIdFromAppToken(raw?: string) {
|
||||||
const token = raw?.trim();
|
const token = raw?.trim();
|
||||||
if (!token) return undefined;
|
if (!token) return undefined;
|
||||||
@@ -49,11 +54,21 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
const sessionScope: SessionScope = sessionCfg?.scope ?? "per-sender";
|
const sessionScope: SessionScope = sessionCfg?.scope ?? "per-sender";
|
||||||
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
|
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
|
||||||
|
|
||||||
|
const slackMode = opts.mode ?? account.config.mode ?? "socket";
|
||||||
|
const slackWebhookPath = normalizeSlackWebhookPath(account.config.webhookPath);
|
||||||
|
const signingSecret = account.config.signingSecret?.trim();
|
||||||
const botToken = resolveSlackBotToken(opts.botToken ?? account.botToken);
|
const botToken = resolveSlackBotToken(opts.botToken ?? account.botToken);
|
||||||
const appToken = resolveSlackAppToken(opts.appToken ?? account.appToken);
|
const appToken = resolveSlackAppToken(opts.appToken ?? account.appToken);
|
||||||
if (!botToken || !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(
|
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 mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||||
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
||||||
|
|
||||||
const app = new App({
|
const receiver =
|
||||||
token: botToken,
|
slackMode === "http"
|
||||||
appToken,
|
? new HTTPReceiver({
|
||||||
socketMode: true,
|
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 botUserId = "";
|
||||||
let teamId = "";
|
let teamId = "";
|
||||||
@@ -164,6 +200,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
|
|
||||||
registerSlackMonitorEvents({ ctx, account, handleSlackMessage });
|
registerSlackMonitorEvents({ ctx, account, handleSlackMessage });
|
||||||
registerSlackMonitorSlashCommands({ ctx, account });
|
registerSlackMonitorSlashCommands({ ctx, account });
|
||||||
|
if (slackMode === "http" && slackHttpHandler) {
|
||||||
|
unregisterHttpHandler = registerSlackHttpHandler({
|
||||||
|
path: slackWebhookPath,
|
||||||
|
handler: slackHttpHandler,
|
||||||
|
log: runtime.log,
|
||||||
|
accountId: account.accountId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (resolveToken) {
|
if (resolveToken) {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -284,13 +328,17 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stopOnAbort = () => {
|
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 });
|
opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await app.start();
|
if (slackMode === "socket") {
|
||||||
runtime.log?.("slack socket mode connected");
|
await app.start();
|
||||||
|
runtime.log?.("slack socket mode connected");
|
||||||
|
} else {
|
||||||
|
runtime.log?.(`slack http mode listening at ${slackWebhookPath}`);
|
||||||
|
}
|
||||||
if (opts.abortSignal?.aborted) return;
|
if (opts.abortSignal?.aborted) return;
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
opts.abortSignal?.addEventListener("abort", () => resolve(), {
|
opts.abortSignal?.addEventListener("abort", () => resolve(), {
|
||||||
@@ -299,6 +347,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
opts.abortSignal?.removeEventListener("abort", stopOnAbort);
|
opts.abortSignal?.removeEventListener("abort", stopOnAbort);
|
||||||
|
unregisterHttpHandler?.();
|
||||||
await app.stop().catch(() => undefined);
|
await app.stop().catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ export type MonitorSlackOpts = {
|
|||||||
botToken?: string;
|
botToken?: string;
|
||||||
appToken?: string;
|
appToken?: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
mode?: "socket" | "http";
|
||||||
config?: ClawdbotConfig;
|
config?: ClawdbotConfig;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
|
|||||||
+24
-2
@@ -310,9 +310,17 @@ export function renderApp(state: AppViewState) {
|
|||||||
execApprovalsSnapshot: state.execApprovalsSnapshot,
|
execApprovalsSnapshot: state.execApprovalsSnapshot,
|
||||||
execApprovalsForm: state.execApprovalsForm,
|
execApprovalsForm: state.execApprovalsForm,
|
||||||
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
|
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
|
||||||
|
execApprovalsTarget: state.execApprovalsTarget,
|
||||||
|
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
|
||||||
onRefresh: () => loadNodes(state),
|
onRefresh: () => loadNodes(state),
|
||||||
onLoadConfig: () => loadConfig(state),
|
onLoadConfig: () => loadConfig(state),
|
||||||
onLoadExecApprovals: () => 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) => {
|
onBindDefault: (nodeId) => {
|
||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
updateConfigFormValue(state, ["tools", "exec", "node"], nodeId);
|
||||||
@@ -329,6 +337,14 @@ export function renderApp(state: AppViewState) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSaveBindings: () => saveConfig(state),
|
onSaveBindings: () => saveConfig(state),
|
||||||
|
onExecApprovalsTargetChange: (kind, nodeId) => {
|
||||||
|
state.execApprovalsTarget = kind;
|
||||||
|
state.execApprovalsTargetNodeId = nodeId;
|
||||||
|
state.execApprovalsSnapshot = null;
|
||||||
|
state.execApprovalsForm = null;
|
||||||
|
state.execApprovalsDirty = false;
|
||||||
|
state.execApprovalsSelectedAgent = null;
|
||||||
|
},
|
||||||
onExecApprovalsSelectAgent: (agentId) => {
|
onExecApprovalsSelectAgent: (agentId) => {
|
||||||
state.execApprovalsSelectedAgent = agentId;
|
state.execApprovalsSelectedAgent = agentId;
|
||||||
},
|
},
|
||||||
@@ -336,7 +352,13 @@ export function renderApp(state: AppViewState) {
|
|||||||
updateExecApprovalsFormValue(state, path, value),
|
updateExecApprovalsFormValue(state, path, value),
|
||||||
onExecApprovalsRemove: (path) =>
|
onExecApprovalsRemove: (path) =>
|
||||||
removeExecApprovalsFormValue(state, 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}
|
: nothing}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export type AppViewState = {
|
|||||||
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
|
execApprovalsSnapshot: ExecApprovalsSnapshot | null;
|
||||||
execApprovalsForm: ExecApprovalsFile | null;
|
execApprovalsForm: ExecApprovalsFile | null;
|
||||||
execApprovalsSelectedAgent: string | null;
|
execApprovalsSelectedAgent: string | null;
|
||||||
|
execApprovalsTarget: "gateway" | "node";
|
||||||
|
execApprovalsTargetNodeId: string | null;
|
||||||
configLoading: boolean;
|
configLoading: boolean;
|
||||||
configRaw: string;
|
configRaw: string;
|
||||||
configValid: boolean | null;
|
configValid: boolean | null;
|
||||||
|
|||||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user