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. - UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
### Fixes ### Fixes
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs. - 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: 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. - 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. - 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: 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. - 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. - 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: 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: 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-. - 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 subsections = self.resolveSubsections(for: section)
let resolved: (ConfigSchemaNode, ConfigPath) = { let resolved: (ConfigSchemaNode, ConfigPath) = {
if case let .key(key) = subsection, 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 (match.node, match.path)
} }
return (self.resolvedSchemaNode(section.node), defaultPath) return (self.resolvedSchemaNode(section.node), defaultPath)
@@ -319,7 +319,7 @@ private enum ExecHostExecutor {
security: context.security, security: context.security,
allowlistMatch: context.allowlistMatch, allowlistMatch: context.allowlistMatch,
skillAllow: context.skillAllow), skillAllow: context.skillAllow),
approvalDecision == nil approvalDecision == nil
{ {
let decision = ExecApprovalsPromptPresenter.prompt( let decision = ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest( ExecApprovalPromptRequest(
@@ -172,6 +172,10 @@ actor GatewayEndpointStore {
return configToken return configToken
} }
if isRemote {
return nil
}
if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines), if let token = launchdSnapshot?.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty !token.isEmpty
{ {
@@ -634,11 +638,12 @@ extension GatewayEndpointStore {
static func _testResolveLocalGatewayHost( static func _testResolveLocalGatewayHost(
bindMode: String?, bindMode: String?,
tailscaleIP: String?) -> String tailscaleIP: String?,
customBindHost: String? = nil) -> String
{ {
self.resolveLocalGatewayHost( self.resolveLocalGatewayHost(
bindMode: bindMode, bindMode: bindMode,
customBindHost: nil, customBindHost: customBindHost,
tailscaleIP: tailscaleIP) tailscaleIP: tailscaleIP)
} }
} }
@@ -10,7 +10,7 @@ struct ConnectOptions {
var token: String? var token: String?
var password: String? var password: String?
var mode: String? var mode: String?
var timeoutMs: Int = 15_000 var timeoutMs: Int = 15000
var json: Bool = false var json: Bool = false
var probe: Bool = false var probe: Bool = false
var clientId: String = "clawdbot-macos" var clientId: String = "clawdbot-macos"
@@ -22,53 +22,43 @@ struct ConnectOptions {
static func parse(_ args: [String]) -> ConnectOptions { static func parse(_ args: [String]) -> ConnectOptions {
var opts = 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 var i = 0
while i < args.count { while i < args.count {
let arg = args[i] let arg = args[i]
switch arg { if let handler = flagHandlers[arg] {
case "-h", "--help": handler(&opts)
opts.help = true i += 1
case "--json": continue
opts.json = true }
case "--probe": if let handler = valueHandlers[arg], let value = self.nextValue(args, index: &i) {
opts.probe = true handler(&opts, value)
case "--url": i += 1
opts.url = self.nextValue(args, index: &i) continue
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
} }
i += 1 i += 1
} }
@@ -257,8 +247,12 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
if resolvedMode == "remote" { if resolvedMode == "remote" {
guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines), guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty else { !raw.isEmpty
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"]) else {
throw NSError(
domain: "Gateway",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"])
} }
guard let url = URL(string: raw) else { guard let url = URL(string: raw) else {
throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) 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 port = config.port ?? 18789
let host = resolveLocalHost(bind: config.bind) let host = resolveLocalHost(bind: config.bind)
guard let url = URL(string: "ws://\(host):\(port)") else { 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( return GatewayEndpoint(
url: url, url: url,
@@ -283,7 +280,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig)
} }
private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? { 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? { private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? {
@@ -3,6 +3,8 @@ import SwiftUI
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
private typealias SnapshotAnyCodable = Clawdbot.AnyCodable
@Suite(.serialized) @Suite(.serialized)
@MainActor @MainActor
struct ChannelsSettingsSmokeTests { struct ChannelsSettingsSmokeTests {
@@ -17,8 +19,11 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal", "signal": "Signal",
"imessage": "iMessage", "imessage": "iMessage",
], ],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [ channels: [
"whatsapp": AnyCodable([ "whatsapp": SnapshotAnyCodable([
"configured": true, "configured": true,
"linked": true, "linked": true,
"authAgeMs": 86_400_000, "authAgeMs": 86_400_000,
@@ -37,7 +42,7 @@ struct ChannelsSettingsSmokeTests {
"lastEventAt": 1_700_000_060_000, "lastEventAt": 1_700_000_060_000,
"lastError": "needs login", "lastError": "needs login",
]), ]),
"telegram": AnyCodable([ "telegram": SnapshotAnyCodable([
"configured": true, "configured": true,
"tokenSource": "env", "tokenSource": "env",
"running": true, "running": true,
@@ -52,7 +57,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_050_000, "lastProbeAt": 1_700_000_050_000,
]), ]),
"signal": AnyCodable([ "signal": SnapshotAnyCodable([
"configured": true, "configured": true,
"baseUrl": "http://127.0.0.1:8080", "baseUrl": "http://127.0.0.1:8080",
"running": true, "running": true,
@@ -65,7 +70,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_050_000, "lastProbeAt": 1_700_000_050_000,
]), ]),
"imessage": AnyCodable([ "imessage": SnapshotAnyCodable([
"configured": false, "configured": false,
"running": false, "running": false,
"lastError": "not configured", "lastError": "not configured",
@@ -100,15 +105,18 @@ struct ChannelsSettingsSmokeTests {
"signal": "Signal", "signal": "Signal",
"imessage": "iMessage", "imessage": "iMessage",
], ],
channelDetailLabels: nil,
channelSystemImages: nil,
channelMeta: nil,
channels: [ channels: [
"whatsapp": AnyCodable([ "whatsapp": SnapshotAnyCodable([
"configured": false, "configured": false,
"linked": false, "linked": false,
"running": false, "running": false,
"connected": false, "connected": false,
"reconnectAttempts": 0, "reconnectAttempts": 0,
]), ]),
"telegram": AnyCodable([ "telegram": SnapshotAnyCodable([
"configured": false, "configured": false,
"running": false, "running": false,
"lastError": "bot missing", "lastError": "bot missing",
@@ -120,7 +128,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_100_000, "lastProbeAt": 1_700_000_100_000,
]), ]),
"signal": AnyCodable([ "signal": SnapshotAnyCodable([
"configured": false, "configured": false,
"baseUrl": "http://127.0.0.1:8080", "baseUrl": "http://127.0.0.1:8080",
"running": false, "running": false,
@@ -133,7 +141,7 @@ struct ChannelsSettingsSmokeTests {
], ],
"lastProbeAt": 1_700_000_200_000, "lastProbeAt": 1_700_000_200_000,
]), ]),
"imessage": AnyCodable([ "imessage": SnapshotAnyCodable([
"configured": false, "configured": false,
"running": false, "running": false,
"lastError": "not configured", "lastError": "not configured",
@@ -11,16 +11,19 @@ struct CronJobEditorSmokeTests {
} }
@Test func cronJobEditorBuildsBodyForNewJob() { @Test func cronJobEditorBuildsBodyForNewJob() {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor( let view = CronJobEditor(
job: nil, job: nil,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
_ = view.body _ = view.body
} }
@Test func cronJobEditorBuildsBodyForExistingJob() { @Test func cronJobEditorBuildsBodyForExistingJob() {
let channelsStore = ChannelsStore(isPreview: true)
let job = CronJob( let job = CronJob(
id: "job-1", id: "job-1",
agentId: "ops", agentId: "ops",
@@ -54,31 +57,36 @@ struct CronJobEditorSmokeTests {
job: job, job: job,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
_ = view.body _ = view.body
} }
@Test func cronJobEditorExercisesBuilders() { @Test func cronJobEditorExercisesBuilders() {
let channelsStore = ChannelsStore(isPreview: true)
var view = CronJobEditor( var view = CronJobEditor(
job: nil, job: nil,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
view.exerciseForTesting() view.exerciseForTesting()
} }
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws { @Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
let channelsStore = ChannelsStore(isPreview: true)
let view = CronJobEditor( let view = CronJobEditor(
job: nil, job: nil,
isSaving: .constant(false), isSaving: .constant(false),
error: .constant(nil), error: .constant(nil),
channelsStore: channelsStore,
onCancel: {}, onCancel: {},
onSave: { _ in }) onSave: { _ in })
var root: [String: Any] = [:] 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 let raw = root["deleteAfterRun"] as? Bool
#expect(raw == true) #expect(raw == true)
} }
@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation import Foundation
import os import os
import Testing import Testing
@@ -1,3 +1,4 @@
import ClawdbotKit
import Foundation import Foundation
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
@@ -139,4 +139,40 @@ import Testing
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults) let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
#expect(resolved.mode == .remote) #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) @Suite(.serialized)
struct LowCoverageHelperTests { struct LowCoverageHelperTests {
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Test func anyCodableHelperAccessors() throws { @Test func anyCodableHelperAccessors() throws {
let payload: [String: AnyCodable] = [ let payload: [String: ProtoAnyCodable] = [
"title": AnyCodable("Hello"), "title": ProtoAnyCodable("Hello"),
"flag": AnyCodable(true), "flag": ProtoAnyCodable(true),
"count": AnyCodable(3), "count": ProtoAnyCodable(3),
"ratio": AnyCodable(1.25), "ratio": ProtoAnyCodable(1.25),
"list": AnyCodable([AnyCodable("a"), AnyCodable(2)]), "list": ProtoAnyCodable([ProtoAnyCodable("a"), ProtoAnyCodable(2)]),
] ]
let any = AnyCodable(payload) let any = ProtoAnyCodable(payload)
let dict = try #require(any.dictionaryValue) let dict = try #require(any.dictionaryValue)
#expect(dict["title"]?.stringValue == "Hello") #expect(dict["title"]?.stringValue == "Hello")
#expect(dict["flag"]?.boolValue == true) #expect(dict["flag"]?.boolValue == true)
@@ -76,31 +78,27 @@ struct LowCoverageHelperTests {
#expect(result.stderr.contains("stderr-1999")) #expect(result.stderr.contains("stderr-1999"))
} }
@Test func pairedNodesStorePersists() async throws { @Test func nodeInfoCodableRoundTrip() throws {
let dir = FileManager().temporaryDirectory let info = NodeInfo(
.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(
nodeId: "node-1", nodeId: "node-1",
displayName: "Node One", displayName: "Node One",
platform: "macOS", platform: "macOS",
version: "1.0", version: "1.0",
coreVersion: "1.0-core",
uiVersion: "1.0-ui",
deviceFamily: "Mac", deviceFamily: "Mac",
modelIdentifier: "MacBookPro", modelIdentifier: "MacBookPro",
token: "token", remoteIp: "192.168.1.2",
createdAtMs: 1, caps: ["chat"],
lastSeenAtMs: nil) commands: ["send"],
try await store.upsert(node) permissions: ["send": true],
#expect(await store.find(nodeId: "node-1")?.displayName == "Node One") paired: true,
connected: false)
try await store.touchSeen(nodeId: "node-1") let data = try JSONEncoder().encode(info)
let updated = await store.find(nodeId: "node-1") let decoded = try JSONDecoder().decode(NodeInfo.self, from: data)
#expect(updated?.lastSeenAtMs != nil) #expect(decoded.nodeId == "node-1")
#expect(decoded.isPaired == true)
#expect(decoded.isConnected == false)
} }
@Test @MainActor func presenceReporterHelpers() { @Test @MainActor func presenceReporterHelpers() {
@@ -21,6 +21,7 @@ import Testing
features: [:], features: [:],
snapshot: snapshot, snapshot: snapshot,
canvashosturl: nil, canvashosturl: nil,
auth: nil,
policy: [:]) policy: [:])
let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello)) let mapped = MacGatewayChatTransport.mapPushToTransportEvent(.snapshot(hello))
@@ -3,13 +3,15 @@ import SwiftUI
import Testing import Testing
@testable import Clawdbot @testable import Clawdbot
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@Suite(.serialized) @Suite(.serialized)
@MainActor @MainActor
struct OnboardingWizardStepViewTests { struct OnboardingWizardStepViewTests {
@Test func noteStepBuilds() { @Test func noteStepBuilds() {
let step = WizardStep( let step = WizardStep(
id: "step-1", id: "step-1",
type: AnyCodable("note"), type: ProtoAnyCodable("note"),
title: "Welcome", title: "Welcome",
message: "Hello", message: "Hello",
options: nil, options: nil,
@@ -22,17 +24,17 @@ struct OnboardingWizardStepViewTests {
} }
@Test func selectStepBuilds() { @Test func selectStepBuilds() {
let options: [[String: AnyCodable]] = [ let options: [[String: ProtoAnyCodable]] = [
["value": AnyCodable("local"), "label": AnyCodable("Local"), "hint": AnyCodable("This Mac")], ["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")],
["value": AnyCodable("remote"), "label": AnyCodable("Remote")], ["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")],
] ]
let step = WizardStep( let step = WizardStep(
id: "step-2", id: "step-2",
type: AnyCodable("select"), type: ProtoAnyCodable("select"),
title: "Mode", title: "Mode",
message: "Choose a mode", message: "Choose a mode",
options: options, options: options,
initialvalue: AnyCodable("local"), initialvalue: ProtoAnyCodable("local"),
placeholder: nil, placeholder: nil,
sensitive: nil, sensitive: nil,
executor: nil) executor: nil)
@@ -15,6 +15,9 @@ extension URLSessionWebSocketTask: WebSocketTasking {}
public struct WebSocketTaskBox: @unchecked Sendable { public struct WebSocketTaskBox: @unchecked Sendable {
public let task: any WebSocketTasking public let task: any WebSocketTasking
public init(task: any WebSocketTasking) {
self.task = task
}
public var state: URLSessionTask.State { self.task.state } 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.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.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.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset) - `tools.exec.node` (default: unset)
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs. - `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
-1
View File
@@ -110,7 +110,6 @@ const writeBuildStamp = () => {
}; };
if (!shouldBuild()) { if (!shouldBuild()) {
logRunner("Skipping build; dist is fresh.");
runNode(); runNode();
} else { } else {
logRunner("Building TypeScript (dist is stale)."); logRunner("Building TypeScript (dist is stale).");
+6 -3
View File
@@ -400,7 +400,7 @@ export function createExecTool(
host = "gateway"; host = "gateway";
} }
const configuredSecurity = defaults?.security ?? "deny"; const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
const requestedSecurity = normalizeExecSecurity(params.security); const requestedSecurity = normalizeExecSecurity(params.security);
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity); let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
if (elevatedRequested) { if (elevatedRequested) {
@@ -447,7 +447,10 @@ export function createExecTool(
applyPathPrepend(env, defaultPathPrepend); applyPathPrepend(env, defaultPathPrepend);
if (host === "node") { 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 hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask); const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback; const askFallback = approvals.agent.askFallback;
@@ -616,7 +619,7 @@ export function createExecTool(
} }
if (host === "gateway") { if (host === "gateway") {
const approvals = resolveExecApprovals(defaults?.agentId); const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" });
const hostSecurity = minSecurity(security, approvals.agent.security); const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask); const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback; 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 requesterKey = resolveInternalSessionKey({ key: sessionKey, alias, mainKey });
const runs = listSubagentRunsForRequester(requesterKey); const runs = listSubagentRunsForRequester(requesterKey);
const verboseEnabled = resolvedVerboseLevel && resolvedVerboseLevel !== "off"; const verboseEnabled = resolvedVerboseLevel && resolvedVerboseLevel !== "off";
if (runs.length === 0) { if (runs.length > 0) {
if (verboseEnabled) subagentsLine = "🤖 Subagents: none";
} else {
const active = runs.filter((entry) => !entry.endedAt); const active = runs.filter((entry) => !entry.endedAt);
const done = runs.length - active.length; const done = runs.length - active.length;
if (verboseEnabled) { if (verboseEnabled) {
+14
View File
@@ -215,6 +215,20 @@ describe("handleCommands subagents", () => {
expect(result.reply?.text).toContain("Subagents: none"); 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 () => { it("returns help for unknown subagents action", async () => {
resetSubagentRegistryForTests(); resetSubagentRegistryForTests();
const cfg = { const cfg = {
+16
View File
@@ -143,6 +143,22 @@ describe("buildStatusMessage", () => {
expect(normalized).toContain("Media: image ok (openai/gpt-5.2) · audio skipped (maxBytes)"); 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", () => { it("does not show elevated label when session explicitly disables it", () => {
const text = buildStatusMessage({ const text = buildStatusMessage({
agent: { model: "anthropic/claude-opus-4-5", elevatedDefault: "on" }, agent: { model: "anthropic/claude-opus-4-5", elevatedDefault: "on" },
+2 -1
View File
@@ -201,8 +201,9 @@ const formatMediaUnderstandingLine = (decisions?: MediaUnderstandingDecision[])
} }
return null; return null;
}) })
.filter(Boolean); .filter((part): part is string => part != null);
if (parts.length === 0) return null; if (parts.length === 0) return null;
if (parts.every((part) => part.endsWith(" none"))) return null;
return `📎 Media: ${parts.join(" · ")}`; 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 type { ExecApprovalsAgent, ExecApprovalsFile } from "../infra/exec-approvals.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.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 { callGatewayFromCli } from "./gateway-rpc.js";
import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js"; import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js";
import type { NodesRpcOpts } from "./nodes-cli/types.js"; import type { NodesRpcOpts } from "./nodes-cli/types.js";
@@ -24,6 +25,17 @@ type ExecApprovalsCliOpts = NodesRpcOpts & {
agent?: string; 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> { async function readStdin(): Promise<string> {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of process.stdin) { for await (const chunk of process.stdin) {
@@ -89,6 +101,9 @@ function isEmptyAgent(agent: ExecApprovalsAgent): boolean {
} }
export function registerExecApprovalsCli(program: Command) { export function registerExecApprovalsCli(program: Command) {
const formatExample = (cmd: string, desc: string) =>
` ${theme.command(cmd)}\n ${theme.muted(desc)}`;
const approvals = program const approvals = program
.command("approvals") .command("approvals")
.alias("exec-approvals") .alias("exec-approvals")
@@ -106,8 +121,87 @@ export function registerExecApprovalsCli(program: Command) {
.action(async (opts: ExecApprovalsCliOpts) => { .action(async (opts: ExecApprovalsCliOpts) => {
const nodeId = await resolveTargetNodeId(opts); const nodeId = await resolveTargetNodeId(opts);
const snapshot = await loadSnapshot(opts, nodeId); const snapshot = await loadSnapshot(opts, nodeId);
const payload = opts.json ? JSON.stringify(snapshot) : JSON.stringify(snapshot, null, 2); if (opts.json) {
defaultRuntime.log(payload); 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); nodesCallOpts(getCmd);
@@ -151,7 +245,23 @@ export function registerExecApprovalsCli(program: Command) {
}); });
nodesCallOpts(setCmd); 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 const allowlistAdd = allowlist
.command("add <pattern>") .command("add <pattern>")
+17 -2
View File
@@ -1,13 +1,28 @@
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { isRich, theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js"; import { runCommandWithRuntime } from "../cli-utils.js";
import { unauthorizedHintForMessage } from "./rpc.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>) { export function runNodesCommand(label: string, action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => { return runCommandWithRuntime(defaultRuntime, action, (err) => {
const message = String(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); const hint = unauthorizedHintForMessage(message);
if (hint) defaultRuntime.error(hint); if (hint) defaultRuntime.error(warn(hint));
defaultRuntime.exit(1); defaultRuntime.exit(1);
}); });
} }
+23 -8
View File
@@ -9,9 +9,10 @@ import {
writeBase64ToFile, writeBase64ToFile,
} from "../nodes-camera.js"; } from "../nodes-camera.js";
import { parseDurationMs } from "../parse-duration.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 { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js"; import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js";
const parseFacing = (value: string): CameraFacing => { const parseFacing = (value: string): CameraFacing => {
const v = String(value ?? "") const v = String(value ?? "")
@@ -52,16 +53,30 @@ export function registerNodesCameraCommands(nodes: Command) {
} }
if (devices.length === 0) { if (devices.length === 0) {
defaultRuntime.log("No cameras reported."); const { muted } = getNodesTheme();
defaultRuntime.log(muted("No cameras reported."));
return; return;
} }
for (const device of devices) { const { heading, muted } = getNodesTheme();
const id = typeof device.id === "string" ? device.id : ""; const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const name = typeof device.name === "string" ? device.name : "Unknown Camera"; const rows = devices.map((device) => ({
const position = typeof device.position === "string" ? device.position : "unspecified"; Name: typeof device.name === "string" ? device.name : "Unknown Camera",
defaultRuntime.log(`${name} (${position})${id ? ` — ${id}` : ""}`); 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 }, { 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 { canvasSnapshotTempPath, parseCanvasSnapshotPayload } from "../nodes-canvas.js";
import { parseTimeoutMs } from "../nodes-run.js"; import { parseTimeoutMs } from "../nodes-run.js";
import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.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 { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js"; import type { NodesRpcOpts } from "./types.js";
@@ -121,7 +121,10 @@ export function registerNodesCanvasCommands(nodes: Command) {
params.placement = placement; params.placement = placement;
} }
await invokeCanvas(opts, "canvas.present", params); 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) => { .action(async (opts: NodesRpcOpts) => {
await runNodesCommand("canvas hide", async () => { await runNodesCommand("canvas hide", async () => {
await invokeCanvas(opts, "canvas.hide", undefined); 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) => { .action(async (url: string, opts: NodesRpcOpts) => {
await runNodesCommand("canvas navigate", async () => { await runNodesCommand("canvas navigate", async () => {
await invokeCanvas(opts, "canvas.navigate", { url }); 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 ? (raw as { payload?: { result?: string } }).payload
: undefined; : undefined;
if (payload?.result) defaultRuntime.log(payload.result); 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 }); await invokeCanvas(opts, "canvas.a2ui.pushJSONL", { jsonl });
if (!opts.json) { if (!opts.json) {
const { ok } = getNodesTheme();
defaultRuntime.log( 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) => { .action(async (opts: NodesRpcOpts) => {
await runNodesCommand("canvas a2ui reset", async () => { await runNodesCommand("canvas a2ui reset", async () => {
await invokeCanvas(opts, "canvas.a2ui.reset", undefined); 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 { randomIdempotencyKey } from "../../gateway/call.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { parseEnvPairs, parseTimeoutMs } from "../nodes-run.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 { callGatewayCli, nodesCallOpts, resolveNodeId, unauthorizedHintForMessage } from "./rpc.js";
import type { NodesRpcOpts } from "./types.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 nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
const command = String(opts.command ?? "").trim(); const command = String(opts.command ?? "").trim();
if (!nodeId || !command) { if (!nodeId || !command) {
defaultRuntime.error("--node and --command required"); const { error } = getNodesTheme();
defaultRuntime.error(error("--node and --command required"));
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
@@ -108,16 +109,21 @@ export function registerNodesInvokeCommands(nodes: Command) {
if (stdout) process.stdout.write(stdout); if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr); if (stderr) process.stderr.write(stderr);
if (timedOut) { if (timedOut) {
defaultRuntime.error("run timed out"); const { error } = getNodesTheme();
defaultRuntime.error(error("run timed out"));
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
if (exitCode !== null && exitCode !== 0) { if (exitCode !== null && exitCode !== 0) {
const hint = unauthorizedHintForMessage(`${stderr}\n${stdout}`); 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) { if (exitCode !== null && exitCode !== 0 && !success) {
defaultRuntime.error(`run exit ${exitCode}`); const { error } = getNodesTheme();
defaultRuntime.error(error(`run exit ${exitCode}`));
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
+3 -2
View File
@@ -1,7 +1,7 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { randomIdempotencyKey } from "../../gateway/call.js"; import { randomIdempotencyKey } from "../../gateway/call.js";
import { defaultRuntime } from "../../runtime.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 { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js"; import type { NodesRpcOpts } from "./types.js";
@@ -49,7 +49,8 @@ export function registerNodesNotifyCommand(nodes: Command) {
defaultRuntime.log(JSON.stringify(result, null, 2)); defaultRuntime.log(JSON.stringify(result, null, 2));
return; 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 type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { formatAge, parsePairingList } from "./format.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 { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js"; import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js";
export function registerNodesPairingCommands(nodes: Command) { export function registerNodesPairingCommands(nodes: Command) {
nodesCallOpts( nodesCallOpts(
@@ -19,16 +20,37 @@ export function registerNodesPairingCommands(nodes: Command) {
return; return;
} }
if (pending.length === 0) { if (pending.length === 0) {
defaultRuntime.log("No pending pairing requests."); const { muted } = getNodesTheme();
defaultRuntime.log(muted("No pending pairing requests."));
return; return;
} }
for (const r of pending) { const { heading, warn, muted } = getNodesTheme();
const name = r.displayName || r.nodeId; const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const repair = r.isRepair ? " (repair)" : ""; const now = Date.now();
const ip = r.remoteIp ? ` · ${r.remoteIp}` : ""; const rows = pending.map((r) => ({
const age = typeof r.ts === "number" ? ` · ${formatAge(Date.now() - r.ts)} ago` : ""; Request: r.requestId,
defaultRuntime.log(`- ${r.requestId}: ${name}${repair}${ip}${age}`); 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)); defaultRuntime.log(JSON.stringify(result, null, 2));
return; 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 type { Command } from "commander";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { formatAge, formatPermissions, parseNodeList, parsePairingList } from "./format.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 { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js"; import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js"; import { renderTable } from "../../terminal/table.js";
import { isRich, theme } from "../../terminal/theme.js";
function formatVersionLabel(raw: string) { function formatVersionLabel(raw: string) {
const trimmed = raw.trim(); const trimmed = raw.trim();
@@ -56,32 +55,61 @@ export function registerNodesStatusCommands(nodes: Command) {
defaultRuntime.log(JSON.stringify(result, null, 2)); defaultRuntime.log(JSON.stringify(result, null, 2));
return; 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 nodes = parseNodeList(result);
const pairedCount = nodes.filter((n) => Boolean(n.paired)).length; const pairedCount = nodes.filter((n) => Boolean(n.paired)).length;
const connectedCount = nodes.filter((n) => Boolean(n.connected)).length; const connectedCount = nodes.filter((n) => Boolean(n.connected)).length;
defaultRuntime.log( defaultRuntime.log(
`Known: ${nodes.length} · Paired: ${pairedCount} · Connected: ${connectedCount}`, `Known: ${nodes.length} · Paired: ${pairedCount} · Connected: ${connectedCount}`,
); );
for (const n of nodes) { if (nodes.length === 0) return;
const name = n.displayName || n.nodeId;
const ip = n.remoteIp ? ` · ${n.remoteIp}` : ""; const rows = nodes.map((n) => {
const device = n.deviceFamily ? ` · device: ${n.deviceFamily}` : ""; const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId;
const hw = n.modelIdentifier ? ` · hw: ${n.modelIdentifier}` : "";
const perms = formatPermissions(n.permissions); const perms = formatPermissions(n.permissions);
const permsText = perms ? ` · perms: ${perms}` : "";
const versions = formatNodeVersions(n); const versions = formatNodeVersions(n);
const versionText = versions ? ` · ${versions}` : ""; const detailParts = [
const caps = n.deviceFamily ? `device: ${n.deviceFamily}` : null,
Array.isArray(n.caps) && n.caps.length > 0 n.modelIdentifier ? `hw: ${n.modelIdentifier}` : null,
? `[${n.caps.map(String).filter(Boolean).sort().join(",")}]` perms ? `perms: ${perms}` : null,
: Array.isArray(n.caps) versions,
? "[]" ].filter(Boolean) as string[];
: "?"; const caps = Array.isArray(n.caps)
const pairing = n.paired ? "paired" : "unpaired"; ? n.caps.map(String).filter(Boolean).sort().join(", ")
defaultRuntime.log( : "?";
`- ${name} · ${n.nodeId}${ip}${device}${hw}${permsText}${versionText} · ${pairing} · ${n.connected ? "connected" : "disconnected"} · caps: ${caps}`, 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 displayName = typeof obj.displayName === "string" ? obj.displayName : nodeId;
const connected = Boolean(obj.connected); 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 caps = Array.isArray(obj.caps) ? obj.caps.map(String).filter(Boolean).sort() : null;
const commands = Array.isArray(obj.commands) const commands = Array.isArray(obj.commands)
? obj.commands.map(String).filter(Boolean).sort() ? obj.commands.map(String).filter(Boolean).sort()
@@ -125,18 +154,38 @@ export function registerNodesStatusCommands(nodes: Command) {
}, },
); );
const parts: string[] = ["Node:", displayName, nodeId]; const { heading, ok, warn, muted } = getNodesTheme();
if (ip) parts.push(ip); const status = `${paired ? ok("paired") : warn("unpaired")} · ${
if (family) parts.push(`device: ${family}`); connected ? ok("connected") : muted("disconnected")
if (model) parts.push(`hw: ${model}`); }`;
if (perms) parts.push(`perms: ${perms}`); const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
if (versions) parts.push(versions); const rows = [
parts.push(connected ? "connected" : "disconnected"); { Field: "ID", Value: nodeId },
parts.push(`caps: ${caps ? `[${caps.join(",")}]` : "?"}`); displayName ? { Field: "Name", Value: displayName } : null,
defaultRuntime.log(parts.join(" · ")); ip ? { Field: "IP", Value: ip } : null,
defaultRuntime.log("Commands:"); 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) { if (commands.length === 0) {
defaultRuntime.log("- (none reported)"); defaultRuntime.log(muted("- (none reported)"));
return; return;
} }
for (const c of commands) defaultRuntime.log(`- ${c}`); for (const c of commands) defaultRuntime.log(`- ${c}`);
@@ -157,10 +206,7 @@ export function registerNodesStatusCommands(nodes: Command) {
} }
const { pending, paired } = parsePairingList(result); const { pending, paired } = parsePairingList(result);
defaultRuntime.log(`Pending: ${pending.length} · Paired: ${paired.length}`); defaultRuntime.log(`Pending: ${pending.length} · Paired: ${paired.length}`);
const rich = isRich(); const { heading, muted, warn } = getNodesTheme();
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 tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const now = Date.now(); const now = Date.now();
+1
View File
@@ -56,6 +56,7 @@ export type NodeListNode = {
permissions?: Record<string, boolean>; permissions?: Record<string, boolean>;
paired?: boolean; paired?: boolean;
connected?: boolean; connected?: boolean;
connectedAtMs?: number;
}; };
export type PendingRequest = { 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"); 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("Known: 1 · Paired: 1 · Connected: 1");
expect(output).toContain("iOS Node"); expect(output).toContain("iOS Node");
expect(output).toContain("Detail");
expect(output).toContain("device: iPad"); expect(output).toContain("device: iPad");
expect(output).toContain("hw: iPad16,6"); expect(output).toContain("hw: iPad16,6");
expect(output).toContain("Status");
expect(output).toContain("paired"); 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 () => { 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"); 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("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("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("unpaired");
expect(output).toContain("connected"); 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 () => { 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"); 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"); 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)}`, ([cmd, desc]) => ` ${theme.command(cmd)}\n ${theme.muted(desc)}`,
).join("\n"); ).join("\n");
program.addHelpText("afterAll", () => { program.addHelpText("afterAll", ({ command }) => {
if (command !== program) return "";
const docs = formatDocsLink("/cli", "docs.clawd.bot/cli"); const docs = formatDocsLink("/cli", "docs.clawd.bot/cli");
return `\n${theme.heading("Examples:")}\n${fmtExamples}\n\n${theme.muted("Docs:")} ${docs}\n`; 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"; } from "./node-cli/daemon.js";
export function registerServiceCli(program: Command) { 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 const service = program
.command("service") .command("service")
.description("Manage Gateway and node host services (launchd/systemd/schtasks)") .description("Manage Gateway and node host services (launchd/systemd/schtasks)")
.addHelpText( .addHelpText(
"after", "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"); const gateway = service.command("gateway").description("Manage the Gateway service");
+2
View File
@@ -258,6 +258,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
caps, caps,
commands, commands,
permissions: live?.permissions ?? paired?.permissions, permissions: live?.permissions ?? paired?.permissions,
connectedAtMs: live?.connectedAtMs,
paired: Boolean(paired), paired: Boolean(paired),
connected: Boolean(live), connected: Boolean(live),
}; };
@@ -320,6 +321,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
caps, caps,
commands, commands,
permissions: live?.permissions, permissions: live?.permissions,
connectedAtMs: live?.connectedAtMs,
paired: Boolean(paired), paired: Boolean(paired),
connected: Boolean(live), connected: Boolean(live),
}, },
+35 -12
View File
@@ -188,31 +188,54 @@ export function ensureExecApprovals(): ExecApprovalsFile {
return updated; return updated;
} }
function normalizeSecurity(value?: ExecSecurity): ExecSecurity { function normalizeSecurity(value: ExecSecurity | undefined, fallback: ExecSecurity): ExecSecurity {
if (value === "allowlist" || value === "full" || value === "deny") return value; 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; 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 file = ensureExecApprovals();
const defaults = file.defaults ?? {}; const defaults = file.defaults ?? {};
const agentKey = agentId ?? "default"; const agentKey = agentId ?? "default";
const agent = file.agents?.[agentKey] ?? {}; 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> = { const resolvedDefaults: Required<ExecApprovalsDefaults> = {
security: normalizeSecurity(defaults.security), security: normalizeSecurity(defaults.security, fallbackSecurity),
ask: normalizeAsk(defaults.ask), ask: normalizeAsk(defaults.ask, fallbackAsk),
askFallback: normalizeSecurity(defaults.askFallback ?? DEFAULT_ASK_FALLBACK), askFallback: normalizeSecurity(
autoAllowSkills: Boolean(defaults.autoAllowSkills ?? DEFAULT_AUTO_ALLOW_SKILLS), defaults.askFallback ?? fallbackAskFallback,
fallbackAskFallback,
),
autoAllowSkills: Boolean(defaults.autoAllowSkills ?? fallbackAutoAllowSkills),
}; };
const resolvedAgent: Required<ExecApprovalsDefaults> = { const resolvedAgent: Required<ExecApprovalsDefaults> = {
security: normalizeSecurity(agent.security ?? resolvedDefaults.security), security: normalizeSecurity(
ask: normalizeAsk(agent.ask ?? resolvedDefaults.ask), agent.security ?? resolvedDefaults.security,
askFallback: normalizeSecurity(agent.askFallback ?? resolvedDefaults.askFallback), resolvedDefaults.security,
),
ask: normalizeAsk(agent.ask ?? resolvedDefaults.ask, resolvedDefaults.ask),
askFallback: normalizeSecurity(
agent.askFallback ?? resolvedDefaults.askFallback,
resolvedDefaults.askFallback,
),
autoAllowSkills: Boolean(agent.autoAllowSkills ?? resolvedDefaults.autoAllowSkills), autoAllowSkills: Boolean(agent.autoAllowSkills ?? resolvedDefaults.autoAllowSkills),
}; };
const allowlist = Array.isArray(agent.allowlist) ? agent.allowlist : []; 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 rawCommand = typeof params.rawCommand === "string" ? params.rawCommand.trim() : "";
const cmdText = rawCommand || formatCommand(argv); const cmdText = rawCommand || formatCommand(argv);
const agentId = params.agentId?.trim() || undefined; const agentId = params.agentId?.trim() || undefined;
const approvals = resolveExecApprovals(agentId); const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
const security = approvals.agent.security; const security = approvals.agent.security;
const ask = approvals.agent.ask; const ask = approvals.agent.ask;
const autoAllowSkills = approvals.agent.autoAllowSkills; const autoAllowSkills = approvals.agent.autoAllowSkills;
+2 -2
View File
@@ -179,6 +179,8 @@ export const registerTelegramHandlers = ({
const callback = ctx.callbackQuery; const callback = ctx.callbackQuery;
if (!callback) return; if (!callback) return;
if (shouldSkipUpdate(ctx)) return; if (shouldSkipUpdate(ctx)) return;
// Answer immediately to prevent Telegram from retrying while we process
await bot.api.answerCallbackQuery(callback.id).catch(() => {});
try { try {
const data = (callback.data ?? "").trim(); const data = (callback.data ?? "").trim();
const callbackMessage = callback.message; const callbackMessage = callback.message;
@@ -323,8 +325,6 @@ export const registerTelegramHandlers = ({
}); });
} catch (err) { } catch (err) {
runtime.error?.(danger(`callback handler failed: ${String(err)}`)); runtime.error?.(danger(`callback handler failed: ${String(err)}`));
} finally {
await bot.api.answerCallbackQuery(callback.id).catch(() => {});
} }
}); });