Compare commits

..
Author SHA1 Message Date
Peter Steinberger 206071be69 fix: answer telegram callback queries immediately (#1349) (thanks @siddhantjain) 2026-01-21 03:51:22 +00:00
Siddhant Jain 98f399b7fd fix(telegram): answer callback queries immediately to prevent retries
Telegram retries callback queries if they aren't acknowledged quickly.
Previously, answerCallbackQuery was called in a finally block AFTER
processing, which could take several seconds for agent responses.

This change moves answerCallbackQuery to immediately after basic
validation, before any processing begins. This prevents Telegram
from sending duplicate callbacks while the agent is thinking.

Fixes duplicate callback handling when agent processing is slow.
2026-01-21 03:46:38 +00:00
Peter Steinberger 38cc2a3288 fix: guard media status parts filter 2026-01-21 03:43:42 +00:00
Peter Steinberger 28c49db494 fix: default exec security to allowlist 2026-01-21 03:40:27 +00:00
Peter Steinberger 026e6c4df4 fix: restore bundled plugin discovery 2026-01-21 03:40:05 +00:00
Peter Steinberger 841dfc693e style: flatten approvals allowlist age 2026-01-21 03:36:54 +00:00
Peter Steinberger f38278d919 style: collapse approvals allowlist age 2026-01-21 03:36:54 +00:00
Peter Steinberger 9545edcb49 style: tighten approvals allowlist age 2026-01-21 03:36:54 +00:00
Peter Steinberger f3554a3ad8 feat: render approvals get as table 2026-01-21 03:36:54 +00:00
Peter Steinberger b30359e9cd fix(macos): ignore launchd token in remote mode 2026-01-21 03:34:51 +00:00
Peter Steinberger d3898ee8df test(macos): cover gateway host resolution 2026-01-21 03:34:51 +00:00
Peter Steinberger d1c2fc4bc8 fix: hide empty status rows 2026-01-21 03:32:16 +00:00
Peter Steinberger a5a3ab958f fix: skip bundled plugin discovery in tests 2026-01-21 03:29:03 +00:00
Peter Steinberger 165861e78d feat: add approvals allowlist examples 2026-01-21 03:27:50 +00:00
Peter Steinberger e7c355ee85 feat: group service help examples by submenu 2026-01-21 03:27:50 +00:00
Peter Steinberger 052a58f2f7 style: tighten nodes pending row 2026-01-21 03:27:50 +00:00
Peter Steinberger 5ff56ffb4e style: compress nodes pending age 2026-01-21 03:27:50 +00:00
Peter Steinberger 9a3dd626a1 style: format nodes pending row 2026-01-21 03:27:50 +00:00
Peter Steinberger aae4b2952f feat: polish nodes cli output 2026-01-21 03:27:50 +00:00
Peter Steinberger aec622fe63 chore: remove fresh dist log 2026-01-21 03:13:50 +00:00
Peter Steinberger e6287270d9 feat: render nodes status as table 2026-01-21 03:11:27 +00:00
40 changed files with 593 additions and 198 deletions

No files matched your search

+2
View File
@@ -24,6 +24,7 @@ Docs: https://docs.clawd.bot
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
### Fixes
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
- Telegram: answer callback queries immediately to prevent retries. (#1349) — thanks @siddhantjain.
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
@@ -37,6 +38,7 @@ Docs: https://docs.clawd.bot
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood.
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
- UI: preserve ordered list numbering in chat markdown. (#1341) — thanks @bradleypriest.
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) — thanks @ameno-.
@@ -263,7 +263,8 @@ extension ConfigSettings {
let subsections = self.resolveSubsections(for: section)
let resolved: (ConfigSchemaNode, ConfigPath) = {
if case let .key(key) = subsection,
let match = subsections.first(where: { $0.key == key }) {
let match = subsections.first(where: { $0.key == key })
{
return (match.node, match.path)
}
return (self.resolvedSchemaNode(section.node), defaultPath)
@@ -319,7 +319,7 @@ private enum ExecHostExecutor {
security: context.security,
allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow),
approvalDecision == nil
approvalDecision == nil
{
let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
@@ -172,6 +172,10 @@ actor GatewayEndpointStore {
return configToken
}
if isRemote {
return nil
}
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty
{
@@ -634,11 +638,12 @@ extension GatewayEndpointStore {
static func _testResolveLocalGatewayHost(
bindMode: String?,
tailscaleIP: String?) -> String
tailscaleIP: String?,
customBindHost: String? = nil) -> String
{
self.resolveLocalGatewayHost(
bindMode: bindMode,
customBindHost: nil,
customBindHost: customBindHost,
tailscaleIP: tailscaleIP)
}
}
@@ -10,7 +10,7 @@ struct ConnectOptions {
var token: String?
var password: String?
var mode: String?
var timeoutMs: Int = 15_000
var timeoutMs: Int = 15000
var json: Bool = false
var probe: Bool = false
var clientId: String = "clawdbot-macos"
@@ -22,53 +22,43 @@ struct ConnectOptions {
static func parse(_ args: [String]) -> ConnectOptions {
var opts = ConnectOptions()
let flagHandlers: [String: (inout ConnectOptions) -> Void] = [
"-h": { $0.help = true },
"--help": { $0.help = true },
"--json": { $0.json = true },
"--probe": { $0.probe = true },
]
let valueHandlers: [String: (inout ConnectOptions, String) -> Void] = [
"--url": { $0.url = $1 },
"--token": { $0.token = $1 },
"--password": { $0.password = $1 },
"--mode": { $0.mode = $1 },
"--timeout": { opts, raw in
if let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) {
opts.timeoutMs = max(250, parsed)
}
},
"--client-id": { $0.clientId = $1 },
"--client-mode": { $0.clientMode = $1 },
"--display-name": { $0.displayName = $1 },
"--role": { $0.role = $1 },
"--scopes": { opts, raw in
opts.scopes = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
},
]
var i = 0
while i < args.count {
let arg = args[i]
switch arg {
case "-h", "--help":
opts.help = true
case "--json":
opts.json = true
case "--probe":
opts.probe = true
case "--url":
opts.url = self.nextValue(args, index: &i)
case "--token":
opts.token = self.nextValue(args, index: &i)
case "--password":
opts.password = self.nextValue(args, index: &i)
case "--mode":
if let value = self.nextValue(args, index: &i) {
opts.mode = value
}
case "--timeout":
if let raw = self.nextValue(args, index: &i),
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines))
{
opts.timeoutMs = max(250, parsed)
}
case "--client-id":
if let value = self.nextValue(args, index: &i) {
opts.clientId = value
}
case "--client-mode":
if let value = self.nextValue(args, index: &i) {
opts.clientMode = value
}
case "--display-name":
opts.displayName = self.nextValue(args, index: &i)
case "--role":
if let value = self.nextValue(args, index: &i) {
opts.role = value
}
case "--scopes":
if let value = self.nextValue(args, index: &i) {
opts.scopes = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
default:
break
if let handler = flagHandlers[arg] {
handler(&opts)
i += 1
continue
}
if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) {
handler(&opts, value)
i += 1
continue
}
i += 1
}
@@ -257,8 +247,12 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
if resolvedMode == "remote" {
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
!raw.isEmpty
else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
}
guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"])
@@ -273,7 +267,10 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
let port = config.port ?? 18789
let host = resolveLocalHost(bind: config.bind)
guard let url = URL(string: "ws://\(host):\(port)") else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"])
}
return GatewayEndpoint(
url: url,
@@ -283,7 +280,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
}
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? {
return try? resolveGatewayEndpoint(opts: opts, config: config)
try? resolveGatewayEndpoint(opts: opts, config: config)
}
private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
@@ -3,6 +3,8 @@ import SwiftUI
import Testing
@testable import Clawdbot
private typealias SnapshotAnyCodable = Clawdbot.AnyCodable
@Suite(.serialized)
@MainActor
struct ChannelsSettingsSmokeTests {
@@ -17,8 +19,11 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal",
"imessage": "iMessage",
],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [
"whatsapp": AnyCodable([
"whatsapp": SnapshotAnyCodable([
"configured": true,
"linked": true,
"authAgeMs": 86_400_000,
@@ -37,7 +42,7 @@ struct ChannelsSettingsSmokeTests {
"lastEventAt": 1_700_000_060_000,
"lastError": "needs login",
]),
"telegram": AnyCodable([
"telegram": SnapshotAnyCodable([
"configured": true,
"tokenSource": "env",
"running": true,
@@ -52,7 +57,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_050_000,
]),
"signal": AnyCodable([
"signal": SnapshotAnyCodable([
"configured": true,
"baseUrl": "http://127.0.0.1:8080",
"running": true,
@@ -65,7 +70,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_050_000,
]),
"imessage": AnyCodable([
"imessage": SnapshotAnyCodable([
"configured": false,
"running": false,
"lastError": "not configured",
@@ -100,15 +105,18 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal",
"imessage": "iMessage",
],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [
"whatsapp": AnyCodable([
"whatsapp": SnapshotAnyCodable([
"configured": false,
"linked": false,
"running": false,
"connected": false,
"reconnectAttempts": 0,
]),
"telegram": AnyCodable([
"telegram": SnapshotAnyCodable([
"configured": false,
"running": false,
"lastError": "bot missing",
@@ -120,7 +128,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_100_000,
]),
"signal": AnyCodable([
"signal": SnapshotAnyCodable([
"configured": false,
"baseUrl": "http://127.0.0.1:8080",
"running": false,
@@ -133,7 +141,7 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_200_000,
]),
"imessage": AnyCodable([
"imessage": SnapshotAnyCodable([
"configured": false,
"running": false,
"lastError": "not configured",
@@ -11,16 +11,19 @@ struct CronJobEditorSmokeTests {
}
@Test func cronJobEditorBuildsBodyForNewJob() {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
_ = view.body
}
@Test func cronJobEditorBuildsBodyForExistingJob() {
let channelsStore = ChannelsStore(isPreview: true)
let job = CronJob(
id: "job-1",
agentId: "ops",
@@ -54,31 +57,36 @@ struct CronJobEditorSmokeTests {
job: job,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
_ = view.body
}
@Test func cronJobEditorExercisesBuilders() {
let channelsStore = ChannelsStore(isPreview: true)
var view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
view.exerciseForTesting()
}
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor(
job: nil,
isSaving: .constant(false),
error: .constant(nil),
channelsStore: channelsStore,
onCancel: {},
onSave: { _ in })
var root: [String: Any] = [:]
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
view.applyDeleteAfterRun(to: &root, scheduleKind: CronJobEditor.ScheduleKind.at, deleteAfterRun: true)
let raw = root["deleteAfterRun"] as? Bool
#expect(raw == true)
}
@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation
import os
import Testing
@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation
import os
import Testing
@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation
import os
import Testing
@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation
import os
import Testing
@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation
import Testing
@testable import Clawdbot
@@ -139,4 +139,40 @@ import Testing
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .remote)
}
@Test func resolveLocalGatewayHostPrefersTailnetForAuto() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: "100.64.1.2")
#expect(host == "100.64.1.2")
}
@Test func resolveLocalGatewayHostFallsBackToLoopbackForAuto() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "auto",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostPrefersTailnetForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: "100.64.1.5")
#expect(host == "100.64.1.5")
}
@Test func resolveLocalGatewayHostFallsBackToLoopbackForTailnetMode() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "tailnet",
tailscaleIP: nil)
#expect(host == "127.0.0.1")
}
@Test func resolveLocalGatewayHostUsesCustomBindHost() {
let host = GatewayEndpointStore._testResolveLocalGatewayHost(
bindMode: "custom",
tailscaleIP: "100.64.1.9",
customBindHost: "192.168.1.10")
#expect(host == "192.168.1.10")
}
}
@@ -7,15 +7,17 @@ import Testing
@Suite(.serialized)
struct LowCoverageHelperTests {
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Test func anyCodableHelperAccessors() throws {
let payload: [String: AnyCodable] = [
"title": AnyCodable("Hello"),
"flag": AnyCodable(true),
"count": AnyCodable(3),
"ratio": AnyCodable(1.25),
"list": AnyCodable([AnyCodable("a"), AnyCodable(2)]),
let payload: [String: ProtoAnyCodable] = [
"title": ProtoAnyCodable("Hello"),
"flag": ProtoAnyCodable(true),
"count": ProtoAnyCodable(3),
"ratio": ProtoAnyCodable(1.25),
"list": ProtoAnyCodable([ProtoAnyCodable("a"), ProtoAnyCodable(2)]),
]
let any = AnyCodable(payload)
let any = ProtoAnyCodable(payload)
let dict = try #require(any.dictionaryValue)
#expect(dict["title"]?.stringValue == "Hello")
#expect(dict["flag"]?.boolValue == true)
@@ -76,31 +78,27 @@ struct LowCoverageHelperTests {
#expect(result.stderr.contains("stderr-1999"))
}
@Test func pairedNodesStorePersists() async throws {
let dir = FileManager().temporaryDirectory
.appendingPathComponent("paired-\(UUID().uuidString)", isDirectory: true)
try FileManager().createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("nodes.json")
let store = PairedNodesStore(fileURL: url)
await store.load()
#expect(await store.all().isEmpty)
let node = PairedNode(
@Test func nodeInfoCodableRoundTrip() throws {
let info = NodeInfo(
nodeId: "node-1",
displayName: "Node One",
platform: "macOS",
version: "1.0",
coreVersion: "1.0-core",
uiVersion: "1.0-ui",
deviceFamily: "Mac",
modelIdentifier: "MacBookPro",
token: "token",
createdAtMs: 1,
lastSeenAtMs: nil)
try await store.upsert(node)
#expect(await store.find(nodeId: "node-1")?.displayName == "Node One")
try await store.touchSeen(nodeId: "node-1")
let updated = await store.find(nodeId: "node-1")
#expect(updated?.lastSeenAtMs != nil)
remoteIp: "192.168.1.2",
caps: ["chat"],
commands: ["send"],
permissions: ["send": true],
paired: true,
connected: false)
let data = try JSONEncoder().encode(info)
let decoded = try JSONDecoder().decode(NodeInfo.self, from: data)
#expect(decoded.nodeId == "node-1")
#expect(decoded.isPaired == true)
#expect(decoded.isConnected == false)
}
@Test @MainActor func presenceReporterHelpers() {
@@ -21,6 +21,7 @@ import Testing
features: [:],
snapshot: snapshot,
canvashosturl: nil,
auth: nil,
policy: [:])
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
@@ -3,13 +3,15 @@ import SwiftUI
import Testing
@testable import Clawdbot
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Suite(.serialized)
@MainActor
struct OnboardingWizardStepViewTests {
@Test func noteStepBuilds() {
let step = WizardStep(
id: "step-1",
type: AnyCodable("note"),
type: ProtoAnyCodable("note"),
title: "Welcome",
message: "Hello",
options: nil,
@@ -22,17 +24,17 @@ struct OnboardingWizardStepViewTests {
}
@Test func selectStepBuilds() {
let options: [[String: AnyCodable]] = [
["value": AnyCodable("local"), "label": AnyCodable("Local"), "hint": AnyCodable("This Mac")],
["value": AnyCodable("remote"), "label": AnyCodable("Remote")],
let options: [[String: ProtoAnyCodable]] = [
["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")],
["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")],
]
let step = WizardStep(
id: "step-2",
type: AnyCodable("select"),
type: ProtoAnyCodable("select"),
title: "Mode",
message: "Choose a mode",
options: options,
initialvalue: AnyCodable("local"),
initialvalue: ProtoAnyCodable("local"),
placeholder: nil,
sensitive: nil,
executor: nil)
@@ -15,6 +15,9 @@ extension URLSessionWebSocketTask: WebSocketTasking {}
public struct WebSocketTaskBox: @unchecked Sendable {
public let task: any WebSocketTasking
public init(task: any WebSocketTasking) {
self.task = task
}
public var state: URLSessionTask.State { self.task.state }
+1 -1
View File
@@ -39,7 +39,7 @@ Notes:
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
- `tools.exec.host` (default: `sandbox`)
- `tools.exec.security` (default: `deny`)
- `tools.exec.security` (default: `deny` for sandbox, `allowlist` for gateway + node when unset)
- `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset)
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
-1
View File
@@ -110,7 +110,6 @@ const writeBuildStamp = () => {
};
if (!shouldBuild()) {
logRunner("Skipping build; dist is fresh.");
runNode();
} else {
logRunner("Building TypeScript (dist is stale).");
+6 -3
View File
@@ -400,7 +400,7 @@ export function createExecTool(
host = "gateway";
}
const configuredSecurity = defaults?.security ?? "deny";
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
const requestedSecurity = normalizeExecSecurity(params.security);
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
if (elevatedRequested) {
@@ -447,7 +447,10 @@ export function createExecTool(
applyPathPrepend(env, defaultPathPrepend);
if (host === "node") {
const approvals = resolveExecApprovals(defaults?.agentId);
const approvals = resolveExecApprovals(
defaults?.agentId,
host === "node" ? { security: "allowlist" } : undefined,
);
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
@@ -616,7 +619,7 @@ export function createExecTool(
}
if (host === "gateway") {
const approvals = resolveExecApprovals(defaults?.agentId);
const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" });
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
+1 -3
View File
@@ -184,9 +184,7 @@ export async function buildStatusReply(params: {
const requesterKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey });
const runs = listSubagentRunsForRequester(requesterKey);
const verboseEnabled = resolvedVerboseLevel && resolvedVerboseLevel !== "off";
if (runs.length === 0) {
if (verboseEnabled) subagentsLine = "🤖 Subagents: none";
} else {
if (runs.length > 0) {
const active = runs.filter((entry) => !entry.endedAt);
const done = runs.length - active.length;
if (verboseEnabled) {
+14
View File
@@ -215,6 +215,20 @@ describe("handleCommands subagents", () => {
expect(result.reply?.text).toContain("Subagents: none");
});
it("omits subagent status line when none exist", async () => {
resetSubagentRegistryForTests();
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
session: { mainKey: "main", scope: "per-sender" },
} as ClawdbotConfig;
const params = buildParams("/status", cfg);
params.resolvedVerboseLevel = "on";
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).not.toContain("Subagents:");
});
it("returns help for unknown subagents action", async () => {
resetSubagentRegistryForTests();
const cfg = {
+16
View File
@@ -143,6 +143,22 @@ describe("buildStatusMessage", () => {
expect(normalized).toContain("Media: image ok (openai/gpt-5.2) · audio skipped (maxBytes)");
});
it("omits media line when all decisions are none", () => {
const text = buildStatusMessage({
agent: { model: "anthropic/claude-opus-4-5" },
sessionEntry: { sessionId: "media-none", updatedAt: 0 },
sessionKey: "agent:main:main",
queue: { mode: "none" },
mediaDecisions: [
{ capability: "image", outcome: "no-attachment", attachments: [] },
{ capability: "audio", outcome: "no-attachment", attachments: [] },
{ capability: "video", outcome: "no-attachment", attachments: [] },
],
});
expect(normalizeTestText(text)).not.toContain("Media:");
});
it("does not show elevated label when session explicitly disables it", () => {
const text = buildStatusMessage({
agent: { model: "anthropic/claude-opus-4-5", elevatedDefault: "on" },
+2 -1
View File
@@ -201,8 +201,9 @@ const formatMediaUnderstandingLine = (decisions?: MediaUnderstandingDecision[])
}
return null;
})
.filter(Boolean);
.filter((part): part is string => part != null);
if (parts.length === 0) return null;
if (parts.every((part) => part.endsWith(" none"))) return null;
return `📎 Media: ${parts.join(" · ")}`;
};
+114 -4
View File
@@ -5,7 +5,8 @@ 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 { isRich, theme } from "../terminal/theme.js";
import { renderTable } from "../terminal/table.js";
import { callGatewayFromCli } from "./gateway-rpc.js";
import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js";
import type { NodesRpcOpts } from "./nodes-cli/types.js";
@@ -24,6 +25,17 @@ type ExecApprovalsCliOpts = NodesRpcOpts & {
agent?: string;
};
function formatAge(msAgo: number) {
const s = Math.max(0, Math.floor(msAgo / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
const d = Math.floor(h / 24);
return `${d}d`;
}
async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
@@ -89,6 +101,9 @@ function isEmptyAgent(agent: ExecApprovalsAgent): boolean {
}
export function registerExecApprovalsCli(program: Command) {
const formatExample = (cmd: string, desc: string) =>
` ${theme.command(cmd)}\n ${theme.muted(desc)}`;
const approvals = program
.command("approvals")
.alias("exec-approvals")
@@ -106,8 +121,87 @@ export function registerExecApprovalsCli(program: Command) {
.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);
if (opts.json) {
defaultRuntime.log(JSON.stringify(snapshot));
return;
}
const rich = isRich();
const heading = (text: string) => (rich ? theme.heading(text) : text);
const muted = (text: string) => (rich ? theme.muted(text) : text);
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const file = snapshot.file ?? { version: 1 };
const defaults = file.defaults ?? {};
const defaultsParts = [
defaults.security ? `security=${defaults.security}` : null,
defaults.ask ? `ask=${defaults.ask}` : null,
defaults.askFallback ? `askFallback=${defaults.askFallback}` : null,
typeof defaults.autoAllowSkills === "boolean"
? `autoAllowSkills=${defaults.autoAllowSkills ? "on" : "off"}`
: null,
].filter(Boolean) as string[];
const agents = file.agents ?? {};
const allowlistRows: Array<{ Agent: string; Pattern: string; LastUsed: string }> = [];
const now = Date.now();
for (const [agentId, agent] of Object.entries(agents)) {
const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : [];
for (const entry of allowlist) {
const pattern = entry?.pattern?.trim() ?? "";
if (!pattern) continue;
const lastUsedAt = typeof entry.lastUsedAt === "number" ? entry.lastUsedAt : null;
allowlistRows.push({
Agent: agentId,
Pattern: pattern,
LastUsed: lastUsedAt
? `${formatAge(Math.max(0, now - lastUsedAt))} ago`
: muted("unknown"),
});
}
}
const summaryRows = [
{ Field: "Path", Value: snapshot.path },
{ Field: "Exists", Value: snapshot.exists ? "yes" : "no" },
{ Field: "Hash", Value: snapshot.hash },
{ Field: "Version", Value: String(file.version ?? 1) },
{ Field: "Socket", Value: file.socket?.path ?? "default" },
{ Field: "Defaults", Value: defaultsParts.length > 0 ? defaultsParts.join(", ") : "none" },
{ Field: "Agents", Value: String(Object.keys(agents).length) },
{ Field: "Allowlist", Value: String(allowlistRows.length) },
];
defaultRuntime.log(heading("Approvals"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Field", header: "Field", minWidth: 8 },
{ key: "Value", header: "Value", minWidth: 24, flex: true },
],
rows: summaryRows,
}).trimEnd(),
);
if (allowlistRows.length === 0) {
defaultRuntime.log("");
defaultRuntime.log(muted("No allowlist entries."));
return;
}
defaultRuntime.log("");
defaultRuntime.log(heading("Allowlist"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Agent", header: "Agent", minWidth: 8 },
{ key: "Pattern", header: "Pattern", minWidth: 20, flex: true },
{ key: "LastUsed", header: "Last Used", minWidth: 10 },
],
rows: allowlistRows,
}).trimEnd(),
);
});
nodesCallOpts(getCmd);
@@ -151,7 +245,23 @@ export function registerExecApprovalsCli(program: Command) {
});
nodesCallOpts(setCmd);
const allowlist = approvals.command("allowlist").description("Edit the per-agent allowlist");
const allowlist = approvals
.command("allowlist")
.description("Edit the per-agent allowlist")
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatExample(
'clawdbot approvals allowlist add "~/Projects/**/bin/rg"',
"Allowlist a local binary pattern for the default agent.",
)}\n${formatExample(
'clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"',
"Allowlist on a specific node/agent.",
)}\n${formatExample(
'clawdbot approvals allowlist remove "~/Projects/**/bin/rg"',
"Remove an allowlist pattern.",
)}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/approvals", "docs.clawd.bot/cli/approvals")}\n`,
);
const allowlistAdd = allowlist
.command("add <pattern>")
+17 -2
View File
@@ -1,13 +1,28 @@
import { defaultRuntime } from "../../runtime.js";
import { isRich, theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { unauthorizedHintForMessage } from "./rpc.js";
export function getNodesTheme() {
const rich = isRich();
const color = (fn: (value: string) => string) => (value: string) => (rich ? fn(value) : value);
return {
rich,
heading: color(theme.heading),
ok: color(theme.success),
warn: color(theme.warn),
muted: color(theme.muted),
error: color(theme.error),
};
}
export function runNodesCommand(label: string, action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
const message = String(err);
defaultRuntime.error(`nodes ${label} failed: ${message}`);
const { error, warn } = getNodesTheme();
defaultRuntime.error(error(`nodes ${label} failed: ${message}`));
const hint = unauthorizedHintForMessage(message);
if (hint) defaultRuntime.error(hint);
if (hint) defaultRuntime.error(warn(hint));
defaultRuntime.exit(1);
});
}
+23 -8
View File
@@ -9,9 +9,10 @@ import {
writeBase64ToFile,
} from "../nodes-camera.js";
import { parseDurationMs } from "../parse-duration.js";
import { runNodesCommand } from "./cli-utils.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js";
const parseFacing = (value: string): CameraFacing => {
const v = String(value ?? "")
@@ -52,16 +53,30 @@ export function registerNodesCameraCommands(nodes: Command) {
}
if (devices.length === 0) {
defaultRuntime.log("No cameras reported.");
const { muted } = getNodesTheme();
defaultRuntime.log(muted("No cameras reported."));
return;
}
for (const device of devices) {
const id = typeof device.id === "string" ? device.id : "";
const name = typeof device.name === "string" ? device.name : "Unknown Camera";
const position = typeof device.position === "string" ? device.position : "unspecified";
defaultRuntime.log(`${name} (${position})${id ? ` — ${id}` : ""}`);
}
const { heading, muted } = getNodesTheme();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const rows = devices.map((device) => ({
Name: typeof device.name === "string" ? device.name : "Unknown Camera",
Position: typeof device.position === "string" ? device.position : muted("unspecified"),
ID: typeof device.id === "string" ? device.id : "",
}));
defaultRuntime.log(heading("Cameras"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Name", header: "Name", minWidth: 14, flex: true },
{ key: "Position", header: "Position", minWidth: 10 },
{ key: "ID", header: "ID", minWidth: 10, flex: true },
],
rows,
}).trimEnd(),
);
});
}),
{ timeoutMs: 60_000 },
+25 -7
View File
@@ -6,7 +6,7 @@ import { writeBase64ToFile } from "../nodes-camera.js";
import { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "../nodes-canvas.js";
import { parseTimeoutMs } from "../nodes-run.js";
import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.js";
import { runNodesCommand } from "./cli-utils.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
@@ -121,7 +121,10 @@ export function registerNodesCanvasCommands(nodes: Command) {
params.placement = placement;
}
await invokeCanvas(opts, "canvas.present", params);
if (!opts.json) defaultRuntime.log("canvas present ok");
if (!opts.json) {
const { ok } = getNodesTheme();
defaultRuntime.log(ok("canvas present ok"));
}
});
}),
);
@@ -135,7 +138,10 @@ export function registerNodesCanvasCommands(nodes: Command) {
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("canvas hide", async () => {
await invokeCanvas(opts, "canvas.hide", undefined);
if (!opts.json) defaultRuntime.log("canvas hide ok");
if (!opts.json) {
const { ok } = getNodesTheme();
defaultRuntime.log(ok("canvas hide ok"));
}
});
}),
);
@@ -150,7 +156,10 @@ export function registerNodesCanvasCommands(nodes: Command) {
.action(async (url: string, opts: NodesRpcOpts) => {
await runNodesCommand("canvas navigate", async () => {
await invokeCanvas(opts, "canvas.navigate", { url });
if (!opts.json) defaultRuntime.log("canvas navigate ok");
if (!opts.json) {
const { ok } = getNodesTheme();
defaultRuntime.log(ok("canvas navigate ok"));
}
});
}),
);
@@ -179,7 +188,10 @@ export function registerNodesCanvasCommands(nodes: Command) {
? (raw as { payload?: { result?: string } }).payload
: undefined;
if (payload?.result) defaultRuntime.log(payload.result);
else defaultRuntime.log("canvas eval ok");
else {
const { ok } = getNodesTheme();
defaultRuntime.log(ok("canvas eval ok"));
}
});
}),
);
@@ -213,8 +225,11 @@ export function registerNodesCanvasCommands(nodes: Command) {
}
await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl });
if (!opts.json) {
const { ok } = getNodesTheme();
defaultRuntime.log(
`canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`,
ok(
`canvas a2ui push ok (v0.8, ${messageCount} message${messageCount === 1 ? "" : "s"})`,
),
);
}
});
@@ -230,7 +245,10 @@ export function registerNodesCanvasCommands(nodes: Command) {
.action(async (opts: NodesRpcOpts) => {
await runNodesCommand("canvas a2ui reset", async () => {
await invokeCanvas(opts, "canvas.a2ui.reset", undefined);
if (!opts.json) defaultRuntime.log("canvas a2ui reset ok");
if (!opts.json) {
const { ok } = getNodesTheme();
defaultRuntime.log(ok("canvas a2ui reset ok"));
}
});
}),
);
+11 -5
View File
@@ -2,7 +2,7 @@ import type { Command } from "commander";
import { randomIdempotencyKey } from "../../gateway/call.js";
import { defaultRuntime } from "../../runtime.js";
import { parseEnvPairs, parseTimeoutMs } from "../nodes-run.js";
import { runNodesCommand } from "./cli-utils.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId, unauthorizedHintForMessage } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
@@ -21,7 +21,8 @@ export function registerNodesInvokeCommands(nodes: Command) {
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
const command = String(opts.command ?? "").trim();
if (!nodeId || !command) {
defaultRuntime.error("--node and --command required");
const { error } = getNodesTheme();
defaultRuntime.error(error("--node and --command required"));
defaultRuntime.exit(1);
return;
}
@@ -108,16 +109,21 @@ export function registerNodesInvokeCommands(nodes: Command) {
if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr);
if (timedOut) {
defaultRuntime.error("run timed out");
const { error } = getNodesTheme();
defaultRuntime.error(error("run timed out"));
defaultRuntime.exit(1);
return;
}
if (exitCode !== null && exitCode !== 0) {
const hint = unauthorizedHintForMessage(`${stderr}\n${stdout}`);
if (hint) defaultRuntime.error(hint);
if (hint) {
const { warn } = getNodesTheme();
defaultRuntime.error(warn(hint));
}
}
if (exitCode !== null && exitCode !== 0 && !success) {
defaultRuntime.error(`run exit ${exitCode}`);
const { error } = getNodesTheme();
defaultRuntime.error(error(`run exit ${exitCode}`));
defaultRuntime.exit(1);
return;
}
+3 -2
View File
@@ -1,7 +1,7 @@
import type { Command } from "commander";
import { randomIdempotencyKey } from "../../gateway/call.js";
import { defaultRuntime } from "../../runtime.js";
import { runNodesCommand } from "./cli-utils.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
@@ -49,7 +49,8 @@ export function registerNodesNotifyCommand(nodes: Command) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log("notify ok");
const { ok } = getNodesTheme();
defaultRuntime.log(ok("notify ok"));
});
}),
);
+33 -10
View File
@@ -1,9 +1,10 @@
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { formatAge, parsePairingList } from "./format.js";
import { runNodesCommand } from "./cli-utils.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js";
export function registerNodesPairingCommands(nodes: Command) {
nodesCallOpts(
@@ -19,16 +20,37 @@ export function registerNodesPairingCommands(nodes: Command) {
return;
}
if (pending.length === 0) {
defaultRuntime.log("No pending pairing requests.");
const { muted } = getNodesTheme();
defaultRuntime.log(muted("No pending pairing requests."));
return;
}
for (const r of pending) {
const name = r.displayName || r.nodeId;
const repair = r.isRepair ? " (repair)" : "";
const ip = r.remoteIp ? ` · ${r.remoteIp}` : "";
const age = typeof r.ts === "number" ? ` · ${formatAge(Date.now() - r.ts)} ago` : "";
defaultRuntime.log(`- ${r.requestId}: ${name}${repair}${ip}${age}`);
}
const { heading, warn, muted } = getNodesTheme();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const now = Date.now();
const rows = pending.map((r) => ({
Request: r.requestId,
Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId,
IP: r.remoteIp ?? "",
Requested:
typeof r.ts === "number"
? `${formatAge(Math.max(0, now - r.ts))} ago`
: muted("unknown"),
Repair: r.isRepair ? warn("yes") : "",
}));
defaultRuntime.log(heading("Pending"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Request", header: "Request", minWidth: 8 },
{ key: "Node", header: "Node", minWidth: 14, flex: true },
{ key: "IP", header: "IP", minWidth: 10 },
{ key: "Requested", header: "Requested", minWidth: 12 },
{ key: "Repair", header: "Repair", minWidth: 6 },
],
rows,
}).trimEnd(),
);
});
}),
);
@@ -86,7 +108,8 @@ export function registerNodesPairingCommands(nodes: Command) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`node rename ok: ${nodeId} -> ${name}`);
const { ok } = getNodesTheme();
defaultRuntime.log(ok(`node rename ok: ${nodeId} -> ${name}`));
});
}),
);
+81 -35
View File
@@ -1,11 +1,10 @@
import type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js";
import { formatAge, formatPermissions, parseNodeList, parsePairingList } from "./format.js";
import { runNodesCommand } from "./cli-utils.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js";
import { isRich, theme } from "../../terminal/theme.js";
function formatVersionLabel(raw: string) {
const trimmed = raw.trim();
@@ -56,32 +55,61 @@ export function registerNodesStatusCommands(nodes: Command) {
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
const { ok, warn, muted } = getNodesTheme();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const now = Date.now();
const nodes = parseNodeList(result);
const pairedCount = nodes.filter((n) => Boolean(n.paired)).length;
const connectedCount = nodes.filter((n) => Boolean(n.connected)).length;
defaultRuntime.log(
`Known: ${nodes.length} · Paired: ${pairedCount} · Connected: ${connectedCount}`,
);
for (const n of nodes) {
const name = n.displayName || n.nodeId;
const ip = n.remoteIp ? ` · ${n.remoteIp}` : "";
const device = n.deviceFamily ? ` · device: ${n.deviceFamily}` : "";
const hw = n.modelIdentifier ? ` · hw: ${n.modelIdentifier}` : "";
if (nodes.length === 0) return;
const rows = nodes.map((n) => {
const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId;
const perms = formatPermissions(n.permissions);
const permsText = perms ? ` · perms: ${perms}` : "";
const versions = formatNodeVersions(n);
const versionText = versions ? ` · ${versions}` : "";
const caps =
Array.isArray(n.caps) && n.caps.length > 0
? `[${n.caps.map(String).filter(Boolean).sort().join(",")}]`
: Array.isArray(n.caps)
? "[]"
: "?";
const pairing = n.paired ? "paired" : "unpaired";
defaultRuntime.log(
`- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText}${versionText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`,
);
}
const detailParts = [
n.deviceFamily ? `device: ${n.deviceFamily}` : null,
n.modelIdentifier ? `hw: ${n.modelIdentifier}` : null,
perms ? `perms: ${perms}` : null,
versions,
].filter(Boolean) as string[];
const caps = Array.isArray(n.caps)
? n.caps.map(String).filter(Boolean).sort().join(", ")
: "?";
const paired = n.paired ? ok("paired") : warn("unpaired");
const connected = n.connected ? ok("connected") : muted("disconnected");
const since =
typeof n.connectedAtMs === "number"
? ` (${formatAge(Math.max(0, now - n.connectedAtMs))} ago)`
: "";
return {
Node: name,
ID: n.nodeId,
IP: n.remoteIp ?? "",
Detail: detailParts.join(" · "),
Status: `${paired} · ${connected}${since}`,
Caps: caps,
};
});
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Node", header: "Node", minWidth: 14, flex: true },
{ key: "ID", header: "ID", minWidth: 10 },
{ key: "IP", header: "IP", minWidth: 10 },
{ key: "Detail", header: "Detail", minWidth: 18, flex: true },
{ key: "Status", header: "Status", minWidth: 18 },
{ key: "Caps", header: "Caps", minWidth: 12, flex: true },
],
rows,
}).trimEnd(),
);
});
}),
);
@@ -108,6 +136,7 @@ export function registerNodesStatusCommands(nodes: Command) {
: {};
const displayName = typeof obj.displayName === "string" ? obj.displayName : nodeId;
const connected = Boolean(obj.connected);
const paired = Boolean(obj.paired);
const caps = Array.isArray(obj.caps) ? obj.caps.map(String).filter(Boolean).sort() : null;
const commands = Array.isArray(obj.commands)
? obj.commands.map(String).filter(Boolean).sort()
@@ -125,18 +154,38 @@ export function registerNodesStatusCommands(nodes: Command) {
},
);
const parts: string[] = ["Node:", displayName, nodeId];
if (ip) parts.push(ip);
if (family) parts.push(`device: ${family}`);
if (model) parts.push(`hw: ${model}`);
if (perms) parts.push(`perms: ${perms}`);
if (versions) parts.push(versions);
parts.push(connected ? "connected" : "disconnected");
parts.push(`caps: ${caps ? `[${caps.join(",")}]` : "?"}`);
defaultRuntime.log(parts.join(" · "));
defaultRuntime.log("Commands:");
const { heading, ok, warn, muted } = getNodesTheme();
const status = `${paired ? ok("paired") : warn("unpaired")} · ${
connected ? ok("connected") : muted("disconnected")
}`;
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const rows = [
{ Field: "ID", Value: nodeId },
displayName ? { Field: "Name", Value: displayName } : null,
ip ? { Field: "IP", Value: ip } : null,
family ? { Field: "Device", Value: family } : null,
model ? { Field: "Model", Value: model } : null,
perms ? { Field: "Perms", Value: perms } : null,
versions ? { Field: "Version", Value: versions } : null,
{ Field: "Status", Value: status },
{ Field: "Caps", Value: caps ? caps.join(", ") : "?" },
].filter(Boolean) as Array<{ Field: string; Value: string }>;
defaultRuntime.log(heading("Node"));
defaultRuntime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Field", header: "Field", minWidth: 8 },
{ key: "Value", header: "Value", minWidth: 24, flex: true },
],
rows,
}).trimEnd(),
);
defaultRuntime.log("");
defaultRuntime.log(heading("Commands"));
if (commands.length === 0) {
defaultRuntime.log("- (none reported)");
defaultRuntime.log(muted("- (none reported)"));
return;
}
for (const c of commands) defaultRuntime.log(`- ${c}`);
@@ -157,10 +206,7 @@ export function registerNodesStatusCommands(nodes: Command) {
}
const { pending, paired } = parsePairingList(result);
defaultRuntime.log(`Pending: ${pending.length} · Paired: ${paired.length}`);
const rich = isRich();
const heading = (text: string) => (rich ? theme.heading(text) : text);
const muted = (text: string) => (rich ? theme.muted(text) : text);
const warn = (text: string) => (rich ? theme.warn(text) : text);
const { heading, muted, warn } = getNodesTheme();
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const now = Date.now();
+1
View File
@@ -56,6 +56,7 @@ export type NodeListNode = {
permissions?: Record<string, boolean>;
paired?: boolean;
connected?: boolean;
connectedAtMs?: number;
};
export type PendingRequest = {
+15 -5
View File
@@ -95,10 +95,14 @@ describe("cli program (nodes basics)", () => {
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
expect(output).toContain("Known: 1 · Paired: 1 · Connected: 1");
expect(output).toContain("iOS Node");
expect(output).toContain("Detail");
expect(output).toContain("device: iPad");
expect(output).toContain("hw: iPad16,6");
expect(output).toContain("Status");
expect(output).toContain("paired");
expect(output).toContain("caps: [camera,canvas]");
expect(output).toContain("Caps");
expect(output).toContain("camera");
expect(output).toContain("canvas");
});
it("runs nodes status and shows unpaired nodes", async () => {
@@ -123,12 +127,18 @@ describe("cli program (nodes basics)", () => {
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
expect(output).toContain("Known: 1 · Paired: 0 · Connected: 1");
expect(output).toContain("Peter's Tab S10 Ultra");
expect(output).toContain("Peter's Tab");
expect(output).toContain("S10 Ultra");
expect(output).toContain("Detail");
expect(output).toContain("device: Android");
expect(output).toContain("hw: samsung SM-X926B");
expect(output).toContain("hw: samsung");
expect(output).toContain("SM-X926B");
expect(output).toContain("Status");
expect(output).toContain("unpaired");
expect(output).toContain("connected");
expect(output).toContain("caps: [camera,canvas]");
expect(output).toContain("Caps");
expect(output).toContain("camera");
expect(output).toContain("canvas");
});
it("runs nodes describe and calls node.describe", async () => {
@@ -176,7 +186,7 @@ describe("cli program (nodes basics)", () => {
);
const out = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
expect(out).toContain("Commands:");
expect(out).toContain("Commands");
expect(out).toContain("canvas.eval");
});
+2 -1
View File
@@ -80,7 +80,8 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) {
([cmd, desc]) => ` ${theme.command(cmd)}\n ${theme.muted(desc)}`,
).join("\n");
program.addHelpText("afterAll", () => {
program.addHelpText("afterAll", ({ command }) => {
if (command !== program) return "";
const docs = formatDocsLink("/cli", "docs.clawd.bot/cli");
return `\n${theme.heading("Examples:")}\n${fmtExamples}\n\n${theme.muted("Docs:")} ${docs}\n`;
});
+28 -1
View File
@@ -20,13 +20,40 @@ import {
} from "./node-cli/daemon.js";
export function registerServiceCli(program: Command) {
const formatExample = (cmd: string, desc: string) =>
` ${theme.command(cmd)}\n ${theme.muted(desc)}`;
const formatGroup = (label: string, examples: Array<[string, string]>) =>
`${theme.muted(label)}\n${examples.map(([cmd, desc]) => formatExample(cmd, desc)).join("\n")}`;
const gatewayExamples: Array<[string, string]> = [
["clawdbot service gateway status", "Show gateway service status + probe."],
[
"clawdbot service gateway install --port 18789 --token <token>",
"Install the Gateway service on port 18789.",
],
["clawdbot service gateway restart", "Restart the Gateway service."],
];
const nodeExamples: Array<[string, string]> = [
["clawdbot service node status", "Show node host service status."],
[
"clawdbot service node install --host gateway.local --port 18789 --tls",
"Install the node host service with TLS.",
],
["clawdbot service node restart", "Restart the node host service."],
];
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`,
`\n${theme.heading("Examples:")}\n${formatGroup("Gateway:", gatewayExamples)}\n\n${formatGroup(
"Node:",
nodeExamples,
)}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/service", "docs.clawd.bot/cli/service")}\n`,
);
const gateway = service.command("gateway").description("Manage the Gateway service");
+2
View File
@@ -258,6 +258,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
caps,
commands,
permissions: live?.permissions ?? paired?.permissions,
connectedAtMs: live?.connectedAtMs,
paired: Boolean(paired),
connected: Boolean(live),
};
@@ -320,6 +321,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
caps,
commands,
permissions: live?.permissions,
connectedAtMs: live?.connectedAtMs,
paired: Boolean(paired),
connected: Boolean(live),
},
+35 -12
View File
@@ -188,31 +188,54 @@ export function ensureExecApprovals(): ExecApprovalsFile {
return updated;
}
function normalizeSecurity(value?: ExecSecurity): ExecSecurity {
function normalizeSecurity(value: ExecSecurity | undefined, fallback: ExecSecurity): ExecSecurity {
if (value === "allowlist" || value === "full" || value === "deny") return value;
return DEFAULT_SECURITY;
return fallback;
}
function normalizeAsk(value?: ExecAsk): ExecAsk {
function normalizeAsk(value: ExecAsk | undefined, fallback: ExecAsk): ExecAsk {
if (value === "always" || value === "off" || value === "on-miss") return value;
return DEFAULT_ASK;
return fallback;
}
export function resolveExecApprovals(agentId?: string): ExecApprovalsResolved {
export type ExecApprovalsDefaultOverrides = {
security?: ExecSecurity;
ask?: ExecAsk;
askFallback?: ExecSecurity;
autoAllowSkills?: boolean;
};
export function resolveExecApprovals(
agentId?: string,
overrides?: ExecApprovalsDefaultOverrides,
): ExecApprovalsResolved {
const file = ensureExecApprovals();
const defaults = file.defaults ?? {};
const agentKey = agentId ?? "default";
const agent = file.agents?.[agentKey] ?? {};
const fallbackSecurity = overrides?.security ?? DEFAULT_SECURITY;
const fallbackAsk = overrides?.ask ?? DEFAULT_ASK;
const fallbackAskFallback = overrides?.askFallback ?? DEFAULT_ASK_FALLBACK;
const fallbackAutoAllowSkills = overrides?.autoAllowSkills ?? DEFAULT_AUTO_ALLOW_SKILLS;
const resolvedDefaults: Required<ExecApprovalsDefaults> = {
security: normalizeSecurity(defaults.security),
ask: normalizeAsk(defaults.ask),
askFallback: normalizeSecurity(defaults.askFallback ?? DEFAULT_ASK_FALLBACK),
autoAllowSkills: Boolean(defaults.autoAllowSkills ?? DEFAULT_AUTO_ALLOW_SKILLS),
security: normalizeSecurity(defaults.security, fallbackSecurity),
ask: normalizeAsk(defaults.ask, fallbackAsk),
askFallback: normalizeSecurity(
defaults.askFallback ?? fallbackAskFallback,
fallbackAskFallback,
),
autoAllowSkills: Boolean(defaults.autoAllowSkills ?? fallbackAutoAllowSkills),
};
const resolvedAgent: Required<ExecApprovalsDefaults> = {
security: normalizeSecurity(agent.security ?? resolvedDefaults.security),
ask: normalizeAsk(agent.ask ?? resolvedDefaults.ask),
askFallback: normalizeSecurity(agent.askFallback ?? resolvedDefaults.askFallback),
security: normalizeSecurity(
agent.security ?? resolvedDefaults.security,
resolvedDefaults.security,
),
ask: normalizeAsk(agent.ask ?? resolvedDefaults.ask, resolvedDefaults.ask),
askFallback: normalizeSecurity(
agent.askFallback ?? resolvedDefaults.askFallback,
resolvedDefaults.askFallback,
),
autoAllowSkills: Boolean(agent.autoAllowSkills ?? resolvedDefaults.autoAllowSkills),
};
const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : [];
+1 -1
View File
@@ -545,7 +545,7 @@ async function handleInvoke(
const rawCommand = typeof params.rawCommand === "string" ? params.rawCommand.trim() : "";
const cmdText = rawCommand || formatCommand(argv);
const agentId = params.agentId?.trim() || undefined;
const approvals = resolveExecApprovals(agentId);
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
const security = approvals.agent.security;
const ask = approvals.agent.ask;
const autoAllowSkills = approvals.agent.autoAllowSkills;
+2 -2
View File
@@ -179,6 +179,8 @@ export const registerTelegramHandlers = ({
const callback = ctx.callbackQuery;
if (!callback) return;
if (shouldSkipUpdate(ctx)) return;
// Answer immediately to prevent Telegram from retrying while we process
await bot.api.answerCallbackQuery(callback.id).catch(() => {});
try {
const data = (callback.data ?? "").trim();
const callbackMessage = callback.message;
@@ -323,8 +325,6 @@ export const registerTelegramHandlers = ({
});
} catch (err) {
runtime.error?.(danger(`callback handler failed: ${String(err)}`));
} finally {
await bot.api.answerCallbackQuery(callback.id).catch(() => {});
}
});