Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b02c47043d | ||
|
|
6f42c623b9 |
No files matched your search
@@ -100,7 +100,7 @@
|
||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||
- macOS logs: use `./scripts/clawlog.sh` to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
|
||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||
|
||||
+68
-3
@@ -2,14 +2,75 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.20
|
||||
|
||||
### Changes
|
||||
- Deps: update workspace + memory-lancedb dependencies.
|
||||
- Dev: use tsgo for dev/watch builds by default; set `CLAWDBOT_TS_COMPILER=tsc` to opt out.
|
||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
||||
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) — thanks @sebslight.
|
||||
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
|
||||
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
||||
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
||||
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
|
||||
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
|
||||
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
|
||||
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
|
||||
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
|
||||
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
|
||||
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill.
|
||||
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes.
|
||||
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
|
||||
- TUI: add input history (up/down) for submitted messages. (#1348) — thanks @vignesh07.
|
||||
### Fixes
|
||||
- iOS: explain Talk mode is unavailable on the simulator to avoid Speech live-audio crashes. (#1358) — thanks @vignesh07.
|
||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
||||
- 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.
|
||||
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
|
||||
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
|
||||
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
|
||||
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
|
||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
||||
- Gateway: clarify connect/validation errors for gateway params. (#1347) — thanks @vignesh07.
|
||||
- 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-.
|
||||
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
||||
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346) — thanks @fogboots.
|
||||
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
||||
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
|
||||
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
||||
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
|
||||
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
|
||||
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
||||
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
|
||||
- Anthropic: default API prompt caching to 1h with configurable TTL override; ignore TTL for OAuth.
|
||||
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
|
||||
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
|
||||
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
|
||||
|
||||
## 2026.1.19-3
|
||||
|
||||
### Changes
|
||||
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
||||
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
|
||||
- Gateway: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) — thanks @RyanLisse.
|
||||
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
|
||||
|
||||
### Fixes
|
||||
- Slack: respect verbose tool summaries and keep tool notifications threaded. (#1360) — thanks @SocialNerd42069.
|
||||
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
|
||||
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
|
||||
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
|
||||
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
|
||||
|
||||
## 2026.1.19-2
|
||||
|
||||
@@ -28,9 +89,11 @@ Docs: https://docs.clawd.bot
|
||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
|
||||
|
||||
### Changes
|
||||
- Gateway: add `/v1/responses` endpoint (OpenResponses API) for agentic workflows with item-based input and semantic streaming events. Enable via `gateway.http.endpoints.responses.enabled: true`.
|
||||
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
||||
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
|
||||
|
||||
### Fixes
|
||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
||||
@@ -39,9 +102,11 @@ Docs: https://docs.clawd.bot
|
||||
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
||||
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) — thanks @ameno-.
|
||||
- Memory: show total file counts + scan issues in `clawdbot memory status`; fall back to non-batch embeddings after repeated batch failures.
|
||||
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
||||
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
||||
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
|
||||
|
||||
## 2026.1.18-5
|
||||
|
||||
@@ -67,8 +132,8 @@ Docs: https://docs.clawd.bot
|
||||
## 2026.1.18-4
|
||||
|
||||
### Changes
|
||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
|
||||
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
|
||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
|
||||
- macOS: stop syncing Peekaboo in postinstall.
|
||||
- Swabble: use the tagged Commander Swift package release.
|
||||
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
||||
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
||||
|
||||
@@ -529,7 +529,7 @@ class NodeRuntime(context: Context) {
|
||||
caps = buildCapabilities(),
|
||||
commands = buildInvokeCommands(),
|
||||
permissions = emptyMap(),
|
||||
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
|
||||
client = buildClientInfo(clientId = "node-host", clientMode = "node"),
|
||||
userAgent = buildUserAgent(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -133,8 +133,9 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
private func startRecognition() throws {
|
||||
#if targetEnvironment(simulator)
|
||||
// Apple Speech live-audio recognition is not supported on Simulator.
|
||||
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
|
||||
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator (Speech live audio requires a device).",
|
||||
])
|
||||
#endif
|
||||
|
||||
|
||||
@@ -87,7 +87,15 @@ final class ControlChannel {
|
||||
|
||||
func configure() async {
|
||||
self.logger.info("control channel configure mode=local")
|
||||
await self.refreshEndpoint(reason: "configure")
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
self.state = .connected
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
}
|
||||
}
|
||||
|
||||
func configure(mode: Mode = .local) async throws {
|
||||
@@ -103,7 +111,7 @@ final class ControlChannel {
|
||||
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||
self.state = .connecting
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
await self.refreshEndpoint(reason: "configure")
|
||||
await self.configure()
|
||||
} catch {
|
||||
self.state = .degraded(error.localizedDescription)
|
||||
throw error
|
||||
@@ -111,19 +119,6 @@ final class ControlChannel {
|
||||
}
|
||||
}
|
||||
|
||||
func refreshEndpoint(reason: String) async {
|
||||
self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await self.establishGatewayConnection()
|
||||
self.state = .connected
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
await GatewayConnection.shared.shutdown()
|
||||
self.state = .disconnected
|
||||
@@ -280,28 +275,18 @@ final class ControlChannel {
|
||||
}
|
||||
}
|
||||
|
||||
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
|
||||
if case .connected = self.state {
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
self.logger.info("control channel recovery finished")
|
||||
} else if case let .degraded(message) = self.state {
|
||||
self.logger.error("control channel recovery failed \(message, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
self.recoveryTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func establishGatewayConnection(timeoutMs: Int = 5000) async throws {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
||||
if ok == false {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
||||
}
|
||||
}
|
||||
|
||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||
var merged = params
|
||||
merged["text"] = AnyHashable(text)
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GatewayConnectivityCoordinator {
|
||||
static let shared = GatewayConnectivityCoordinator()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.connectivity")
|
||||
private var endpointTask: Task<Void, Never>?
|
||||
private var lastResolvedURL: URL?
|
||||
|
||||
private(set) var endpointState: GatewayEndpointState?
|
||||
private(set) var resolvedURL: URL?
|
||||
private(set) var resolvedMode: AppState.ConnectionMode?
|
||||
private(set) var resolvedHostLabel: String?
|
||||
|
||||
private init() {
|
||||
self.start()
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.endpointTask == nil else { return }
|
||||
self.endpointTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayEndpointStore.shared.subscribe()
|
||||
for await state in stream {
|
||||
await MainActor.run { self.handleEndpointState(state) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var localEndpointHostLabel: String? {
|
||||
guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil }
|
||||
return Self.hostLabel(for: url)
|
||||
}
|
||||
|
||||
private func handleEndpointState(_ state: GatewayEndpointState) {
|
||||
self.endpointState = state
|
||||
switch state {
|
||||
case let .ready(mode, url, _, _):
|
||||
self.resolvedMode = mode
|
||||
self.resolvedURL = url
|
||||
self.resolvedHostLabel = Self.hostLabel(for: url)
|
||||
let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString
|
||||
if urlChanged {
|
||||
self.lastResolvedURL = url
|
||||
Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") }
|
||||
}
|
||||
case let .connecting(mode, _):
|
||||
self.resolvedMode = mode
|
||||
case let .unavailable(mode, _):
|
||||
self.resolvedMode = mode
|
||||
}
|
||||
}
|
||||
|
||||
private static func hostLabel(for url: URL) -> String {
|
||||
let host = url.host ?? url.absoluteString
|
||||
if let port = url.port { return "\(host):\(port)" }
|
||||
return host
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,6 @@ actor GatewayEndpointStore {
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||
?? TailscaleService.fallbackTailnetIPv4()
|
||||
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||
bindMode: bind,
|
||||
customBindHost: customBindHost,
|
||||
@@ -488,7 +487,6 @@ actor GatewayEndpointStore {
|
||||
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||
|
||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||
?? TailscaleService.fallbackTailnetIPv4()
|
||||
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
||||
|
||||
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||
|
||||
@@ -235,8 +235,8 @@ final class HealthStore {
|
||||
let lower = error.lowercased()
|
||||
if lower.contains("connection refused") {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||
return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back."
|
||||
return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " +
|
||||
"restart Clawdbot to bring it back."
|
||||
}
|
||||
if lower.contains("timeout") {
|
||||
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
|
||||
|
||||
@@ -13,7 +13,6 @@ struct ClawdbotApp: App {
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
|
||||
@State private var statusItem: NSStatusItem?
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isPanelVisible = false
|
||||
|
||||
@@ -469,7 +469,7 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
case .local:
|
||||
platform = "local"
|
||||
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||
host = "127.0.0.1:\(port)"
|
||||
case .unconfigured:
|
||||
platform = nil
|
||||
host = nil
|
||||
|
||||
@@ -103,7 +103,6 @@ final class TailscaleService {
|
||||
}
|
||||
|
||||
func checkTailscaleStatus() async {
|
||||
let previousIP = self.tailscaleIP
|
||||
self.isInstalled = self.checkAppInstallation()
|
||||
if !self.isInstalled {
|
||||
self.isRunning = false
|
||||
@@ -148,10 +147,6 @@ final class TailscaleService {
|
||||
self.statusError = nil
|
||||
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
|
||||
}
|
||||
|
||||
if previousIP != self.tailscaleIP {
|
||||
await GatewayEndpointStore.shared.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func openTailscaleApp() {
|
||||
@@ -219,8 +214,4 @@ final class TailscaleService {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated static func fallbackTailnetIPv4() -> String? {
|
||||
Self.detectTailnetIPv4()
|
||||
}
|
||||
}
|
||||
@@ -23,35 +23,6 @@ public actor GatewayNodeSession {
|
||||
private var onConnected: (@Sendable () async -> Void)?
|
||||
private var onDisconnected: (@Sendable (String) async -> Void)?
|
||||
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
|
||||
|
||||
static func invokeWithTimeout(
|
||||
request: BridgeInvokeRequest,
|
||||
timeoutMs: Int?,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
|
||||
) async -> BridgeInvokeResponse {
|
||||
let timeout = max(0, timeoutMs ?? 0)
|
||||
guard timeout > 0 else {
|
||||
return await onInvoke(request)
|
||||
}
|
||||
|
||||
return await withTaskGroup(of: BridgeInvokeResponse.self) { group in
|
||||
group.addTask { await onInvoke(request) }
|
||||
group.addTask {
|
||||
try? await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000)
|
||||
return BridgeInvokeResponse(
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: ClawdbotNodeError(
|
||||
code: .unavailable,
|
||||
message: "node invoke timed out")
|
||||
)
|
||||
}
|
||||
|
||||
let first = await group.next()!
|
||||
group.cancelAll()
|
||||
return first
|
||||
}
|
||||
}
|
||||
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
|
||||
private var canvasHostUrl: String?
|
||||
|
||||
@@ -196,11 +167,7 @@ public actor GatewayNodeSession {
|
||||
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
||||
guard let onInvoke else { return }
|
||||
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
|
||||
let response = await Self.invokeWithTimeout(
|
||||
request: req,
|
||||
timeoutMs: request.timeoutMs,
|
||||
onInvoke: onInvoke
|
||||
)
|
||||
let response = await onInvoke(req)
|
||||
await self.sendInvokeResult(request: request, response: response)
|
||||
} catch {
|
||||
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
|
||||
struct GatewayNodeSessionTests {
|
||||
@Test
|
||||
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
|
||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 50,
|
||||
onInvoke: { req in
|
||||
#expect(req.id == "1")
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response.ok == true)
|
||||
#expect(response.error == nil)
|
||||
#expect(response.payloadJSON == "{}")
|
||||
}
|
||||
|
||||
@Test
|
||||
func invokeWithTimeoutReturnsTimeoutError() async {
|
||||
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 10,
|
||||
onInvoke: { _ in
|
||||
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
|
||||
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.code == .unavailable)
|
||||
#expect(response.error?.message.contains("timed out") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func invokeWithTimeoutZeroDisablesTimeout() async {
|
||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 0,
|
||||
onInvoke: { req in
|
||||
try? await Task.sleep(nanoseconds: 5_000_000)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response.ok == true)
|
||||
#expect(response.error == nil)
|
||||
}
|
||||
}
|
||||
+3
-12
@@ -7,9 +7,9 @@ read_when:
|
||||
|
||||
# `clawdbot update`
|
||||
|
||||
Safely update Clawdbot and switch between stable/beta/dev channels.
|
||||
Safely update a **source checkout** (git install) of Clawdbot.
|
||||
|
||||
If you installed via **npm/pnpm** (global install, no git metadata), updates happen via the package manager flow in [Updating](/install/updating).
|
||||
If you installed via **npm/pnpm** (global install, no git metadata), use the package manager flow in [Updating](/install/updating).
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -48,16 +48,7 @@ Options:
|
||||
- `--json`: print machine-readable status JSON.
|
||||
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
||||
|
||||
## What it does
|
||||
|
||||
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the
|
||||
install method aligned:
|
||||
|
||||
- `dev` → ensures a git checkout (default: `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
|
||||
updates it, and installs the global CLI from that checkout.
|
||||
- `stable`/`beta` → installs from npm using the matching dist-tag.
|
||||
|
||||
## Git checkout flow
|
||||
## What it does (git checkout)
|
||||
|
||||
Channels:
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
|
||||
# Development channels
|
||||
|
||||
Last updated: 2026-01-21
|
||||
Last updated: 2026-01-20
|
||||
|
||||
Clawdbot ships three update channels:
|
||||
|
||||
@@ -38,13 +38,6 @@ clawdbot update --channel dev
|
||||
|
||||
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
|
||||
|
||||
When you **explicitly** switch channels with `--channel`, Clawdbot also aligns
|
||||
the install method:
|
||||
|
||||
- `dev` ensures a git checkout (default `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
|
||||
updates it, and installs the global CLI from that checkout.
|
||||
- `stable`/`beta` installs from npm using the matching dist-tag.
|
||||
|
||||
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
|
||||
|
||||
## Plugins and channels
|
||||
|
||||
@@ -60,7 +60,6 @@ Text + native (when enabled):
|
||||
- `/commands`
|
||||
- `/skill <name> [input]` (run a skill by name)
|
||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
||||
- `/allowlist` (list/add/remove allowlist entries)
|
||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
||||
@@ -94,7 +93,6 @@ Notes:
|
||||
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
|
||||
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
|
||||
- For full provider usage breakdown, use `clawdbot status --usage`.
|
||||
- `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`.
|
||||
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
|
||||
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
||||
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
||||
|
||||
+15
-15
@@ -21,7 +21,7 @@ handle_sudo_error() {
|
||||
echo -e "\n${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${YELLOW}⚠️ Password Required for Log Access${NC}"
|
||||
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
||||
echo -e "clawlog needs to use sudo to show complete log data (Apple hides sensitive info by default)."
|
||||
echo -e "vtlog needs to use sudo to show complete log data (Apple hides sensitive info by default)."
|
||||
echo -e "\nTo avoid password prompts, configure passwordless sudo for the log command:"
|
||||
echo -e "See: ${BLUE}apple/docs/logging-private-fix.md${NC}\n"
|
||||
echo -e "Quick fix:"
|
||||
@@ -51,7 +51,7 @@ show_usage() {
|
||||
clawlog - Clawdbot Logging Utility
|
||||
|
||||
USAGE:
|
||||
clawlog [OPTIONS]
|
||||
vtlog [OPTIONS]
|
||||
|
||||
DESCRIPTION:
|
||||
View Clawdbot logs with full details (bypasses Apple's privacy redaction).
|
||||
@@ -69,10 +69,10 @@ LOG CATEGORIES (examples):
|
||||
• shell - ShellExecutor
|
||||
|
||||
QUICK START:
|
||||
clawlog -n 100 Show last 100 lines from all components
|
||||
clawlog -f Follow logs in real-time
|
||||
clawlog -e Show only errors
|
||||
clawlog -c ServerManager Show logs from ServerManager only
|
||||
vtlog -n 100 Show last 100 lines from all components
|
||||
vtlog -f Follow logs in real-time
|
||||
vtlog -e Show only errors
|
||||
vtlog -c ServerManager Show logs from ServerManager only
|
||||
|
||||
OPTIONS:
|
||||
-h, --help Show this help message
|
||||
@@ -91,15 +91,15 @@ OPTIONS:
|
||||
--json Output in JSON format
|
||||
|
||||
EXAMPLES:
|
||||
clawlog Show last 50 lines from past 5 minutes (default)
|
||||
clawlog -f Stream logs continuously
|
||||
clawlog -n 100 Show last 100 lines
|
||||
clawlog -e Show only recent errors
|
||||
clawlog -l 30m -n 200 Show last 200 lines from past 30 minutes
|
||||
clawlog -c ServerManager Show recent ServerManager logs
|
||||
clawlog -s "fail" Search for "fail" in recent logs
|
||||
clawlog --server -e Show recent server errors
|
||||
clawlog -f -d Stream debug logs continuously
|
||||
vtlog Show last 50 lines from past 5 minutes (default)
|
||||
vtlog -f Stream logs continuously
|
||||
vtlog -n 100 Show last 100 lines
|
||||
vtlog -e Show only recent errors
|
||||
vtlog -l 30m -n 200 Show last 200 lines from past 30 minutes
|
||||
vtlog -c ServerManager Show recent ServerManager logs
|
||||
vtlog -s "fail" Search for "fail" in recent logs
|
||||
vtlog --server -e Show recent server errors
|
||||
vtlog -f -d Stream debug logs continuously
|
||||
|
||||
CATEGORIES:
|
||||
Common categories include:
|
||||
|
||||
@@ -76,7 +76,7 @@ bash pty:true workdir:~/project background:true command:"codex exec --full-auto
|
||||
# Monitor progress
|
||||
process action:log sessionId:XXX
|
||||
|
||||
# Check if done
|
||||
# Check if done
|
||||
process action:poll sessionId:XXX
|
||||
|
||||
# Send input (if agent asks a question)
|
||||
@@ -217,55 +217,15 @@ git worktree remove /tmp/issue-99
|
||||
|
||||
## ⚠️ Rules
|
||||
|
||||
1. **Always use pty:true** - coding agents need a terminal!
|
||||
2. **Respect tool choice** - if user asks for Codex, use Codex.
|
||||
- Orchestrator mode: do NOT hand-code patches yourself.
|
||||
- If an agent fails/hangs, respawn it or ask the user for direction, but don't silently take over.
|
||||
3. **Be patient** - don't kill sessions because they're "slow"
|
||||
4. **Monitor with process:log** - check progress without interfering
|
||||
5. **--full-auto for building** - auto-approves changes
|
||||
6. **vanilla for reviewing** - no special flags needed
|
||||
7. **Parallel is OK** - run many Codex processes at once for batch work
|
||||
8. **NEVER start Codex in ~/clawd/** - it'll read your soul docs and get weird ideas about the org chart!
|
||||
9. **NEVER checkout branches in ~/Projects/clawdbot/** - that's the LIVE Clawdbot instance!
|
||||
|
||||
---
|
||||
|
||||
## Progress Updates (Critical)
|
||||
|
||||
When you spawn coding agents in the background, keep the user in the loop.
|
||||
|
||||
- Send 1 short message when you start (what's running + where).
|
||||
- Then only update again when something changes:
|
||||
- a milestone completes (build finished, tests passed)
|
||||
- the agent asks a question / needs input
|
||||
- you hit an error or need user action
|
||||
- the agent finishes (include what changed + where)
|
||||
- If you kill a session, immediately say you killed it and why.
|
||||
|
||||
This prevents the user from seeing only "Agent failed before reply" and having no idea what happened.
|
||||
|
||||
---
|
||||
|
||||
## Auto-Notify on Completion
|
||||
|
||||
For long-running background tasks, append a wake trigger to your prompt so Clawdbot gets notified immediately when the agent finishes (instead of waiting for the next heartbeat):
|
||||
|
||||
```
|
||||
... your task here.
|
||||
|
||||
When completely finished, run this command to notify me:
|
||||
clawdbot gateway wake --text "Done: [brief summary of what was built]" --mode now
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
bash pty:true workdir:~/project background:true command:"codex --yolo exec 'Build a REST API for todos.
|
||||
|
||||
When completely finished, run: clawdbot gateway wake --text \"Done: Built todos REST API with CRUD endpoints\" --mode now'"
|
||||
```
|
||||
|
||||
This triggers an immediate wake event — Skippy gets pinged in seconds, not 10 minutes.
|
||||
1. **Always use pty:true** — coding agents need a terminal!
|
||||
2. **Respect tool choice** — if user asks for Codex, use Codex. NEVER offer to build it yourself!
|
||||
3. **Be patient** — don't kill sessions because they're "slow"
|
||||
4. **Monitor with process:log** — check progress without interfering
|
||||
5. **--full-auto for building** — auto-approves changes
|
||||
6. **vanilla for reviewing** — no special flags needed
|
||||
7. **Parallel is OK** — run many Codex processes at once for batch work
|
||||
8. **NEVER start Codex in ~/clawd/** — it'll read your soul docs and get weird ideas about the org chart!
|
||||
9. **NEVER checkout branches in ~/Projects/clawdbot/** — that's the LIVE Clawdbot instance!
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { resolveSessionAuthProfileOverride } from "./session-override.js";
|
||||
|
||||
async function writeAuthStore(agentDir: string) {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
const payload = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"zai:work": { type: "api_key", provider: "zai", key: "sk-test" },
|
||||
},
|
||||
order: {
|
||||
zai: ["zai:work"],
|
||||
},
|
||||
};
|
||||
await fs.writeFile(authPath, JSON.stringify(payload), "utf-8");
|
||||
}
|
||||
|
||||
describe("resolveSessionAuthProfileOverride", () => {
|
||||
it("keeps user override when provider alias differs", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
process.env.CLAWDBOT_STATE_DIR = tmpDir;
|
||||
try {
|
||||
const agentDir = path.join(tmpDir, "agent");
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await writeAuthStore(agentDir);
|
||||
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
authProfileOverride: "zai:work",
|
||||
authProfileOverrideSource: "user",
|
||||
};
|
||||
const sessionStore = { "agent:main:main": sessionEntry };
|
||||
|
||||
const resolved = await resolveSessionAuthProfileOverride({
|
||||
cfg: {} as ClawdbotConfig,
|
||||
provider: "z.ai",
|
||||
agentDir,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "agent:main:main",
|
||||
storePath: undefined,
|
||||
isNewSession: false,
|
||||
});
|
||||
|
||||
expect(resolved).toBe("zai:work");
|
||||
expect(sessionEntry.authProfileOverride).toBe("zai:work");
|
||||
} finally {
|
||||
if (prevStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
||||
else process.env.CLAWDBOT_STATE_DIR = prevStateDir;
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
isProfileInCooldown,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../auth-profiles.js";
|
||||
|
||||
function isProfileForProvider(params: {
|
||||
provider: string;
|
||||
profileId: string;
|
||||
store: ReturnType<typeof ensureAuthProfileStore>;
|
||||
}): boolean {
|
||||
const entry = params.store.profiles[params.profileId];
|
||||
if (!entry?.provider) return false;
|
||||
return normalizeProviderId(entry.provider) === normalizeProviderId(params.provider);
|
||||
}
|
||||
|
||||
export async function clearSessionAuthProfileOverride(params: {
|
||||
sessionEntry: SessionEntry;
|
||||
sessionStore: Record<string, SessionEntry>;
|
||||
sessionKey: string;
|
||||
storePath?: string;
|
||||
}) {
|
||||
const { sessionEntry, sessionStore, sessionKey, storePath } = params;
|
||||
delete sessionEntry.authProfileOverride;
|
||||
delete sessionEntry.authProfileOverrideSource;
|
||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSessionAuthProfileOverride(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
provider: string;
|
||||
agentDir: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
isNewSession: boolean;
|
||||
}): Promise<string | undefined> {
|
||||
const {
|
||||
cfg,
|
||||
provider,
|
||||
agentDir,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
isNewSession,
|
||||
} = params;
|
||||
if (!sessionEntry || !sessionStore || !sessionKey) return sessionEntry?.authProfileOverride;
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||
const order = resolveAuthProfileOrder({ cfg, store, provider });
|
||||
let current = sessionEntry.authProfileOverride?.trim();
|
||||
|
||||
if (current && !store.profiles[current]) {
|
||||
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
|
||||
current = undefined;
|
||||
}
|
||||
|
||||
if (current && !isProfileForProvider({ provider, profileId: current, store })) {
|
||||
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
|
||||
current = undefined;
|
||||
}
|
||||
|
||||
if (current && order.length > 0 && !order.includes(current)) {
|
||||
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
|
||||
current = undefined;
|
||||
}
|
||||
|
||||
if (order.length === 0) return undefined;
|
||||
|
||||
const pickFirstAvailable = () =>
|
||||
order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
|
||||
const pickNextAvailable = (active: string) => {
|
||||
const startIndex = order.indexOf(active);
|
||||
if (startIndex < 0) return pickFirstAvailable();
|
||||
for (let offset = 1; offset <= order.length; offset += 1) {
|
||||
const candidate = order[(startIndex + offset) % order.length];
|
||||
if (!isProfileInCooldown(store, candidate)) return candidate;
|
||||
}
|
||||
return order[startIndex] ?? order[0];
|
||||
};
|
||||
|
||||
const compactionCount = sessionEntry.compactionCount ?? 0;
|
||||
const storedCompaction =
|
||||
typeof sessionEntry.authProfileOverrideCompactionCount === "number"
|
||||
? sessionEntry.authProfileOverrideCompactionCount
|
||||
: compactionCount;
|
||||
|
||||
const source =
|
||||
sessionEntry.authProfileOverrideSource ??
|
||||
(typeof sessionEntry.authProfileOverrideCompactionCount === "number"
|
||||
? "auto"
|
||||
: current
|
||||
? "user"
|
||||
: undefined);
|
||||
if (source === "user" && current && !isNewSession) {
|
||||
return current;
|
||||
}
|
||||
|
||||
let next = current;
|
||||
if (isNewSession) {
|
||||
next = current ? pickNextAvailable(current) : pickFirstAvailable();
|
||||
} else if (current && compactionCount > storedCompaction) {
|
||||
next = pickNextAvailable(current);
|
||||
} else if (!current || isProfileInCooldown(store, current)) {
|
||||
next = pickFirstAvailable();
|
||||
}
|
||||
|
||||
if (!next) return current;
|
||||
const shouldPersist =
|
||||
next !== sessionEntry.authProfileOverride ||
|
||||
sessionEntry.authProfileOverrideSource !== "auto" ||
|
||||
sessionEntry.authProfileOverrideCompactionCount !== compactionCount;
|
||||
if (shouldPersist) {
|
||||
sessionEntry.authProfileOverride = next;
|
||||
sessionEntry.authProfileOverrideSource = "auto";
|
||||
sessionEntry.authProfileOverrideCompactionCount = compactionCount;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -92,16 +92,13 @@ const makeConfig = (): ClawdbotConfig =>
|
||||
},
|
||||
}) satisfies ClawdbotConfig;
|
||||
|
||||
const writeAuthStore = async (agentDir: string, opts?: { includeAnthropic?: boolean }) => {
|
||||
const writeAuthStore = async (agentDir: string) => {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
const payload = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
|
||||
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
|
||||
...(opts?.includeAnthropic
|
||||
? { "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-anth" } }
|
||||
: {}),
|
||||
},
|
||||
usageStats: {
|
||||
"openai:p1": { lastUsed: 1 },
|
||||
@@ -209,43 +206,4 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores user-locked profile when provider mismatches", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
try {
|
||||
await writeAuthStore(agentDir, { includeAnthropic: true });
|
||||
|
||||
runEmbeddedAttemptMock.mockResolvedValueOnce(
|
||||
makeAttempt({
|
||||
assistantTexts: ["ok"],
|
||||
lastAssistant: buildAssistant({
|
||||
stopReason: "stop",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:mismatch",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig(),
|
||||
prompt: "hello",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
authProfileId: "anthropic:default",
|
||||
authProfileIdSource: "user",
|
||||
timeoutMs: 5_000,
|
||||
runId: "run:mismatch",
|
||||
});
|
||||
|
||||
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
resolveAuthProfileOrder,
|
||||
type ResolvedProviderAuth,
|
||||
} from "../model-auth.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import { ensureClawdbotModelsJson } from "../models-config.js";
|
||||
import {
|
||||
classifyFailoverReason,
|
||||
@@ -117,16 +116,8 @@ export async function runEmbeddedPiAgent(
|
||||
|
||||
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||
const preferredProfileId = params.authProfileId?.trim();
|
||||
let lockedProfileId = params.authProfileIdSource === "user" ? preferredProfileId : undefined;
|
||||
if (lockedProfileId) {
|
||||
const lockedProfile = authStore.profiles[lockedProfileId];
|
||||
if (
|
||||
!lockedProfile ||
|
||||
normalizeProviderId(lockedProfile.provider) !== normalizeProviderId(provider)
|
||||
) {
|
||||
lockedProfileId = undefined;
|
||||
}
|
||||
}
|
||||
const lockedProfileId =
|
||||
params.authProfileIdSource === "user" ? preferredProfileId : undefined;
|
||||
const profileOrder = resolveAuthProfileOrder({
|
||||
cfg: params.config,
|
||||
store: authStore,
|
||||
|
||||
@@ -39,10 +39,6 @@ export type EmbeddedPiSubscribeState = {
|
||||
lastStreamedAssistant?: string;
|
||||
lastStreamedReasoning?: string;
|
||||
lastBlockReplyText?: string;
|
||||
assistantMessageIndex: number;
|
||||
lastAssistantTextMessageIndex: number;
|
||||
lastAssistantTextNormalized?: string;
|
||||
lastAssistantTextTrimmed?: string;
|
||||
assistantTextBaseline: number;
|
||||
suppressBlockChunks: boolean;
|
||||
lastReasoningSent?: string;
|
||||
|
||||
-29
@@ -86,35 +86,6 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
|
||||
expect(subscription.assistantTexts).toEqual(["Hello world"]);
|
||||
});
|
||||
it("does not duplicate assistantTexts when message_end repeats with trailing whitespace changes", () => {
|
||||
let handler: SessionEventHandler | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
handler = fn;
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
const subscription = subscribeEmbeddedPiSession({
|
||||
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
|
||||
runId: "run",
|
||||
});
|
||||
|
||||
const assistantMessageWithNewline = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello world\n" }],
|
||||
} as AssistantMessage;
|
||||
|
||||
const assistantMessageTrimmed = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hello world" }],
|
||||
} as AssistantMessage;
|
||||
|
||||
handler?.({ type: "message_end", message: assistantMessageWithNewline });
|
||||
handler?.({ type: "message_end", message: assistantMessageTrimmed });
|
||||
|
||||
expect(subscription.assistantTexts).toEqual(["Hello world"]);
|
||||
});
|
||||
it("does not duplicate assistantTexts when message_end repeats with reasoning blocks", () => {
|
||||
let handler: SessionEventHandler | undefined;
|
||||
const session: StubSession = {
|
||||
|
||||
@@ -48,10 +48,6 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
lastStreamedAssistant: undefined,
|
||||
lastStreamedReasoning: undefined,
|
||||
lastBlockReplyText: undefined,
|
||||
assistantMessageIndex: 0,
|
||||
lastAssistantTextMessageIndex: -1,
|
||||
lastAssistantTextNormalized: undefined,
|
||||
lastAssistantTextTrimmed: undefined,
|
||||
assistantTextBaseline: 0,
|
||||
suppressBlockChunks: false, // Avoid late chunk inserts after final text merge.
|
||||
lastReasoningSent: undefined,
|
||||
@@ -88,36 +84,9 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
state.lastStreamedReasoning = undefined;
|
||||
state.lastReasoningSent = undefined;
|
||||
state.suppressBlockChunks = false;
|
||||
state.assistantMessageIndex += 1;
|
||||
state.lastAssistantTextMessageIndex = -1;
|
||||
state.lastAssistantTextNormalized = undefined;
|
||||
state.lastAssistantTextTrimmed = undefined;
|
||||
state.assistantTextBaseline = nextAssistantTextBaseline;
|
||||
};
|
||||
|
||||
const rememberAssistantText = (text: string) => {
|
||||
state.lastAssistantTextMessageIndex = state.assistantMessageIndex;
|
||||
state.lastAssistantTextTrimmed = text.trimEnd();
|
||||
const normalized = normalizeTextForComparison(text);
|
||||
state.lastAssistantTextNormalized = normalized.length > 0 ? normalized : undefined;
|
||||
};
|
||||
|
||||
const shouldSkipAssistantText = (text: string) => {
|
||||
if (state.lastAssistantTextMessageIndex !== state.assistantMessageIndex) return false;
|
||||
const trimmed = text.trimEnd();
|
||||
if (trimmed && trimmed === state.lastAssistantTextTrimmed) return true;
|
||||
const normalized = normalizeTextForComparison(text);
|
||||
if (normalized.length > 0 && normalized === state.lastAssistantTextNormalized) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const pushAssistantText = (text: string) => {
|
||||
if (!text) return;
|
||||
if (shouldSkipAssistantText(text)) return;
|
||||
assistantTexts.push(text);
|
||||
rememberAssistantText(text);
|
||||
};
|
||||
|
||||
const finalizeAssistantTexts = (args: {
|
||||
text: string;
|
||||
addedDuringMessage: boolean;
|
||||
@@ -134,15 +103,16 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
assistantTexts.length - state.assistantTextBaseline,
|
||||
text,
|
||||
);
|
||||
rememberAssistantText(text);
|
||||
} else {
|
||||
pushAssistantText(text);
|
||||
const last = assistantTexts.at(-1);
|
||||
if (!last || last !== text) assistantTexts.push(text);
|
||||
}
|
||||
state.suppressBlockChunks = true;
|
||||
} else if (!addedDuringMessage && !chunkerHasBuffered && text) {
|
||||
// Non-streaming models (no text_delta): ensure assistantTexts gets the final
|
||||
// text when the chunker has nothing buffered to drain.
|
||||
pushAssistantText(text);
|
||||
const last = assistantTexts.at(-1);
|
||||
if (!last || last !== text) assistantTexts.push(text);
|
||||
}
|
||||
|
||||
state.assistantTextBaseline = assistantTexts.length;
|
||||
@@ -368,11 +338,8 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldSkipAssistantText(chunk)) return;
|
||||
|
||||
state.lastBlockReplyText = chunk;
|
||||
assistantTexts.push(chunk);
|
||||
rememberAssistantText(chunk);
|
||||
if (!params.onBlockReply) return;
|
||||
const splitResult = parseReplyDirectives(chunk);
|
||||
const {
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
DEFAULT_AGENT_ID,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../../routing/session-key.js";
|
||||
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { readStringParam } from "./common.js";
|
||||
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
||||
@@ -241,7 +240,6 @@ export function createSessionStatusTool(opts?: {
|
||||
throw new Error(`Unknown sessionKey: ${requestedKeyRaw}`);
|
||||
}
|
||||
|
||||
const configured = resolveDefaultModelForAgent({ cfg, agentId });
|
||||
const modelRaw = readStringParam(params, "model");
|
||||
let changedModel = false;
|
||||
if (typeof modelRaw === "string") {
|
||||
@@ -251,33 +249,33 @@ export function createSessionStatusTool(opts?: {
|
||||
sessionEntry: resolved.entry,
|
||||
agentId,
|
||||
});
|
||||
const nextEntry: SessionEntry = { ...resolved.entry };
|
||||
const applied = applyModelOverrideToSessionEntry({
|
||||
entry: nextEntry,
|
||||
selection:
|
||||
selection.kind === "reset"
|
||||
? {
|
||||
provider: configured.provider,
|
||||
model: configured.model,
|
||||
isDefault: true,
|
||||
}
|
||||
: {
|
||||
provider: selection.provider,
|
||||
model: selection.model,
|
||||
isDefault: selection.isDefault,
|
||||
},
|
||||
});
|
||||
if (applied.updated) {
|
||||
store[resolved.key] = nextEntry;
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
nextStore[resolved.key] = nextEntry;
|
||||
});
|
||||
resolved.entry = nextEntry;
|
||||
changedModel = true;
|
||||
const nextEntry: SessionEntry = {
|
||||
...resolved.entry,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (selection.kind === "reset" || selection.isDefault) {
|
||||
delete nextEntry.providerOverride;
|
||||
delete nextEntry.modelOverride;
|
||||
delete nextEntry.authProfileOverride;
|
||||
delete nextEntry.authProfileOverrideSource;
|
||||
delete nextEntry.authProfileOverrideCompactionCount;
|
||||
} else {
|
||||
nextEntry.providerOverride = selection.provider;
|
||||
nextEntry.modelOverride = selection.model;
|
||||
delete nextEntry.authProfileOverride;
|
||||
delete nextEntry.authProfileOverrideSource;
|
||||
delete nextEntry.authProfileOverrideCompactionCount;
|
||||
}
|
||||
store[resolved.key] = nextEntry;
|
||||
await updateSessionStore(storePath, (nextStore) => {
|
||||
nextStore[resolved.key] = nextEntry;
|
||||
});
|
||||
resolved.entry = nextEntry;
|
||||
changedModel = true;
|
||||
}
|
||||
|
||||
const agentDir = resolveAgentDir(cfg, agentId);
|
||||
const configured = resolveDefaultModelForAgent({ cfg, agentId });
|
||||
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
|
||||
const usageProvider = resolveUsageProviderId(providerForCard);
|
||||
let usageLine: string | undefined;
|
||||
|
||||
@@ -157,13 +157,6 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
description: "Show current status.",
|
||||
textAlias: "/status",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "allowlist",
|
||||
description: "List/add/remove allowlist entries.",
|
||||
textAlias: "/allowlist",
|
||||
acceptsArgs: true,
|
||||
scope: "text",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "context",
|
||||
nativeName: "context",
|
||||
|
||||
@@ -16,21 +16,19 @@ export const createShouldEmitToolResult = (params: {
|
||||
storePath?: string;
|
||||
resolvedVerboseLevel: VerboseLevel;
|
||||
}): (() => boolean) => {
|
||||
// Normalize verbose values from session store/config so false/"false" still means off.
|
||||
const fallbackVerbose = normalizeVerboseLevel(String(params.resolvedVerboseLevel ?? "")) ?? "off";
|
||||
return () => {
|
||||
if (!params.sessionKey || !params.storePath) {
|
||||
return fallbackVerbose !== "off";
|
||||
return params.resolvedVerboseLevel !== "off";
|
||||
}
|
||||
try {
|
||||
const store = loadSessionStore(params.storePath);
|
||||
const entry = store[params.sessionKey];
|
||||
const current = normalizeVerboseLevel(String(entry?.verboseLevel ?? ""));
|
||||
const current = normalizeVerboseLevel(entry?.verboseLevel);
|
||||
if (current) return current !== "off";
|
||||
} catch {
|
||||
// ignore store read failures
|
||||
}
|
||||
return fallbackVerbose !== "off";
|
||||
return params.resolvedVerboseLevel !== "off";
|
||||
};
|
||||
};
|
||||
|
||||
@@ -39,21 +37,19 @@ export const createShouldEmitToolOutput = (params: {
|
||||
storePath?: string;
|
||||
resolvedVerboseLevel: VerboseLevel;
|
||||
}): (() => boolean) => {
|
||||
// Normalize verbose values from session store/config so false/"false" still means off.
|
||||
const fallbackVerbose = normalizeVerboseLevel(String(params.resolvedVerboseLevel ?? "")) ?? "off";
|
||||
return () => {
|
||||
if (!params.sessionKey || !params.storePath) {
|
||||
return fallbackVerbose === "full";
|
||||
return params.resolvedVerboseLevel === "full";
|
||||
}
|
||||
try {
|
||||
const store = loadSessionStore(params.storePath);
|
||||
const entry = store[params.sessionKey];
|
||||
const current = normalizeVerboseLevel(String(entry?.verboseLevel ?? ""));
|
||||
const current = normalizeVerboseLevel(entry?.verboseLevel);
|
||||
if (current) return current === "full";
|
||||
} catch {
|
||||
// ignore store read failures
|
||||
}
|
||||
return fallbackVerbose === "full";
|
||||
return params.resolvedVerboseLevel === "full";
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
|
||||
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
|
||||
const writeConfigFileMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../config/config.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
|
||||
writeConfigFile: writeConfigFileMock,
|
||||
};
|
||||
});
|
||||
|
||||
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../pairing/pairing-store.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
|
||||
"../../pairing/pairing-store.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
||||
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
|
||||
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../channels/plugins/pairing.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
|
||||
"../../channels/plugins/pairing.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
listPairingChannels: () => ["telegram"],
|
||||
};
|
||||
});
|
||||
|
||||
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||
const ctx = {
|
||||
Body: commandBody,
|
||||
CommandBody: commandBody,
|
||||
CommandSource: "text",
|
||||
CommandAuthorized: true,
|
||||
Provider: "telegram",
|
||||
Surface: "telegram",
|
||||
...ctxOverrides,
|
||||
} as MsgContext;
|
||||
|
||||
const command = buildCommandContext({
|
||||
ctx,
|
||||
cfg,
|
||||
isGroup: false,
|
||||
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
cfg,
|
||||
command,
|
||||
directives: parseInlineDirectives(commandBody),
|
||||
elevated: { enabled: true, allowed: true, failures: [] },
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: "/tmp",
|
||||
defaultGroupActivation: () => "mention",
|
||||
resolvedVerboseLevel: "off" as const,
|
||||
resolvedReasoningLevel: "off" as const,
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
provider: "telegram",
|
||||
model: "test-model",
|
||||
contextTokens: 0,
|
||||
isGroup: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleCommands /allowlist", () => {
|
||||
it("lists config + store allowFrom entries", async () => {
|
||||
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/allowlist list dm", cfg);
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Channel: telegram");
|
||||
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
|
||||
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
|
||||
});
|
||||
|
||||
it("adds entries to config and pairing store", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||
valid: true,
|
||||
parsed: {
|
||||
channels: { telegram: { allowFrom: ["123"] } },
|
||||
},
|
||||
});
|
||||
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||
ok: true,
|
||||
config,
|
||||
}));
|
||||
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
||||
changed: true,
|
||||
allowFrom: ["123", "789"],
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
commands: { text: true, config: true },
|
||||
channels: { telegram: { allowFrom: ["123"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/allowlist add dm 789", cfg);
|
||||
const result = await handleCommands(params);
|
||||
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: { telegram: { allowFrom: ["123", "789"] } },
|
||||
}),
|
||||
);
|
||||
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
||||
channel: "telegram",
|
||||
entry: "789",
|
||||
});
|
||||
expect(result.reply?.text).toContain("DM allowlist added");
|
||||
});
|
||||
});
|
||||
@@ -1,657 +0,0 @@
|
||||
import {
|
||||
readConfigFileSnapshot,
|
||||
validateConfigObjectWithPlugins,
|
||||
writeConfigFile,
|
||||
} from "../../config/config.js";
|
||||
import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
|
||||
import { getChannelDock } from "../../channels/dock.js";
|
||||
import { normalizeChannelId } from "../../channels/registry.js";
|
||||
import { listPairingChannels } from "../../channels/plugins/pairing.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { resolveDiscordAccount } from "../../discord/accounts.js";
|
||||
import { resolveIMessageAccount } from "../../imessage/accounts.js";
|
||||
import { resolveSignalAccount } from "../../signal/accounts.js";
|
||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||
import { resolveTelegramAccount } from "../../telegram/accounts.js";
|
||||
import { resolveWhatsAppAccount } from "../../web/accounts.js";
|
||||
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
|
||||
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
|
||||
import {
|
||||
addChannelAllowFromStoreEntry,
|
||||
readChannelAllowFromStore,
|
||||
removeChannelAllowFromStoreEntry,
|
||||
} from "../../pairing/pairing-store.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
type AllowlistScope = "dm" | "group" | "all";
|
||||
type AllowlistAction = "list" | "add" | "remove";
|
||||
type AllowlistTarget = "both" | "config" | "store";
|
||||
|
||||
type AllowlistCommand =
|
||||
| {
|
||||
action: "list";
|
||||
scope: AllowlistScope;
|
||||
channel?: string;
|
||||
account?: string;
|
||||
resolve?: boolean;
|
||||
}
|
||||
| {
|
||||
action: "add" | "remove";
|
||||
scope: AllowlistScope;
|
||||
channel?: string;
|
||||
account?: string;
|
||||
entry: string;
|
||||
resolve?: boolean;
|
||||
target: AllowlistTarget;
|
||||
}
|
||||
| { action: "error"; message: string };
|
||||
|
||||
const ACTIONS = new Set(["list", "add", "remove"]);
|
||||
const SCOPES = new Set<AllowlistScope>(["dm", "group", "all"]);
|
||||
|
||||
function parseAllowlistCommand(raw: string): AllowlistCommand | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.toLowerCase().startsWith("/allowlist")) return null;
|
||||
const rest = trimmed.slice("/allowlist".length).trim();
|
||||
if (!rest) return { action: "list", scope: "dm" };
|
||||
|
||||
const tokens = rest.split(/\s+/);
|
||||
let action: AllowlistAction = "list";
|
||||
let scope: AllowlistScope = "dm";
|
||||
let resolve = false;
|
||||
let target: AllowlistTarget = "both";
|
||||
let channel: string | undefined;
|
||||
let account: string | undefined;
|
||||
const entryTokens: string[] = [];
|
||||
|
||||
let i = 0;
|
||||
if (tokens[i] && ACTIONS.has(tokens[i].toLowerCase())) {
|
||||
action = tokens[i].toLowerCase() as AllowlistAction;
|
||||
i += 1;
|
||||
}
|
||||
if (tokens[i] && SCOPES.has(tokens[i].toLowerCase() as AllowlistScope)) {
|
||||
scope = tokens[i].toLowerCase() as AllowlistScope;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
for (; i < tokens.length; i += 1) {
|
||||
const token = tokens[i];
|
||||
const lowered = token.toLowerCase();
|
||||
if (lowered === "--resolve" || lowered === "resolve") {
|
||||
resolve = true;
|
||||
continue;
|
||||
}
|
||||
if (lowered === "--config" || lowered === "config") {
|
||||
target = "config";
|
||||
continue;
|
||||
}
|
||||
if (lowered === "--store" || lowered === "store") {
|
||||
target = "store";
|
||||
continue;
|
||||
}
|
||||
if (lowered === "--channel" && tokens[i + 1]) {
|
||||
channel = tokens[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (lowered === "--account" && tokens[i + 1]) {
|
||||
account = tokens[i + 1];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const kv = token.split("=");
|
||||
if (kv.length === 2) {
|
||||
const key = kv[0]?.trim().toLowerCase();
|
||||
const value = kv[1]?.trim();
|
||||
if (key === "channel") {
|
||||
if (value) channel = value;
|
||||
continue;
|
||||
}
|
||||
if (key === "account") {
|
||||
if (value) account = value;
|
||||
continue;
|
||||
}
|
||||
if (key === "scope" && value && SCOPES.has(value.toLowerCase() as AllowlistScope)) {
|
||||
scope = value.toLowerCase() as AllowlistScope;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
entryTokens.push(token);
|
||||
}
|
||||
|
||||
if (action === "add" || action === "remove") {
|
||||
const entry = entryTokens.join(" ").trim();
|
||||
if (!entry) {
|
||||
return { action: "error", message: "Usage: /allowlist add|remove <entry>" };
|
||||
}
|
||||
return { action, scope, entry, channel, account, resolve, target };
|
||||
}
|
||||
|
||||
return { action: "list", scope, channel, account, resolve };
|
||||
}
|
||||
|
||||
function normalizeAllowFrom(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
channelId: ChannelId;
|
||||
accountId?: string | null;
|
||||
values: Array<string | number>;
|
||||
}): string[] {
|
||||
const dock = getChannelDock(params.channelId);
|
||||
if (dock?.config?.formatAllowFrom) {
|
||||
return dock.config.formatAllowFrom({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
allowFrom: params.values,
|
||||
});
|
||||
}
|
||||
return params.values.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function formatEntryList(entries: string[], resolved?: Map<string, string>): string {
|
||||
if (entries.length === 0) return "(none)";
|
||||
return entries
|
||||
.map((entry) => {
|
||||
const name = resolved?.get(entry);
|
||||
return name ? `${entry} (${name})` : entry;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function resolveAccountTarget(
|
||||
parsed: Record<string, unknown>,
|
||||
channelId: ChannelId,
|
||||
accountId?: string | null,
|
||||
) {
|
||||
const channels = (parsed.channels ??= {}) as Record<string, unknown>;
|
||||
const channel = (channels[channelId] ??= {}) as Record<string, unknown>;
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object");
|
||||
const useAccount = normalizedAccountId !== DEFAULT_ACCOUNT_ID || hasAccounts;
|
||||
if (!useAccount) {
|
||||
return { target: channel, pathPrefix: `channels.${channelId}`, accountId: normalizedAccountId };
|
||||
}
|
||||
const accounts = (channel.accounts ??= {}) as Record<string, unknown>;
|
||||
const account = (accounts[normalizedAccountId] ??= {}) as Record<string, unknown>;
|
||||
return {
|
||||
target: account,
|
||||
pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`,
|
||||
accountId: normalizedAccountId,
|
||||
};
|
||||
}
|
||||
|
||||
function getNestedValue(root: Record<string, unknown>, path: string[]): unknown {
|
||||
let current: unknown = root;
|
||||
for (const key of path) {
|
||||
if (!current || typeof current !== "object") return undefined;
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function ensureNestedObject(
|
||||
root: Record<string, unknown>,
|
||||
path: string[],
|
||||
): Record<string, unknown> {
|
||||
let current = root;
|
||||
for (const key of path) {
|
||||
const existing = current[key];
|
||||
if (!existing || typeof existing !== "object") {
|
||||
current[key] = {};
|
||||
}
|
||||
current = current[key] as Record<string, unknown>;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function setNestedValue(root: Record<string, unknown>, path: string[], value: unknown) {
|
||||
if (path.length === 0) return;
|
||||
if (path.length === 1) {
|
||||
root[path[0]] = value;
|
||||
return;
|
||||
}
|
||||
const parent = ensureNestedObject(root, path.slice(0, -1));
|
||||
parent[path[path.length - 1]] = value;
|
||||
}
|
||||
|
||||
function deleteNestedValue(root: Record<string, unknown>, path: string[]) {
|
||||
if (path.length === 0) return;
|
||||
if (path.length === 1) {
|
||||
delete root[path[0]];
|
||||
return;
|
||||
}
|
||||
const parent = getNestedValue(root, path.slice(0, -1));
|
||||
if (!parent || typeof parent !== "object") return;
|
||||
delete (parent as Record<string, unknown>)[path[path.length - 1]];
|
||||
}
|
||||
|
||||
function resolveChannelAllowFromPaths(
|
||||
channelId: ChannelId,
|
||||
scope: AllowlistScope,
|
||||
): string[] | null {
|
||||
if (scope === "all") return null;
|
||||
if (scope === "dm") {
|
||||
if (channelId === "slack" || channelId === "discord") return ["dm", "allowFrom"];
|
||||
if (
|
||||
channelId === "telegram" ||
|
||||
channelId === "whatsapp" ||
|
||||
channelId === "signal" ||
|
||||
channelId === "imessage"
|
||||
) {
|
||||
return ["allowFrom"];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (scope === "group") {
|
||||
if (
|
||||
channelId === "telegram" ||
|
||||
channelId === "whatsapp" ||
|
||||
channelId === "signal" ||
|
||||
channelId === "imessage"
|
||||
) {
|
||||
return ["groupAllowFrom"];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveSlackNames(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
entries: string[];
|
||||
}) {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const token = account.config.userToken?.trim() || account.botToken?.trim();
|
||||
if (!token) return new Map<string, string>();
|
||||
const resolved = await resolveSlackUserAllowlist({ token, entries: params.entries });
|
||||
const map = new Map<string, string>();
|
||||
for (const entry of resolved) {
|
||||
if (entry.resolved && entry.name) map.set(entry.input, entry.name);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function resolveDiscordNames(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId?: string | null;
|
||||
entries: string[];
|
||||
}) {
|
||||
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const token = account.token?.trim();
|
||||
if (!token) return new Map<string, string>();
|
||||
const resolved = await resolveDiscordUserAllowlist({ token, entries: params.entries });
|
||||
const map = new Map<string, string>();
|
||||
for (const entry of resolved) {
|
||||
if (entry.resolved && entry.name) map.set(entry.input, entry.name);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export const handleAllowlistCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const parsed = parseAllowlistCommand(params.command.commandBodyNormalized);
|
||||
if (!parsed) return null;
|
||||
if (parsed.action === "error") {
|
||||
return { shouldContinue: false, reply: { text: `⚠️ ${parsed.message}` } };
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /allowlist from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
const channelId =
|
||||
normalizeChannelId(parsed.channel) ??
|
||||
params.command.channelId ??
|
||||
normalizeChannelId(params.command.channel);
|
||||
if (!channelId) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ Unknown channel. Add channel=<id> to the command." },
|
||||
};
|
||||
}
|
||||
const accountId = normalizeAccountId(parsed.account ?? params.ctx.AccountId);
|
||||
const scope = parsed.scope;
|
||||
|
||||
if (parsed.action === "list") {
|
||||
const pairingChannels = listPairingChannels();
|
||||
const supportsStore = pairingChannels.includes(channelId);
|
||||
const storeAllowFrom = supportsStore
|
||||
? await readChannelAllowFromStore(channelId).catch(() => [])
|
||||
: [];
|
||||
|
||||
let dmAllowFrom: string[] = [];
|
||||
let groupAllowFrom: string[] = [];
|
||||
let groupOverrides: Array<{ label: string; entries: string[] }> = [];
|
||||
let dmPolicy: string | undefined;
|
||||
let groupPolicy: string | undefined;
|
||||
|
||||
if (channelId === "telegram") {
|
||||
const account = resolveTelegramAccount({ cfg: params.cfg, accountId });
|
||||
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
|
||||
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
|
||||
dmPolicy = account.config.dmPolicy;
|
||||
groupPolicy = account.config.groupPolicy;
|
||||
const groups = account.config.groups ?? {};
|
||||
for (const [groupId, groupCfg] of Object.entries(groups)) {
|
||||
const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean);
|
||||
if (entries.length > 0) {
|
||||
groupOverrides.push({ label: groupId, entries });
|
||||
}
|
||||
const topics = groupCfg?.topics ?? {};
|
||||
for (const [topicId, topicCfg] of Object.entries(topics)) {
|
||||
const topicEntries = (topicCfg?.allowFrom ?? []).map(String).filter(Boolean);
|
||||
if (topicEntries.length > 0) {
|
||||
groupOverrides.push({ label: `${groupId} topic ${topicId}`, entries: topicEntries });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (channelId === "whatsapp") {
|
||||
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId });
|
||||
dmAllowFrom = (account.allowFrom ?? []).map(String);
|
||||
groupAllowFrom = (account.groupAllowFrom ?? []).map(String);
|
||||
dmPolicy = account.dmPolicy;
|
||||
groupPolicy = account.groupPolicy;
|
||||
} else if (channelId === "signal") {
|
||||
const account = resolveSignalAccount({ cfg: params.cfg, accountId });
|
||||
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
|
||||
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
|
||||
dmPolicy = account.config.dmPolicy;
|
||||
groupPolicy = account.config.groupPolicy;
|
||||
} else if (channelId === "imessage") {
|
||||
const account = resolveIMessageAccount({ cfg: params.cfg, accountId });
|
||||
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
|
||||
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
|
||||
dmPolicy = account.config.dmPolicy;
|
||||
groupPolicy = account.config.groupPolicy;
|
||||
} else if (channelId === "slack") {
|
||||
const account = resolveSlackAccount({ cfg: params.cfg, accountId });
|
||||
dmAllowFrom = (account.dm?.allowFrom ?? []).map(String);
|
||||
groupPolicy = account.groupPolicy;
|
||||
const channels = account.channels ?? {};
|
||||
groupOverrides = Object.entries(channels)
|
||||
.map(([key, value]) => {
|
||||
const entries = (value?.users ?? []).map(String).filter(Boolean);
|
||||
return entries.length > 0 ? { label: key, entries } : null;
|
||||
})
|
||||
.filter(Boolean) as Array<{ label: string; entries: string[] }>;
|
||||
} else if (channelId === "discord") {
|
||||
const account = resolveDiscordAccount({ cfg: params.cfg, accountId });
|
||||
dmAllowFrom = (account.config.dm?.allowFrom ?? []).map(String);
|
||||
groupPolicy = account.config.groupPolicy;
|
||||
const guilds = account.config.guilds ?? {};
|
||||
for (const [guildKey, guildCfg] of Object.entries(guilds)) {
|
||||
const entries = (guildCfg?.users ?? []).map(String).filter(Boolean);
|
||||
if (entries.length > 0) {
|
||||
groupOverrides.push({ label: `guild ${guildKey}`, entries });
|
||||
}
|
||||
const channels = guildCfg?.channels ?? {};
|
||||
for (const [channelKey, channelCfg] of Object.entries(channels)) {
|
||||
const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean);
|
||||
if (channelEntries.length > 0) {
|
||||
groupOverrides.push({
|
||||
label: `guild ${guildKey} / channel ${channelKey}`,
|
||||
entries: channelEntries,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dmDisplay = normalizeAllowFrom({
|
||||
cfg: params.cfg,
|
||||
channelId,
|
||||
accountId,
|
||||
values: dmAllowFrom,
|
||||
});
|
||||
const groupDisplay = normalizeAllowFrom({
|
||||
cfg: params.cfg,
|
||||
channelId,
|
||||
accountId,
|
||||
values: groupAllowFrom,
|
||||
});
|
||||
const groupOverrideEntries = groupOverrides.flatMap((entry) => entry.entries);
|
||||
const groupOverrideDisplay = normalizeAllowFrom({
|
||||
cfg: params.cfg,
|
||||
channelId,
|
||||
accountId,
|
||||
values: groupOverrideEntries,
|
||||
});
|
||||
const resolvedDm =
|
||||
parsed.resolve && dmDisplay.length > 0 && channelId === "slack"
|
||||
? await resolveSlackNames({ cfg: params.cfg, accountId, entries: dmDisplay })
|
||||
: parsed.resolve && dmDisplay.length > 0 && channelId === "discord"
|
||||
? await resolveDiscordNames({ cfg: params.cfg, accountId, entries: dmDisplay })
|
||||
: undefined;
|
||||
const resolvedGroup =
|
||||
parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "slack"
|
||||
? await resolveSlackNames({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
entries: groupOverrideDisplay,
|
||||
})
|
||||
: parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "discord"
|
||||
? await resolveDiscordNames({
|
||||
cfg: params.cfg,
|
||||
accountId,
|
||||
entries: groupOverrideDisplay,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const lines: string[] = ["🧾 Allowlist"];
|
||||
lines.push(`Channel: ${channelId}${accountId ? ` (account ${accountId})` : ""}`);
|
||||
if (dmPolicy) lines.push(`DM policy: ${dmPolicy}`);
|
||||
if (groupPolicy) lines.push(`Group policy: ${groupPolicy}`);
|
||||
|
||||
const showDm = scope === "dm" || scope === "all";
|
||||
const showGroup = scope === "group" || scope === "all";
|
||||
if (showDm) {
|
||||
lines.push(`DM allowFrom (config): ${formatEntryList(dmDisplay, resolvedDm)}`);
|
||||
}
|
||||
if (supportsStore && storeAllowFrom.length > 0) {
|
||||
const storeLabel = normalizeAllowFrom({
|
||||
cfg: params.cfg,
|
||||
channelId,
|
||||
accountId,
|
||||
values: storeAllowFrom,
|
||||
});
|
||||
lines.push(`Paired allowFrom (store): ${formatEntryList(storeLabel)}`);
|
||||
}
|
||||
if (showGroup) {
|
||||
if (groupAllowFrom.length > 0) {
|
||||
lines.push(`Group allowFrom (config): ${formatEntryList(groupDisplay)}`);
|
||||
}
|
||||
if (groupOverrides.length > 0) {
|
||||
lines.push("Group overrides:");
|
||||
for (const entry of groupOverrides) {
|
||||
const normalized = normalizeAllowFrom({
|
||||
cfg: params.cfg,
|
||||
channelId,
|
||||
accountId,
|
||||
values: entry.entries,
|
||||
});
|
||||
lines.push(`- ${entry.label}: ${formatEntryList(normalized, resolvedGroup)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { shouldContinue: false, reply: { text: lines.join("\n") } };
|
||||
}
|
||||
|
||||
if (params.cfg.commands?.config !== true) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ /allowlist edits are disabled. Set commands.config=true to enable." },
|
||||
};
|
||||
}
|
||||
|
||||
const shouldUpdateConfig = parsed.target !== "store";
|
||||
const shouldTouchStore = parsed.target !== "config" && listPairingChannels().includes(channelId);
|
||||
|
||||
if (shouldUpdateConfig) {
|
||||
const allowWrites = resolveChannelConfigWrites({
|
||||
cfg: params.cfg,
|
||||
channelId,
|
||||
accountId: params.ctx.AccountId,
|
||||
});
|
||||
if (!allowWrites) {
|
||||
const hint = `channels.${channelId}.configWrites=true`;
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ Config writes are disabled for ${channelId}. Set ${hint} to enable.` },
|
||||
};
|
||||
}
|
||||
|
||||
const allowlistPath = resolveChannelAllowFromPaths(channelId, scope);
|
||||
if (!allowlistPath) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `⚠️ ${channelId} does not support ${scope} allowlist edits via /allowlist.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ Config file is invalid; fix it before using /allowlist." },
|
||||
};
|
||||
}
|
||||
const parsedConfig = structuredClone(snapshot.parsed as Record<string, unknown>);
|
||||
const {
|
||||
target,
|
||||
pathPrefix,
|
||||
accountId: normalizedAccountId,
|
||||
} = resolveAccountTarget(parsedConfig, channelId, accountId);
|
||||
const existingRaw = getNestedValue(target, allowlistPath);
|
||||
const existing = Array.isArray(existingRaw)
|
||||
? existingRaw.map((entry) => String(entry).trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const normalizedEntry = normalizeAllowFrom({
|
||||
cfg: params.cfg,
|
||||
channelId,
|
||||
accountId: normalizedAccountId,
|
||||
values: [parsed.entry],
|
||||
});
|
||||
if (normalizedEntry.length === 0) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ Invalid allowlist entry." },
|
||||
};
|
||||
}
|
||||
|
||||
const existingNormalized = normalizeAllowFrom({
|
||||
cfg: params.cfg,
|
||||
channelId,
|
||||
accountId: normalizedAccountId,
|
||||
values: existing,
|
||||
});
|
||||
|
||||
const shouldMatch = (value: string) => normalizedEntry.includes(value);
|
||||
|
||||
let configChanged = false;
|
||||
let next = existing;
|
||||
const configHasEntry = existingNormalized.some((value) => shouldMatch(value));
|
||||
if (parsed.action === "add") {
|
||||
if (!configHasEntry) {
|
||||
next = [...existing, parsed.entry.trim()];
|
||||
configChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.action === "remove") {
|
||||
const keep: string[] = [];
|
||||
for (const entry of existing) {
|
||||
const normalized = normalizeAllowFrom({
|
||||
cfg: params.cfg,
|
||||
channelId,
|
||||
accountId: normalizedAccountId,
|
||||
values: [entry],
|
||||
});
|
||||
if (normalized.some((value) => shouldMatch(value))) {
|
||||
configChanged = true;
|
||||
continue;
|
||||
}
|
||||
keep.push(entry);
|
||||
}
|
||||
next = keep;
|
||||
}
|
||||
|
||||
if (configChanged) {
|
||||
if (next.length === 0) {
|
||||
deleteNestedValue(target, allowlistPath);
|
||||
} else {
|
||||
setNestedValue(target, allowlistPath, next);
|
||||
}
|
||||
}
|
||||
|
||||
if (configChanged) {
|
||||
const validated = validateConfigObjectWithPlugins(parsedConfig);
|
||||
if (!validated.ok) {
|
||||
const issue = validated.issues[0];
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ Config invalid after update (${issue.path}: ${issue.message}).` },
|
||||
};
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
}
|
||||
|
||||
if (!configChanged && !shouldTouchStore) {
|
||||
const message = parsed.action === "add" ? "✅ Already allowlisted." : "⚠️ Entry not found.";
|
||||
return { shouldContinue: false, reply: { text: message } };
|
||||
}
|
||||
|
||||
if (shouldTouchStore) {
|
||||
if (parsed.action === "add") {
|
||||
await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||
} else if (parsed.action === "remove") {
|
||||
await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||
}
|
||||
}
|
||||
|
||||
const actionLabel = parsed.action === "add" ? "added" : "removed";
|
||||
const scopeLabel = scope === "dm" ? "DM" : "group";
|
||||
const locations: string[] = [];
|
||||
if (configChanged) {
|
||||
locations.push(`${pathPrefix}.${allowlistPath.join(".")}`);
|
||||
}
|
||||
if (shouldTouchStore) {
|
||||
locations.push("pairing store");
|
||||
}
|
||||
const targetLabel = locations.length > 0 ? locations.join(" + ") : "no-op";
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `✅ ${scopeLabel} allowlist ${actionLabel}: ${targetLabel}.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!shouldTouchStore) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚠️ This channel does not support allowlist storage." },
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.action === "add") {
|
||||
await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||
} else if (parsed.action === "remove") {
|
||||
await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||
}
|
||||
|
||||
const actionLabel = parsed.action === "add" ? "added" : "removed";
|
||||
const scopeLabel = scope === "dm" ? "DM" : "group";
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `✅ ${scopeLabel} allowlist ${actionLabel} in pairing store.` },
|
||||
};
|
||||
};
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
handleStatusCommand,
|
||||
handleWhoamiCommand,
|
||||
} from "./commands-info.js";
|
||||
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
||||
import { handleSubagentsCommand } from "./commands-subagents.js";
|
||||
import {
|
||||
handleAbortTrigger,
|
||||
@@ -38,7 +37,6 @@ const HANDLERS: CommandHandler[] = [
|
||||
handleHelpCommand,
|
||||
handleCommandsListCommand,
|
||||
handleStatusCommand,
|
||||
handleAllowlistCommand,
|
||||
handleContextCommand,
|
||||
handleWhoamiCommand,
|
||||
handleSubagentsCommand,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"
|
||||
import type { ExecAsk, ExecHost, ExecSecurity } from "../../infra/exec-approvals.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
|
||||
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||
import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import {
|
||||
@@ -341,11 +340,22 @@ export async function handleDirectiveOnly(params: {
|
||||
}
|
||||
}
|
||||
if (modelSelection) {
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry: sessionEntry,
|
||||
selection: modelSelection,
|
||||
profileOverride,
|
||||
});
|
||||
if (modelSelection.isDefault) {
|
||||
delete sessionEntry.providerOverride;
|
||||
delete sessionEntry.modelOverride;
|
||||
} else {
|
||||
sessionEntry.providerOverride = modelSelection.provider;
|
||||
sessionEntry.modelOverride = modelSelection.model;
|
||||
}
|
||||
if (profileOverride) {
|
||||
sessionEntry.authProfileOverride = profileOverride;
|
||||
sessionEntry.authProfileOverrideSource = "user";
|
||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||
} else if (directives.hasModelDirective) {
|
||||
delete sessionEntry.authProfileOverride;
|
||||
delete sessionEntry.authProfileOverrideSource;
|
||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||
}
|
||||
}
|
||||
if (directives.hasQueueDirective && directives.queueReset) {
|
||||
delete sessionEntry.queueMode;
|
||||
|
||||
@@ -16,7 +16,6 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
|
||||
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||
import { resolveProfileOverride } from "./directive-handling.auth.js";
|
||||
import type { InlineDirectives } from "./directive-handling.parse.js";
|
||||
import { formatElevatedEvent, formatReasoningEvent } from "./directive-handling.shared.js";
|
||||
@@ -165,15 +164,22 @@ export async function persistInlineDirectives(params: {
|
||||
}
|
||||
const isDefault =
|
||||
resolved.ref.provider === defaultProvider && resolved.ref.model === defaultModel;
|
||||
const { updated: modelUpdated } = applyModelOverrideToSessionEntry({
|
||||
entry: sessionEntry,
|
||||
selection: {
|
||||
provider: resolved.ref.provider,
|
||||
model: resolved.ref.model,
|
||||
isDefault,
|
||||
},
|
||||
profileOverride,
|
||||
});
|
||||
if (isDefault) {
|
||||
delete sessionEntry.providerOverride;
|
||||
delete sessionEntry.modelOverride;
|
||||
} else {
|
||||
sessionEntry.providerOverride = resolved.ref.provider;
|
||||
sessionEntry.modelOverride = resolved.ref.model;
|
||||
}
|
||||
if (profileOverride) {
|
||||
sessionEntry.authProfileOverride = profileOverride;
|
||||
sessionEntry.authProfileOverrideSource = "user";
|
||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||
} else if (directives.hasModelDirective) {
|
||||
delete sessionEntry.authProfileOverride;
|
||||
delete sessionEntry.authProfileOverrideSource;
|
||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||
}
|
||||
provider = resolved.ref.provider;
|
||||
model = resolved.ref.model;
|
||||
const nextLabel = `${provider}/${model}`;
|
||||
@@ -183,7 +189,7 @@ export async function persistInlineDirectives(params: {
|
||||
contextKey: `model:${nextLabel}`,
|
||||
});
|
||||
}
|
||||
updated = updated || modelUpdated;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,16 @@ import {
|
||||
isEmbeddedPiRunStreaming,
|
||||
resolveEmbeddedSessionLane,
|
||||
} from "../../agents/pi-embedded.js";
|
||||
import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
isProfileInCooldown,
|
||||
resolveAuthProfileOrder,
|
||||
} from "../../agents/auth-profiles.js";
|
||||
import type { ExecToolDefaults } from "../../agents/bash-tools.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveSessionFilePath,
|
||||
saveSessionStore,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
@@ -103,6 +108,92 @@ type RunPreparedReplyParams = {
|
||||
abortedLastRun: boolean;
|
||||
};
|
||||
|
||||
async function resolveSessionAuthProfileOverride(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
provider: string;
|
||||
agentDir: string;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
isNewSession: boolean;
|
||||
}): Promise<string | undefined> {
|
||||
const {
|
||||
cfg,
|
||||
provider,
|
||||
agentDir,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
isNewSession,
|
||||
} = params;
|
||||
if (!sessionEntry || !sessionStore || !sessionKey) return sessionEntry?.authProfileOverride;
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||
const order = resolveAuthProfileOrder({ cfg, store, provider });
|
||||
if (order.length === 0) return sessionEntry.authProfileOverride;
|
||||
|
||||
const pickFirstAvailable = () =>
|
||||
order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
|
||||
const pickNextAvailable = (current: string) => {
|
||||
const startIndex = order.indexOf(current);
|
||||
if (startIndex < 0) return pickFirstAvailable();
|
||||
for (let offset = 1; offset <= order.length; offset += 1) {
|
||||
const candidate = order[(startIndex + offset) % order.length];
|
||||
if (!isProfileInCooldown(store, candidate)) return candidate;
|
||||
}
|
||||
return order[startIndex] ?? order[0];
|
||||
};
|
||||
|
||||
const compactionCount = sessionEntry.compactionCount ?? 0;
|
||||
const storedCompaction =
|
||||
typeof sessionEntry.authProfileOverrideCompactionCount === "number"
|
||||
? sessionEntry.authProfileOverrideCompactionCount
|
||||
: compactionCount;
|
||||
|
||||
let current = sessionEntry.authProfileOverride?.trim();
|
||||
if (current && !order.includes(current)) current = undefined;
|
||||
|
||||
const source =
|
||||
sessionEntry.authProfileOverrideSource ??
|
||||
(typeof sessionEntry.authProfileOverrideCompactionCount === "number"
|
||||
? "auto"
|
||||
: current
|
||||
? "user"
|
||||
: undefined);
|
||||
if (source === "user" && current && !isNewSession) {
|
||||
return current;
|
||||
}
|
||||
|
||||
let next = current;
|
||||
if (isNewSession) {
|
||||
next = current ? pickNextAvailable(current) : pickFirstAvailable();
|
||||
} else if (current && compactionCount > storedCompaction) {
|
||||
next = pickNextAvailable(current);
|
||||
} else if (!current || isProfileInCooldown(store, current)) {
|
||||
next = pickFirstAvailable();
|
||||
}
|
||||
|
||||
if (!next) return current;
|
||||
const shouldPersist =
|
||||
next !== sessionEntry.authProfileOverride ||
|
||||
sessionEntry.authProfileOverrideSource !== "auto" ||
|
||||
sessionEntry.authProfileOverrideCompactionCount !== compactionCount;
|
||||
if (shouldPersist) {
|
||||
sessionEntry.authProfileOverride = next;
|
||||
sessionEntry.authProfileOverrideSource = "auto";
|
||||
sessionEntry.authProfileOverrideCompactionCount = compactionCount;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function runPreparedReply(
|
||||
params: RunPreparedReplyParams,
|
||||
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
} from "../../agents/model-selection.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||
import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
|
||||
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||
import type { ThinkLevel } from "./directives.js";
|
||||
|
||||
export type ModelDirectiveSelection = {
|
||||
@@ -186,19 +184,16 @@ export async function createModelSelectionState(params: {
|
||||
if (overrideModel) {
|
||||
const key = modelKey(overrideProvider, overrideModel);
|
||||
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
|
||||
const { updated } = applyModelOverrideToSessionEntry({
|
||||
entry: sessionEntry,
|
||||
selection: { provider: defaultProvider, model: defaultModel, isDefault: true },
|
||||
});
|
||||
if (updated) {
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
}
|
||||
delete sessionEntry.providerOverride;
|
||||
delete sessionEntry.modelOverride;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
}
|
||||
resetModelOverride = updated;
|
||||
resetModelOverride = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,14 +215,17 @@ export async function createModelSelectionState(params: {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const profile = store.profiles[sessionEntry.authProfileOverride];
|
||||
const providerKey = normalizeProviderId(provider);
|
||||
if (!profile || normalizeProviderId(profile.provider) !== providerKey) {
|
||||
await clearSessionAuthProfileOverride({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
if (!profile || profile.provider !== provider) {
|
||||
delete sessionEntry.authProfileOverride;
|
||||
delete sessionEntry.authProfileOverrideSource;
|
||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = sessionEntry;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -209,22 +209,6 @@ describe("routeReply", () => {
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi", expect.any(Object));
|
||||
});
|
||||
|
||||
it("uses threadId for Slack when replyToId is missing", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
threadId: "456.789",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"hi",
|
||||
expect.objectContaining({ threadTs: "456.789" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes thread id to Telegram sends", async () => {
|
||||
mocks.sendMessageTelegram.mockClear();
|
||||
await routeReply({
|
||||
|
||||
@@ -42,39 +42,6 @@ describe("applyResetModelOverride", () => {
|
||||
expect(sessionCtx.BodyStripped).toBe("summarize");
|
||||
});
|
||||
|
||||
it("clears auth profile overrides when reset applies a model", async () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
||||
const sessionEntry = {
|
||||
sessionId: "s1",
|
||||
updatedAt: Date.now(),
|
||||
authProfileOverride: "anthropic:default",
|
||||
authProfileOverrideSource: "user",
|
||||
authProfileOverrideCompactionCount: 2,
|
||||
};
|
||||
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
||||
const sessionCtx = { BodyStripped: "minimax summarize" };
|
||||
const ctx = { ChatType: "direct" };
|
||||
|
||||
await applyResetModelOverride({
|
||||
cfg,
|
||||
resetTriggered: true,
|
||||
bodyStripped: "minimax summarize",
|
||||
sessionCtx,
|
||||
ctx,
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "agent:main:dm:1",
|
||||
defaultProvider: "openai",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
aliasIndex,
|
||||
});
|
||||
|
||||
expect(sessionEntry.authProfileOverride).toBeUndefined();
|
||||
expect(sessionEntry.authProfileOverrideSource).toBeUndefined();
|
||||
expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips when resetTriggered is false", async () => {
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
||||
|
||||
@@ -12,7 +12,6 @@ import { updateSessionStore } from "../../config/sessions.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
|
||||
import { resolveModelDirectiveSelection, type ModelDirectiveSelection } from "./model-selection.js";
|
||||
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||
|
||||
type ResetModelResult = {
|
||||
selection?: ModelDirectiveSelection;
|
||||
@@ -63,11 +62,25 @@ function applySelectionToSession(params: {
|
||||
}) {
|
||||
const { selection, sessionEntry, sessionStore, sessionKey, storePath } = params;
|
||||
if (!sessionEntry || !sessionStore || !sessionKey) return;
|
||||
const { updated } = applyModelOverrideToSessionEntry({
|
||||
entry: sessionEntry,
|
||||
selection,
|
||||
});
|
||||
let updated = false;
|
||||
if (selection.isDefault) {
|
||||
if (sessionEntry.providerOverride || sessionEntry.modelOverride) {
|
||||
delete sessionEntry.providerOverride;
|
||||
delete sessionEntry.modelOverride;
|
||||
updated = true;
|
||||
}
|
||||
} else {
|
||||
if (sessionEntry.providerOverride !== selection.provider) {
|
||||
sessionEntry.providerOverride = selection.provider;
|
||||
updated = true;
|
||||
}
|
||||
if (sessionEntry.modelOverride !== selection.model) {
|
||||
sessionEntry.modelOverride = selection.model;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
if (!updated) return;
|
||||
sessionEntry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = sessionEntry;
|
||||
if (storePath) {
|
||||
updateSessionStore(storePath, (store) => {
|
||||
|
||||
@@ -5,23 +5,19 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
||||
deliveryMode: "direct",
|
||||
chunker: null,
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
|
||||
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||
// Use threadId fallback so routed tool notifications stay in the Slack thread.
|
||||
const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined);
|
||||
const result = await send(to, text, {
|
||||
threadTs,
|
||||
threadTs: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return { channel: "slack", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId }) => {
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||
// Use threadId fallback so routed tool notifications stay in the Slack thread.
|
||||
const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined);
|
||||
const result = await send(to, text, {
|
||||
mediaUrl,
|
||||
threadTs,
|
||||
threadTs: replyToId ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return { channel: "slack", ...result };
|
||||
|
||||
@@ -30,6 +30,13 @@ function buildRows(entries: Array<{ id: string; name?: string | undefined }>) {
|
||||
}));
|
||||
}
|
||||
|
||||
function formatEntry(entry: { id: string; name?: string; handle?: string }) {
|
||||
const name = entry.name?.trim() ?? "";
|
||||
const handle = entry.handle?.trim() ?? "";
|
||||
const label = name || handle;
|
||||
return label ? `${entry.id} - ${label}` : entry.id;
|
||||
}
|
||||
|
||||
export function registerDirectoryCli(program: Command) {
|
||||
const directory = program
|
||||
.command("directory")
|
||||
@@ -220,24 +227,9 @@ export function registerDirectoryCli(program: Command) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (result.length === 0) {
|
||||
defaultRuntime.log(theme.muted("No group members found."));
|
||||
return;
|
||||
for (const entry of result) {
|
||||
defaultRuntime.log(formatEntry(entry));
|
||||
}
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(
|
||||
`${theme.heading("Group Members")} ${theme.muted(`(${result.length})`)}`,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "ID", header: "ID", minWidth: 16, flex: true },
|
||||
{ key: "Name", header: "Name", minWidth: 18, flex: true },
|
||||
],
|
||||
rows: buildRows(result),
|
||||
}).trimEnd(),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
|
||||
const { buildProgram } = await import("./program.js");
|
||||
|
||||
describe("dns cli", () => {
|
||||
it("prints setup info (no apply)", async () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["dns", "setup"], { from: "user" });
|
||||
const output = log.mock.calls.map((call) => call.join(" ")).join("\n");
|
||||
expect(output).toContain("DNS setup");
|
||||
const output = log.mock.calls.map((args) => args.join(" ")).join("\n");
|
||||
expect(output).toContain("clawdbot.internal");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { theme } from "../terminal/theme.js";
|
||||
|
||||
export type HelpExample = readonly [command: string, description: string];
|
||||
export type HelpExample = [command: string, description: string];
|
||||
|
||||
export function formatHelpExample(command: string, description: string): string {
|
||||
return ` ${theme.command(command)}\n ${theme.muted(description)}`;
|
||||
@@ -11,15 +11,11 @@ export function formatHelpExampleLine(command: string, description: string): str
|
||||
return ` ${theme.command(command)} ${theme.muted(`# ${description}`)}`;
|
||||
}
|
||||
|
||||
export function formatHelpExamples(examples: ReadonlyArray<HelpExample>, inline = false): string {
|
||||
export function formatHelpExamples(examples: HelpExample[], inline = false): string {
|
||||
const formatter = inline ? formatHelpExampleLine : formatHelpExample;
|
||||
return examples.map(([command, description]) => formatter(command, description)).join("\n");
|
||||
}
|
||||
|
||||
export function formatHelpExampleGroup(
|
||||
label: string,
|
||||
examples: ReadonlyArray<HelpExample>,
|
||||
inline = false,
|
||||
) {
|
||||
export function formatHelpExampleGroup(label: string, examples: HelpExample[], inline = false) {
|
||||
return `${theme.muted(label)}\n${formatHelpExamples(examples, inline)}`;
|
||||
}
|
||||
@@ -46,10 +46,13 @@ function formatNodeVersions(node: {
|
||||
|
||||
function parseSinceMs(raw: unknown, label: string): number | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
const value =
|
||||
typeof raw === "string" ? raw.trim() : typeof raw === "number" ? String(raw).trim() : null;
|
||||
if (value === null) {
|
||||
defaultRuntime.error(`${label}: invalid duration value`);
|
||||
let value = "";
|
||||
if (typeof raw === "string") {
|
||||
value = raw.trim();
|
||||
} else if (typeof raw === "number") {
|
||||
value = `${raw}`;
|
||||
} else {
|
||||
defaultRuntime.error(`${label}: expected a duration string`);
|
||||
defaultRuntime.exit(1);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Command } from "commander";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
|
||||
const listChannelPairingRequests = vi.fn();
|
||||
const approveChannelPairingCode = vi.fn();
|
||||
const notifyPairingApproved = vi.fn();
|
||||
@@ -64,14 +66,14 @@ describe("pairing cli", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerPairingCli(program);
|
||||
await program.parseAsync(["pairing", "list", "--channel", "telegram"], {
|
||||
from: "user",
|
||||
});
|
||||
const output = log.mock.calls.map((call) => call.join(" ")).join("\n");
|
||||
const output = log.mock.calls.map((args) => args.join(" ")).join("\n");
|
||||
expect(output).toContain("telegramUserId");
|
||||
expect(output).toContain("123");
|
||||
});
|
||||
@@ -126,14 +128,14 @@ describe("pairing cli", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerPairingCli(program);
|
||||
await program.parseAsync(["pairing", "list", "--channel", "discord"], {
|
||||
from: "user",
|
||||
});
|
||||
const output = log.mock.calls.map((call) => call.join(" ")).join("\n");
|
||||
const output = log.mock.calls.map((args) => args.join(" ")).join("\n");
|
||||
expect(output).toContain("discordUserId");
|
||||
expect(output).toContain("999");
|
||||
});
|
||||
@@ -150,7 +152,7 @@ describe("pairing cli", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
|
||||
const program = new Command();
|
||||
program.name("test");
|
||||
registerPairingCli(program);
|
||||
|
||||
@@ -454,10 +454,6 @@ export function registerPluginsCli(program: Command) {
|
||||
const targets = opts.all ? Object.keys(installs) : id ? [id] : [];
|
||||
|
||||
if (targets.length === 0) {
|
||||
if (opts.all) {
|
||||
defaultRuntime.log("No npm-installed plugins to update.");
|
||||
return;
|
||||
}
|
||||
defaultRuntime.error("Provide a plugin id or use --all.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ type CommandOptions = Record<string, unknown>;
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
const SANDBOX_EXAMPLES = {
|
||||
const SANDBOX_EXAMPLES: Record<string, [string, string][]> = {
|
||||
main: [
|
||||
["clawdbot sandbox list", "List all sandbox containers."],
|
||||
["clawdbot sandbox list --browser", "List only browser containers."],
|
||||
@@ -40,7 +40,7 @@ const SANDBOX_EXAMPLES = {
|
||||
["clawdbot sandbox explain --agent work", "Explain an agent sandbox."],
|
||||
["clawdbot sandbox explain --json", "JSON output."],
|
||||
],
|
||||
} as const;
|
||||
};
|
||||
|
||||
function createRunner(
|
||||
commandFn: (opts: CommandOptions, runtime: typeof defaultRuntime) => Promise<void>,
|
||||
|
||||
@@ -31,10 +31,6 @@ vi.mock("../infra/update-check.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runCommandWithTimeout: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock doctor (heavy module; should not run in unit tests)
|
||||
vi.mock("../commands/doctor.js", () => ({
|
||||
doctorCommand: vi.fn(),
|
||||
@@ -80,7 +76,6 @@ describe("update-cli", () => {
|
||||
const { readConfigFileSnapshot } = await import("../config/config.js");
|
||||
const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } =
|
||||
await import("../infra/update-check.js");
|
||||
const { runCommandWithTimeout } = await import("../process/exec.js");
|
||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd());
|
||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot);
|
||||
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
|
||||
@@ -116,13 +111,6 @@ describe("update-cli", () => {
|
||||
latestVersion: "1.2.3",
|
||||
},
|
||||
});
|
||||
vi.mocked(runCommandWithTimeout).mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
});
|
||||
setTty(false);
|
||||
setStdoutTty(false);
|
||||
});
|
||||
@@ -214,21 +202,9 @@ describe("update-cli", () => {
|
||||
|
||||
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||
const { updateCommand } = await import("./update-cli.js");
|
||||
|
||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||
root: tempDir,
|
||||
installKind: "package",
|
||||
packageManager: "npm",
|
||||
deps: {
|
||||
manager: "npm",
|
||||
status: "ok",
|
||||
lockfilePath: null,
|
||||
markerPath: null,
|
||||
},
|
||||
});
|
||||
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
||||
status: "ok",
|
||||
mode: "npm",
|
||||
@@ -282,24 +258,12 @@ describe("update-cli", () => {
|
||||
const { resolveNpmChannelTag } = await import("../infra/update-check.js");
|
||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||
const { updateCommand } = await import("./update-cli.js");
|
||||
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||
|
||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
||||
...baseSnapshot,
|
||||
config: { update: { channel: "beta" } },
|
||||
});
|
||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||
root: tempDir,
|
||||
installKind: "package",
|
||||
packageManager: "npm",
|
||||
deps: {
|
||||
manager: "npm",
|
||||
status: "ok",
|
||||
lockfilePath: null,
|
||||
markerPath: null,
|
||||
},
|
||||
});
|
||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||
tag: "latest",
|
||||
version: "2026.1.20-1",
|
||||
@@ -495,20 +459,8 @@ describe("update-cli", () => {
|
||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||
const { defaultRuntime } = await import("../runtime.js");
|
||||
const { updateCommand } = await import("./update-cli.js");
|
||||
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||
|
||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||
root: tempDir,
|
||||
installKind: "package",
|
||||
packageManager: "npm",
|
||||
deps: {
|
||||
manager: "npm",
|
||||
status: "ok",
|
||||
lockfilePath: null,
|
||||
markerPath: null,
|
||||
},
|
||||
});
|
||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||
tag: "latest",
|
||||
version: "0.0.1",
|
||||
@@ -548,20 +500,8 @@ describe("update-cli", () => {
|
||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||
const { defaultRuntime } = await import("../runtime.js");
|
||||
const { updateCommand } = await import("./update-cli.js");
|
||||
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||
|
||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||
root: tempDir,
|
||||
installKind: "package",
|
||||
packageManager: "npm",
|
||||
deps: {
|
||||
manager: "npm",
|
||||
status: "ok",
|
||||
lockfilePath: null,
|
||||
markerPath: null,
|
||||
},
|
||||
});
|
||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||
tag: "latest",
|
||||
version: "0.0.1",
|
||||
|
||||
+13
-284
@@ -1,6 +1,5 @@
|
||||
import { confirm, isCancel, spinner } from "@clack/prompts";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { Command } from "commander";
|
||||
|
||||
@@ -17,17 +16,8 @@ import {
|
||||
runGatewayUpdate,
|
||||
type UpdateRunResult,
|
||||
type UpdateStepInfo,
|
||||
type UpdateStepResult,
|
||||
type UpdateStepProgress,
|
||||
type UpdateStepResult,
|
||||
} from "../infra/update-runner.js";
|
||||
import {
|
||||
detectGlobalInstallManagerByPresence,
|
||||
detectGlobalInstallManagerForRoot,
|
||||
globalInstallArgs,
|
||||
resolveGlobalPackageRoot,
|
||||
type GlobalInstallManager,
|
||||
} from "../infra/update-global.js";
|
||||
import {
|
||||
channelToNpmTag,
|
||||
DEFAULT_GIT_CHANNEL,
|
||||
@@ -36,7 +26,6 @@ import {
|
||||
normalizeUpdateChannel,
|
||||
resolveEffectiveUpdateChannel,
|
||||
} from "../infra/update-channels.js";
|
||||
import { trimLogTail } from "../infra/restart-sentinel.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
@@ -50,7 +39,6 @@ import {
|
||||
resolveUpdateAvailability,
|
||||
} from "../commands/status.update.js";
|
||||
import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../plugins/update.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
|
||||
export type UpdateCommandOptions = {
|
||||
json?: boolean;
|
||||
@@ -70,14 +58,12 @@ const STEP_LABELS: Record<string, string> = {
|
||||
"upstream check": "Upstream branch exists",
|
||||
"git fetch": "Fetching latest changes",
|
||||
"git rebase": "Rebasing onto upstream",
|
||||
"git clone": "Cloning git checkout",
|
||||
"deps install": "Installing dependencies",
|
||||
build: "Building",
|
||||
"ui:build": "Building UI",
|
||||
"clawdbot doctor": "Running doctor checks",
|
||||
"git rev-parse HEAD (after)": "Verifying update",
|
||||
"global update": "Updating via package manager",
|
||||
"global install": "Installing global package",
|
||||
};
|
||||
|
||||
const UPDATE_QUIPS = [
|
||||
@@ -103,10 +89,6 @@ const UPDATE_QUIPS = [
|
||||
"Version bump! Same chaos energy, fewer crashes (probably).",
|
||||
];
|
||||
|
||||
const MAX_LOG_CHARS = 8000;
|
||||
const CLAWDBOT_REPO_URL = "https://github.com/clawdbot/clawdbot.git";
|
||||
const DEFAULT_GIT_DIR = path.join(os.homedir(), "clawdbot");
|
||||
|
||||
function normalizeTag(value?: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -151,146 +133,6 @@ async function isGitCheckout(root: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function isClawdbotPackage(root: string): Promise<boolean> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
|
||||
const parsed = JSON.parse(raw) as { name?: string };
|
||||
return parsed?.name === "clawdbot";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isEmptyDir(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
const entries = await fs.readdir(targetPath);
|
||||
return entries.length === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGitInstallDir(): string {
|
||||
const override = process.env.CLAWDBOT_GIT_DIR?.trim();
|
||||
if (override) return path.resolve(override);
|
||||
return DEFAULT_GIT_DIR;
|
||||
}
|
||||
|
||||
function resolveNodeRunner(): string {
|
||||
const base = path.basename(process.execPath).toLowerCase();
|
||||
if (base === "node" || base === "node.exe") return process.execPath;
|
||||
return "node";
|
||||
}
|
||||
|
||||
async function runUpdateStep(params: {
|
||||
name: string;
|
||||
argv: string[];
|
||||
cwd?: string;
|
||||
timeoutMs: number;
|
||||
progress?: UpdateStepProgress;
|
||||
}): Promise<UpdateStepResult> {
|
||||
const command = params.argv.join(" ");
|
||||
params.progress?.onStepStart?.({
|
||||
name: params.name,
|
||||
command,
|
||||
index: 0,
|
||||
total: 0,
|
||||
});
|
||||
const started = Date.now();
|
||||
const res = await runCommandWithTimeout(params.argv, {
|
||||
cwd: params.cwd,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
const durationMs = Date.now() - started;
|
||||
const stderrTail = trimLogTail(res.stderr, MAX_LOG_CHARS);
|
||||
params.progress?.onStepComplete?.({
|
||||
name: params.name,
|
||||
command,
|
||||
index: 0,
|
||||
total: 0,
|
||||
durationMs,
|
||||
exitCode: res.code,
|
||||
stderrTail,
|
||||
});
|
||||
return {
|
||||
name: params.name,
|
||||
command,
|
||||
cwd: params.cwd ?? process.cwd(),
|
||||
durationMs,
|
||||
exitCode: res.code,
|
||||
stdoutTail: trimLogTail(res.stdout, MAX_LOG_CHARS),
|
||||
stderrTail,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureGitCheckout(params: {
|
||||
dir: string;
|
||||
timeoutMs: number;
|
||||
progress?: UpdateStepProgress;
|
||||
}): Promise<UpdateStepResult | null> {
|
||||
const dirExists = await pathExists(params.dir);
|
||||
if (!dirExists) {
|
||||
return await runUpdateStep({
|
||||
name: "git clone",
|
||||
argv: ["git", "clone", CLAWDBOT_REPO_URL, params.dir],
|
||||
timeoutMs: params.timeoutMs,
|
||||
progress: params.progress,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await isGitCheckout(params.dir))) {
|
||||
const empty = await isEmptyDir(params.dir);
|
||||
if (!empty) {
|
||||
throw new Error(
|
||||
`CLAWDBOT_GIT_DIR points at a non-git directory: ${params.dir}. Set CLAWDBOT_GIT_DIR to an empty folder or a clawdbot checkout.`,
|
||||
);
|
||||
}
|
||||
return await runUpdateStep({
|
||||
name: "git clone",
|
||||
argv: ["git", "clone", CLAWDBOT_REPO_URL, params.dir],
|
||||
cwd: params.dir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
progress: params.progress,
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await isClawdbotPackage(params.dir))) {
|
||||
throw new Error(`CLAWDBOT_GIT_DIR does not look like a clawdbot checkout: ${params.dir}.`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveGlobalManager(params: {
|
||||
root: string;
|
||||
installKind: "git" | "package" | "unknown";
|
||||
timeoutMs: number;
|
||||
}): Promise<GlobalInstallManager> {
|
||||
const runCommand = async (argv: string[], options: { timeoutMs: number }) => {
|
||||
const res = await runCommandWithTimeout(argv, options);
|
||||
return { stdout: res.stdout, stderr: res.stderr, code: res.code };
|
||||
};
|
||||
if (params.installKind === "package") {
|
||||
const detected = await detectGlobalInstallManagerForRoot(
|
||||
runCommand,
|
||||
params.root,
|
||||
params.timeoutMs,
|
||||
);
|
||||
if (detected) return detected;
|
||||
}
|
||||
const byPresence = await detectGlobalInstallManagerByPresence(runCommand, params.timeoutMs);
|
||||
return byPresence ?? "npm";
|
||||
}
|
||||
|
||||
function formatGitStatusLine(params: {
|
||||
branch: string | null;
|
||||
tag: string | null;
|
||||
@@ -552,13 +394,6 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
cwd: process.cwd(),
|
||||
})) ?? process.cwd();
|
||||
|
||||
const updateStatus = await checkUpdateStatus({
|
||||
root,
|
||||
timeoutMs: timeoutMs ?? 3500,
|
||||
fetchGit: false,
|
||||
includeRegistry: false,
|
||||
});
|
||||
|
||||
const configSnapshot = await readConfigFileSnapshot();
|
||||
let activeConfig = configSnapshot.valid ? configSnapshot.config : null;
|
||||
const storedChannel = configSnapshot.valid
|
||||
@@ -578,18 +413,13 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const installKind = updateStatus.installKind;
|
||||
const switchToGit = requestedChannel === "dev" && installKind !== "git";
|
||||
const switchToPackage =
|
||||
requestedChannel !== null && requestedChannel !== "dev" && installKind === "git";
|
||||
const updateInstallKind = switchToGit ? "git" : switchToPackage ? "package" : installKind;
|
||||
const defaultChannel =
|
||||
updateInstallKind === "git" ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL;
|
||||
const gitCheckout = await isGitCheckout(root);
|
||||
const defaultChannel = gitCheckout ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL;
|
||||
const channel = requestedChannel ?? storedChannel ?? defaultChannel;
|
||||
const explicitTag = normalizeTag(opts.tag);
|
||||
let tag = explicitTag ?? channelToNpmTag(channel);
|
||||
if (updateInstallKind !== "git") {
|
||||
const currentVersion = switchToPackage ? null : await readPackageVersion(root);
|
||||
if (!gitCheckout) {
|
||||
const currentVersion = await readPackageVersion(root);
|
||||
const targetVersion = explicitTag
|
||||
? await resolveTargetVersion(tag, timeoutMs)
|
||||
: await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
|
||||
@@ -657,114 +487,14 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
|
||||
const { progress, stop } = createUpdateProgress(showProgress);
|
||||
|
||||
const startedAt = Date.now();
|
||||
let result: UpdateRunResult;
|
||||
|
||||
if (switchToPackage) {
|
||||
const manager = await resolveGlobalManager({
|
||||
root,
|
||||
installKind,
|
||||
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||
});
|
||||
const runCommand = async (argv: string[], options: { timeoutMs: number }) => {
|
||||
const res = await runCommandWithTimeout(argv, options);
|
||||
return { stdout: res.stdout, stderr: res.stderr, code: res.code };
|
||||
};
|
||||
const pkgRoot = await resolveGlobalPackageRoot(manager, runCommand, timeoutMs ?? 20 * 60_000);
|
||||
const beforeVersion = pkgRoot ? await readPackageVersion(pkgRoot) : null;
|
||||
const updateStep = await runUpdateStep({
|
||||
name: "global update",
|
||||
argv: globalInstallArgs(manager, `clawdbot@${tag}`),
|
||||
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||
progress,
|
||||
});
|
||||
const steps = [updateStep];
|
||||
let afterVersion = beforeVersion;
|
||||
if (pkgRoot) {
|
||||
afterVersion = await readPackageVersion(pkgRoot);
|
||||
const entryPath = path.join(pkgRoot, "dist", "entry.js");
|
||||
if (await pathExists(entryPath)) {
|
||||
const doctorStep = await runUpdateStep({
|
||||
name: "clawdbot doctor",
|
||||
argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive"],
|
||||
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||
progress,
|
||||
});
|
||||
steps.push(doctorStep);
|
||||
}
|
||||
}
|
||||
const failedStep = steps.find((step) => step.exitCode !== 0);
|
||||
result = {
|
||||
status: failedStep ? "error" : "ok",
|
||||
mode: manager,
|
||||
root: pkgRoot ?? root,
|
||||
reason: failedStep ? failedStep.name : undefined,
|
||||
before: { version: beforeVersion },
|
||||
after: { version: afterVersion },
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
} else {
|
||||
const updateRoot = switchToGit ? resolveGitInstallDir() : root;
|
||||
const cloneStep = switchToGit
|
||||
? await ensureGitCheckout({
|
||||
dir: updateRoot,
|
||||
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||
progress,
|
||||
})
|
||||
: null;
|
||||
if (cloneStep && cloneStep.exitCode !== 0) {
|
||||
result = {
|
||||
status: "error",
|
||||
mode: "git",
|
||||
root: updateRoot,
|
||||
reason: cloneStep.name,
|
||||
steps: [cloneStep],
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
stop();
|
||||
printResult(result, { ...opts, hideSteps: showProgress });
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const updateResult = await runGatewayUpdate({
|
||||
cwd: updateRoot,
|
||||
argv1: switchToGit ? undefined : process.argv[1],
|
||||
timeoutMs,
|
||||
progress,
|
||||
channel,
|
||||
tag,
|
||||
});
|
||||
const steps = [...(cloneStep ? [cloneStep] : []), ...updateResult.steps];
|
||||
if (switchToGit && updateResult.status === "ok") {
|
||||
const manager = await resolveGlobalManager({
|
||||
root,
|
||||
installKind,
|
||||
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||
});
|
||||
const installStep = await runUpdateStep({
|
||||
name: "global install",
|
||||
argv: globalInstallArgs(manager, updateRoot),
|
||||
cwd: updateRoot,
|
||||
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||
progress,
|
||||
});
|
||||
steps.push(installStep);
|
||||
const failedStep = [installStep].find((step) => step.exitCode !== 0);
|
||||
result = {
|
||||
...updateResult,
|
||||
status: updateResult.status === "ok" && !failedStep ? "ok" : "error",
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
} else {
|
||||
result = {
|
||||
...updateResult,
|
||||
steps,
|
||||
durationMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
const result = await runGatewayUpdate({
|
||||
cwd: root,
|
||||
argv1: process.argv[1],
|
||||
timeoutMs,
|
||||
progress,
|
||||
channel,
|
||||
tag,
|
||||
});
|
||||
|
||||
stop();
|
||||
|
||||
@@ -893,8 +623,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
process.env.CLAWDBOT_UPDATE_IN_PROGRESS = "1";
|
||||
try {
|
||||
const { doctorCommand } = await import("../commands/doctor.js");
|
||||
const interactiveDoctor = Boolean(process.stdin.isTTY) && !opts.json && opts.yes !== true;
|
||||
await doctorCommand(defaultRuntime, { nonInteractive: !interactiveDoctor });
|
||||
await doctorCommand(defaultRuntime, { nonInteractive: true });
|
||||
} catch (err) {
|
||||
defaultRuntime.log(theme.warn(`Doctor failed: ${String(err)}`));
|
||||
} finally {
|
||||
|
||||
+13
-16
@@ -50,8 +50,6 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { applyVerboseOverride } from "../sessions/level-overrides.js";
|
||||
import { resolveSendPolicy } from "../sessions/send-policy.js";
|
||||
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
|
||||
import { clearSessionAuthProfileOverride } from "../agents/auth-profiles/session-override.js";
|
||||
import { resolveMessageChannel } from "../utils/message-channel.js";
|
||||
import { deliverAgentCommandResult } from "./agent/delivery.js";
|
||||
import { resolveAgentRunContext } from "./agent/run-context.js";
|
||||
@@ -285,16 +283,13 @@ export async function agentCommand(
|
||||
allowedModelKeys.size > 0 &&
|
||||
!allowedModelKeys.has(key)
|
||||
) {
|
||||
const { updated } = applyModelOverrideToSessionEntry({
|
||||
entry,
|
||||
selection: { provider: defaultProvider, model: defaultModel, isDefault: true },
|
||||
delete entry.providerOverride;
|
||||
delete entry.modelOverride;
|
||||
entry.updatedAt = Date.now();
|
||||
sessionStore[sessionKey] = entry;
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = entry;
|
||||
});
|
||||
if (updated) {
|
||||
sessionStore[sessionKey] = entry;
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = entry;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,12 +315,14 @@ export async function agentCommand(
|
||||
const store = ensureAuthProfileStore();
|
||||
const profile = store.profiles[authProfileId];
|
||||
if (!profile || profile.provider !== provider) {
|
||||
delete entry.authProfileOverride;
|
||||
delete entry.authProfileOverrideSource;
|
||||
delete entry.authProfileOverrideCompactionCount;
|
||||
entry.updatedAt = Date.now();
|
||||
if (sessionStore && sessionKey) {
|
||||
await clearSessionAuthProfileOverride({
|
||||
sessionEntry: entry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
sessionStore[sessionKey] = entry;
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[sessionKey] = entry;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,14 +121,10 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
options: DoctorOptions;
|
||||
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
|
||||
}) {
|
||||
void params.confirm;
|
||||
const shouldRepair = params.options.repair === true || params.options.yes === true;
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const baseCfg = snapshot.config ?? {};
|
||||
let cfg: ClawdbotConfig = baseCfg;
|
||||
let candidate = structuredClone(baseCfg) as ClawdbotConfig;
|
||||
let pendingChanges = false;
|
||||
let shouldWriteConfig = false;
|
||||
const fixHints: string[] = [];
|
||||
let cfg: ClawdbotConfig = snapshot.config ?? {};
|
||||
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
|
||||
note("Config invalid; doctor will run with best-effort config.", "Config");
|
||||
}
|
||||
@@ -143,76 +139,52 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"),
|
||||
"Legacy config keys detected",
|
||||
);
|
||||
const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed);
|
||||
if (changes.length > 0) {
|
||||
note(changes.join("\n"), "Doctor changes");
|
||||
}
|
||||
if (migrated) {
|
||||
candidate = migrated;
|
||||
pendingChanges = pendingChanges || changes.length > 0;
|
||||
}
|
||||
if (shouldRepair) {
|
||||
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into channels.whatsapp.allowFrom.
|
||||
const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed);
|
||||
if (changes.length > 0) note(changes.join("\n"), "Doctor changes");
|
||||
if (migrated) cfg = migrated;
|
||||
} else {
|
||||
fixHints.push(
|
||||
note(
|
||||
`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply legacy migrations.`,
|
||||
"Doctor",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = normalizeLegacyConfigValues(candidate);
|
||||
const normalized = normalizeLegacyConfigValues(cfg);
|
||||
if (normalized.changes.length > 0) {
|
||||
note(normalized.changes.join("\n"), "Doctor changes");
|
||||
candidate = normalized.config;
|
||||
pendingChanges = true;
|
||||
if (shouldRepair) {
|
||||
cfg = normalized.config;
|
||||
} else {
|
||||
fixHints.push(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply these changes.`);
|
||||
note(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply these changes.`, "Doctor");
|
||||
}
|
||||
}
|
||||
|
||||
const autoEnable = applyPluginAutoEnable({ config: candidate, env: process.env });
|
||||
const autoEnable = applyPluginAutoEnable({ config: cfg, env: process.env });
|
||||
if (autoEnable.changes.length > 0) {
|
||||
note(autoEnable.changes.join("\n"), "Doctor changes");
|
||||
candidate = autoEnable.config;
|
||||
pendingChanges = true;
|
||||
if (shouldRepair) {
|
||||
cfg = autoEnable.config;
|
||||
} else {
|
||||
fixHints.push(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply these changes.`);
|
||||
note(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply these changes.`, "Doctor");
|
||||
}
|
||||
}
|
||||
|
||||
const unknown = stripUnknownConfigKeys(candidate);
|
||||
const unknown = stripUnknownConfigKeys(cfg);
|
||||
if (unknown.removed.length > 0) {
|
||||
const lines = unknown.removed.map((path) => `- ${path}`).join("\n");
|
||||
candidate = unknown.config;
|
||||
pendingChanges = true;
|
||||
if (shouldRepair) {
|
||||
cfg = unknown.config;
|
||||
note(lines, "Doctor changes");
|
||||
} else {
|
||||
note(lines, "Unknown config keys");
|
||||
fixHints.push('Run "clawdbot doctor --fix" to remove these keys.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldRepair && pendingChanges) {
|
||||
const shouldApply = await params.confirm({
|
||||
message: "Apply recommended config repairs now?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (shouldApply) {
|
||||
cfg = candidate;
|
||||
shouldWriteConfig = true;
|
||||
} else if (fixHints.length > 0) {
|
||||
note(fixHints.join("\n"), "Doctor");
|
||||
note('Run "clawdbot doctor --fix" to remove these keys.', "Doctor");
|
||||
}
|
||||
}
|
||||
|
||||
noteOpencodeProviderOverrides(cfg);
|
||||
|
||||
return { cfg, path: snapshot.path ?? CONFIG_PATH_CLAWDBOT, shouldWriteConfig };
|
||||
return { cfg, path: snapshot.path ?? CONFIG_PATH_CLAWDBOT };
|
||||
}
|
||||
@@ -250,8 +250,7 @@ export async function doctorCommand(
|
||||
healthOk,
|
||||
});
|
||||
|
||||
const shouldWriteConfig = prompter.shouldRepair || configResult.shouldWriteConfig;
|
||||
if (shouldWriteConfig) {
|
||||
if (prompter.shouldRepair) {
|
||||
cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) });
|
||||
await writeConfigFile(cfg);
|
||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||
|
||||
@@ -5,8 +5,6 @@ export const GATEWAY_CLIENT_IDS = {
|
||||
CLI: "cli",
|
||||
GATEWAY_CLIENT: "gateway-client",
|
||||
MACOS_APP: "clawdbot-macos",
|
||||
IOS_APP: "clawdbot-ios",
|
||||
ANDROID_APP: "clawdbot-android",
|
||||
NODE_HOST: "node-host",
|
||||
TEST: "test",
|
||||
FINGERPRINT: "fingerprint",
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { test } from "vitest";
|
||||
import WebSocket from "ws";
|
||||
|
||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||
import { getFreePort, onceMessage, startGatewayServer } from "./test-helpers.server.js";
|
||||
|
||||
function connectReq(
|
||||
ws: WebSocket,
|
||||
params: { clientId: string; platform: string; token?: string; password?: string },
|
||||
): Promise<{ ok: boolean; error?: { message?: string } }> {
|
||||
const id = `c-${Math.random().toString(16).slice(2)}`;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id,
|
||||
method: "connect",
|
||||
params: {
|
||||
minProtocol: PROTOCOL_VERSION,
|
||||
maxProtocol: PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: params.clientId,
|
||||
version: "dev",
|
||||
platform: params.platform,
|
||||
mode: "node",
|
||||
},
|
||||
auth: {
|
||||
token: params.token,
|
||||
password: params.password,
|
||||
},
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: ["canvas"],
|
||||
commands: ["system.notify"],
|
||||
permissions: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return onceMessage(
|
||||
ws,
|
||||
(o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === id,
|
||||
);
|
||||
}
|
||||
|
||||
test("accepts clawdbot-ios as a valid gateway client id", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
const res = await connectReq(ws, { clientId: "clawdbot-ios", platform: "ios" });
|
||||
// We don't care if auth fails here; we only care that schema validation accepts the client id.
|
||||
// A schema rejection would close the socket before sending a response.
|
||||
if (!res.ok) {
|
||||
// allow unauthorized error when gateway requires auth
|
||||
// but reject schema validation errors
|
||||
const message = String(res.error?.message ?? "");
|
||||
if (message.includes("invalid connect params")) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("accepts clawdbot-android as a valid gateway client id", async () => {
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
|
||||
const res = await connectReq(ws, { clientId: "clawdbot-android", platform: "android" });
|
||||
// We don't care if auth fails here; we only care that schema validation accepts the client id.
|
||||
// A schema rejection would close the socket before sending a response.
|
||||
if (!res.ok) {
|
||||
// allow unauthorized error when gateway requires auth
|
||||
// but reject schema validation errors
|
||||
const message = String(res.error?.message ?? "");
|
||||
if (message.includes("invalid connect params")) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
@@ -57,32 +57,4 @@ describe("gateway sessions patch", () => {
|
||||
if (res.ok) return;
|
||||
expect(res.error.message).toContain("invalid elevatedLevel");
|
||||
});
|
||||
|
||||
test("clears auth overrides when model patch changes", async () => {
|
||||
const store: Record<string, SessionEntry> = {
|
||||
"agent:main:main": {
|
||||
sessionId: "sess",
|
||||
updatedAt: 1,
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-opus-4-5",
|
||||
authProfileOverride: "anthropic:default",
|
||||
authProfileOverrideSource: "user",
|
||||
authProfileOverrideCompactionCount: 3,
|
||||
} as SessionEntry,
|
||||
};
|
||||
const res = await applySessionsPatchToStore({
|
||||
cfg: {} as ClawdbotConfig,
|
||||
store,
|
||||
storeKey: "agent:main:main",
|
||||
patch: { model: "openai/gpt-5.2" },
|
||||
loadGatewayModelCatalog: async () => [{ provider: "openai", id: "gpt-5.2" }],
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) return;
|
||||
expect(res.entry.providerOverride).toBe("openai");
|
||||
expect(res.entry.modelOverride).toBe("gpt-5.2");
|
||||
expect(res.entry.authProfileOverride).toBeUndefined();
|
||||
expect(res.entry.authProfileOverrideSource).toBeUndefined();
|
||||
expect(res.entry.authProfileOverrideCompactionCount).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,6 @@ import { isSubagentSessionKey } from "../routing/session-key.js";
|
||||
import { applyVerboseOverride, parseVerboseOverride } from "../sessions/level-overrides.js";
|
||||
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
||||
import { parseSessionLabel } from "../sessions/session-label.js";
|
||||
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
@@ -221,23 +220,18 @@ export async function applySessionsPatchToStore(params: {
|
||||
|
||||
if ("model" in patch) {
|
||||
const raw = patch.model;
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
if (raw === null) {
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry: next,
|
||||
selection: {
|
||||
provider: resolvedDefault.provider,
|
||||
model: resolvedDefault.model,
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) return invalid("invalid model: empty");
|
||||
|
||||
const resolvedDefault = resolveConfiguredModelRef({
|
||||
cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
if (!params.loadGatewayModelCatalog) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -255,17 +249,16 @@ export async function applySessionsPatchToStore(params: {
|
||||
if ("error" in resolved) {
|
||||
return invalid(resolved.error);
|
||||
}
|
||||
const isDefault =
|
||||
if (
|
||||
resolved.ref.provider === resolvedDefault.provider &&
|
||||
resolved.ref.model === resolvedDefault.model;
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry: next,
|
||||
selection: {
|
||||
provider: resolved.ref.provider,
|
||||
model: resolved.ref.model,
|
||||
isDefault,
|
||||
},
|
||||
});
|
||||
resolved.ref.model === resolvedDefault.model
|
||||
) {
|
||||
delete next.providerOverride;
|
||||
delete next.modelOverride;
|
||||
} else {
|
||||
next.providerOverride = resolved.ref.provider;
|
||||
next.modelOverride = resolved.ref.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ export function onceMessage<T = unknown>(
|
||||
// Full-suite runs can saturate the event loop (581+ files). Keep this high
|
||||
// enough to avoid flaky RPC timeouts, but still fail fast when a response
|
||||
// never arrives.
|
||||
timeoutMs = 10_000,
|
||||
timeoutMs = 15_000,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export type GlobalInstallManager = "npm" | "pnpm" | "bun";
|
||||
|
||||
export type CommandRunner = (
|
||||
argv: string[],
|
||||
options: { timeoutMs: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
||||
) => Promise<{ stdout: string; stderr: string; code: number | null }>;
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryRealpath(targetPath: string): Promise<string> {
|
||||
try {
|
||||
return await fs.realpath(targetPath);
|
||||
} catch {
|
||||
return path.resolve(targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBunGlobalRoot(): string {
|
||||
const bunInstall = process.env.BUN_INSTALL?.trim() || path.join(os.homedir(), ".bun");
|
||||
return path.join(bunInstall, "install", "global", "node_modules");
|
||||
}
|
||||
|
||||
export async function resolveGlobalRoot(
|
||||
manager: GlobalInstallManager,
|
||||
runCommand: CommandRunner,
|
||||
timeoutMs: number,
|
||||
): Promise<string | null> {
|
||||
if (manager === "bun") return resolveBunGlobalRoot();
|
||||
const argv = manager === "pnpm" ? ["pnpm", "root", "-g"] : ["npm", "root", "-g"];
|
||||
const res = await runCommand(argv, { timeoutMs }).catch(() => null);
|
||||
if (!res || res.code !== 0) return null;
|
||||
const root = res.stdout.trim();
|
||||
return root || null;
|
||||
}
|
||||
|
||||
export async function resolveGlobalPackageRoot(
|
||||
manager: GlobalInstallManager,
|
||||
runCommand: CommandRunner,
|
||||
timeoutMs: number,
|
||||
): Promise<string | null> {
|
||||
const root = await resolveGlobalRoot(manager, runCommand, timeoutMs);
|
||||
if (!root) return null;
|
||||
return path.join(root, "clawdbot");
|
||||
}
|
||||
|
||||
export async function detectGlobalInstallManagerForRoot(
|
||||
runCommand: CommandRunner,
|
||||
pkgRoot: string,
|
||||
timeoutMs: number,
|
||||
): Promise<GlobalInstallManager | null> {
|
||||
const pkgReal = await tryRealpath(pkgRoot);
|
||||
|
||||
const candidates: Array<{
|
||||
manager: "npm" | "pnpm";
|
||||
argv: string[];
|
||||
}> = [
|
||||
{ manager: "npm", argv: ["npm", "root", "-g"] },
|
||||
{ manager: "pnpm", argv: ["pnpm", "root", "-g"] },
|
||||
];
|
||||
|
||||
for (const { manager, argv } of candidates) {
|
||||
const res = await runCommand(argv, { timeoutMs }).catch(() => null);
|
||||
if (!res || res.code !== 0) continue;
|
||||
const globalRoot = res.stdout.trim();
|
||||
if (!globalRoot) continue;
|
||||
const globalReal = await tryRealpath(globalRoot);
|
||||
const expected = path.join(globalReal, "clawdbot");
|
||||
if (path.resolve(expected) === path.resolve(pkgReal)) return manager;
|
||||
}
|
||||
|
||||
const bunGlobalRoot = resolveBunGlobalRoot();
|
||||
const bunGlobalReal = await tryRealpath(bunGlobalRoot);
|
||||
const bunExpected = path.join(bunGlobalReal, "clawdbot");
|
||||
if (path.resolve(bunExpected) === path.resolve(pkgReal)) return "bun";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function detectGlobalInstallManagerByPresence(
|
||||
runCommand: CommandRunner,
|
||||
timeoutMs: number,
|
||||
): Promise<GlobalInstallManager | null> {
|
||||
for (const manager of ["npm", "pnpm"] as const) {
|
||||
const root = await resolveGlobalRoot(manager, runCommand, timeoutMs);
|
||||
if (!root) continue;
|
||||
if (await pathExists(path.join(root, "clawdbot"))) return manager;
|
||||
}
|
||||
|
||||
const bunRoot = resolveBunGlobalRoot();
|
||||
if (await pathExists(path.join(bunRoot, "clawdbot"))) return "bun";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function globalInstallArgs(manager: GlobalInstallManager, spec: string): string[] {
|
||||
if (manager === "pnpm") return ["pnpm", "add", "-g", spec];
|
||||
if (manager === "bun") return ["bun", "add", "-g", spec];
|
||||
return ["npm", "i", "-g", spec];
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import os from "node:os";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
|
||||
import { compareSemverStrings } from "./update-check.js";
|
||||
import { DEV_BRANCH, isBetaTag, isStableTag, type UpdateChannel } from "./update-channels.js";
|
||||
import { detectGlobalInstallManagerForRoot, globalInstallArgs } from "./update-global.js";
|
||||
import { trimLogTail } from "./restart-sentinel.js";
|
||||
|
||||
export type UpdateStepResult = {
|
||||
@@ -210,6 +210,52 @@ async function detectPackageManager(root: string) {
|
||||
return "npm";
|
||||
}
|
||||
|
||||
async function tryRealpath(value: string): Promise<string> {
|
||||
try {
|
||||
return await fs.realpath(value);
|
||||
} catch {
|
||||
return path.resolve(value);
|
||||
}
|
||||
}
|
||||
|
||||
async function detectGlobalInstallManager(
|
||||
runCommand: CommandRunner,
|
||||
pkgRoot: string,
|
||||
timeoutMs: number,
|
||||
): Promise<"npm" | "pnpm" | "bun" | null> {
|
||||
const pkgReal = await tryRealpath(pkgRoot);
|
||||
|
||||
const candidates: Array<{
|
||||
manager: "npm" | "pnpm";
|
||||
argv: string[];
|
||||
}> = [
|
||||
{ manager: "npm", argv: ["npm", "root", "-g"] },
|
||||
{ manager: "pnpm", argv: ["pnpm", "root", "-g"] },
|
||||
];
|
||||
|
||||
for (const { manager, argv } of candidates) {
|
||||
const res = await runCommand(argv, { timeoutMs }).catch(() => null);
|
||||
if (!res) continue;
|
||||
if (res.code !== 0) continue;
|
||||
const globalRoot = res.stdout.trim();
|
||||
if (!globalRoot) continue;
|
||||
|
||||
const globalReal = await tryRealpath(globalRoot);
|
||||
const expected = path.join(globalReal, "clawdbot");
|
||||
if (path.resolve(expected) === path.resolve(pkgReal)) return manager;
|
||||
}
|
||||
|
||||
// Bun doesn't have an officially stable "global root" command across versions,
|
||||
// so we check the common global install path (best-effort).
|
||||
const bunInstall = process.env.BUN_INSTALL?.trim() || path.join(os.homedir(), ".bun");
|
||||
const bunGlobalRoot = path.join(bunInstall, "install", "global", "node_modules");
|
||||
const bunGlobalReal = await tryRealpath(bunGlobalRoot);
|
||||
const bunExpected = path.join(bunGlobalReal, "clawdbot");
|
||||
if (path.resolve(bunExpected) === path.resolve(pkgReal)) return "bun";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type RunStepOptions = {
|
||||
runCommand: CommandRunner;
|
||||
name: string;
|
||||
@@ -278,6 +324,13 @@ function normalizeTag(tag?: string) {
|
||||
return trimmed.startsWith("clawdbot@") ? trimmed.slice("clawdbot@".length) : trimmed;
|
||||
}
|
||||
|
||||
function globalUpdateArgs(manager: "pnpm" | "npm" | "bun", tag?: string) {
|
||||
const spec = `clawdbot@${normalizeTag(tag)}`;
|
||||
if (manager === "pnpm") return ["pnpm", "add", "-g", spec];
|
||||
if (manager === "bun") return ["bun", "add", "-g", spec];
|
||||
return ["npm", "i", "-g", spec];
|
||||
}
|
||||
|
||||
export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<UpdateRunResult> {
|
||||
const startedAt = Date.now();
|
||||
const runCommand =
|
||||
@@ -551,13 +604,12 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
||||
}
|
||||
|
||||
const beforeVersion = await readPackageVersion(pkgRoot);
|
||||
const globalManager = await detectGlobalInstallManagerForRoot(runCommand, pkgRoot, timeoutMs);
|
||||
const globalManager = await detectGlobalInstallManager(runCommand, pkgRoot, timeoutMs);
|
||||
if (globalManager) {
|
||||
const spec = `clawdbot@${normalizeTag(opts.tag)}`;
|
||||
const updateStep = await runStep({
|
||||
runCommand,
|
||||
name: "global update",
|
||||
argv: globalInstallArgs(globalManager, spec),
|
||||
argv: globalUpdateArgs(globalManager, opts.tag),
|
||||
cwd: pkgRoot,
|
||||
timeoutMs,
|
||||
progress,
|
||||
|
||||
@@ -245,37 +245,6 @@ export async function addChannelAllowFromStoreEntry(params: {
|
||||
);
|
||||
}
|
||||
|
||||
export async function removeChannelAllowFromStoreEntry(params: {
|
||||
channel: PairingChannel;
|
||||
entry: string | number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{ changed: boolean; allowFrom: string[] }> {
|
||||
const env = params.env ?? process.env;
|
||||
const filePath = resolveAllowFromPath(params.channel, env);
|
||||
return await withFileLock(
|
||||
filePath,
|
||||
{ version: 1, allowFrom: [] } satisfies AllowFromStore,
|
||||
async () => {
|
||||
const { value } = await readJsonFile<AllowFromStore>(filePath, {
|
||||
version: 1,
|
||||
allowFrom: [],
|
||||
});
|
||||
const current = (Array.isArray(value.allowFrom) ? value.allowFrom : [])
|
||||
.map((v) => normalizeAllowEntry(params.channel, String(v)))
|
||||
.filter(Boolean);
|
||||
const normalized = normalizeAllowEntry(params.channel, normalizeId(params.entry));
|
||||
if (!normalized) return { changed: false, allowFrom: current };
|
||||
const next = current.filter((entry) => entry !== normalized);
|
||||
if (next.length === current.length) return { changed: false, allowFrom: current };
|
||||
await writeJsonFile(filePath, {
|
||||
version: 1,
|
||||
allowFrom: next,
|
||||
} satisfies AllowFromStore);
|
||||
return { changed: true, allowFrom: next };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function listChannelPairingRequests(
|
||||
channel: PairingChannel,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
|
||||
export type ModelOverrideSelection = {
|
||||
provider: string;
|
||||
model: string;
|
||||
isDefault?: boolean;
|
||||
};
|
||||
|
||||
export function applyModelOverrideToSessionEntry(params: {
|
||||
entry: SessionEntry;
|
||||
selection: ModelOverrideSelection;
|
||||
profileOverride?: string;
|
||||
profileOverrideSource?: "auto" | "user";
|
||||
}): { updated: boolean } {
|
||||
const { entry, selection, profileOverride } = params;
|
||||
const profileOverrideSource = params.profileOverrideSource ?? "user";
|
||||
let updated = false;
|
||||
|
||||
if (selection.isDefault) {
|
||||
if (entry.providerOverride) {
|
||||
delete entry.providerOverride;
|
||||
updated = true;
|
||||
}
|
||||
if (entry.modelOverride) {
|
||||
delete entry.modelOverride;
|
||||
updated = true;
|
||||
}
|
||||
} else {
|
||||
if (entry.providerOverride !== selection.provider) {
|
||||
entry.providerOverride = selection.provider;
|
||||
updated = true;
|
||||
}
|
||||
if (entry.modelOverride !== selection.model) {
|
||||
entry.modelOverride = selection.model;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (profileOverride) {
|
||||
if (entry.authProfileOverride !== profileOverride) {
|
||||
entry.authProfileOverride = profileOverride;
|
||||
updated = true;
|
||||
}
|
||||
if (entry.authProfileOverrideSource !== profileOverrideSource) {
|
||||
entry.authProfileOverrideSource = profileOverrideSource;
|
||||
updated = true;
|
||||
}
|
||||
if (entry.authProfileOverrideCompactionCount !== undefined) {
|
||||
delete entry.authProfileOverrideCompactionCount;
|
||||
updated = true;
|
||||
}
|
||||
} else {
|
||||
if (entry.authProfileOverride) {
|
||||
delete entry.authProfileOverride;
|
||||
updated = true;
|
||||
}
|
||||
if (entry.authProfileOverrideSource) {
|
||||
delete entry.authProfileOverrideSource;
|
||||
updated = true;
|
||||
}
|
||||
if (entry.authProfileOverrideCompactionCount !== undefined) {
|
||||
delete entry.authProfileOverrideCompactionCount;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
entry.updatedAt = Date.now();
|
||||
}
|
||||
|
||||
return { updated };
|
||||
}
|
||||
@@ -476,11 +476,10 @@ export async function prepareSlackMessage(params: {
|
||||
Surface: "slack" as const,
|
||||
MessageSid: message.ts,
|
||||
ReplyToId: message.thread_ts ?? message.ts,
|
||||
// Preserve thread context for routed tool notifications (thread replies only).
|
||||
MessageThreadId: isThreadReply ? threadTs : undefined,
|
||||
ParentSessionKey: threadKeys.parentSessionKey,
|
||||
ThreadStarterBody: threadStarterBody,
|
||||
ThreadLabel: threadLabel,
|
||||
MessageThreadId: isThreadReply ? threadTs : undefined,
|
||||
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
||||
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
|
||||
MediaPath: media?.path,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import SlackBolt from "@slack/bolt";
|
||||
import SlackBoltDefault, * as SlackBoltModule from "@slack/bolt";
|
||||
|
||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
|
||||
@@ -26,14 +26,24 @@ import { normalizeAllowList } from "./allow-list.js";
|
||||
|
||||
import type { MonitorSlackOpts } from "./types.js";
|
||||
|
||||
const slackBoltModule = SlackBolt as typeof import("@slack/bolt") & {
|
||||
default?: typeof import("@slack/bolt");
|
||||
};
|
||||
// Bun allows named imports from CJS; Node ESM doesn't. Use default+fallback for compatibility.
|
||||
// Fix: Check if module has App property directly (Node 25.x ESM/CJS compat issue)
|
||||
const slackBolt =
|
||||
(slackBoltModule.App ? slackBoltModule : slackBoltModule.default) ?? slackBoltModule;
|
||||
const { App, HTTPReceiver } = slackBolt;
|
||||
type SlackBoltNamespace = typeof import("@slack/bolt");
|
||||
type SlackBoltDefault = SlackBoltNamespace | SlackBoltNamespace["App"];
|
||||
|
||||
const slackBoltDefaultImport = SlackBoltDefault as SlackBoltDefault | undefined;
|
||||
const slackBoltModuleDefault = (SlackBoltModule as { default?: SlackBoltDefault }).default;
|
||||
const slackBoltDefault = slackBoltDefaultImport ?? slackBoltModuleDefault;
|
||||
const slackBoltNamespace =
|
||||
typeof slackBoltDefault === "object" && slackBoltDefault
|
||||
? (slackBoltDefault as SlackBoltNamespace)
|
||||
: typeof slackBoltModuleDefault === "object" && slackBoltModuleDefault
|
||||
? (slackBoltModuleDefault as SlackBoltNamespace)
|
||||
: undefined;
|
||||
// Bun allows named imports from CJS; Node ESM doesn't. Resolve default/module shapes for compatibility.
|
||||
const App = ((typeof slackBoltDefault === "function" ? slackBoltDefault : undefined) ??
|
||||
slackBoltNamespace?.App ??
|
||||
SlackBoltModule.App) as SlackBoltNamespace["App"];
|
||||
const HTTPReceiver = (slackBoltNamespace?.HTTPReceiver ??
|
||||
SlackBoltModule.HTTPReceiver) as SlackBoltNamespace["HTTPReceiver"];
|
||||
function parseApiAppIdFromAppToken(raw?: string) {
|
||||
const token = raw?.trim();
|
||||
if (!token) return undefined;
|
||||
@@ -123,6 +133,13 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
||||
|
||||
if (!App) {
|
||||
throw new Error("Slack Bolt App export missing; check @slack/bolt installation.");
|
||||
}
|
||||
if (slackMode === "http" && !HTTPReceiver) {
|
||||
throw new Error("Slack Bolt HTTPReceiver export missing; check @slack/bolt installation.");
|
||||
}
|
||||
|
||||
const receiver =
|
||||
slackMode === "http"
|
||||
? new HTTPReceiver({
|
||||
|
||||
Reference in new issue
Block a user