Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4917c2cfa | ||
|
|
205a5fd522 | ||
|
|
9ba7c8874b |
No files matched your search
@@ -29,7 +29,6 @@
|
||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
|
||||
- Type-check/build: `pnpm build` (tsc)
|
||||
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||
|
||||
+1
-18
@@ -2,49 +2,32 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.22
|
||||
|
||||
### Changes
|
||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||
|
||||
### Fixes
|
||||
- Doctor: warn when gateway.mode is unset with configure/config guidance.
|
||||
|
||||
## 2026.1.21
|
||||
|
||||
### Changes
|
||||
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
||||
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
|
||||
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
||||
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
|
||||
|
||||
### Fixes
|
||||
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
||||
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
||||
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
|
||||
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
||||
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
||||
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
|
||||
- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock).
|
||||
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
||||
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
|
||||
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
||||
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
||||
- Model picker: list the full catalog when no model allowlist is configured.
|
||||
- Chat: include configured defaults/providers in `/models` output and normalize all-mode paging. (#1398) Thanks @vignesh07.
|
||||
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
|
||||
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
|
||||
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
|
||||
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
|
||||
|
||||
## 2026.1.20
|
||||
|
||||
|
||||
@@ -280,10 +280,8 @@ enum ExecApprovalsStore {
|
||||
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||
security: agentEntry.security ?? wildcardEntry.security ?? resolvedDefaults.security,
|
||||
ask: agentEntry.ask ?? wildcardEntry.ask ?? resolvedDefaults.ask,
|
||||
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback
|
||||
?? resolvedDefaults.askFallback,
|
||||
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills
|
||||
?? resolvedDefaults.autoAllowSkills)
|
||||
askFallback: agentEntry.askFallback ?? wildcardEntry.askFallback ?? resolvedDefaults.askFallback,
|
||||
autoAllowSkills: agentEntry.autoAllowSkills ?? wildcardEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
|
||||
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
|
||||
.map { entry in
|
||||
ExecAllowlistEntry(
|
||||
@@ -556,30 +554,6 @@ enum ExecCommandFormatter {
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecApprovalHelpers {
|
||||
static func parseDecision(_ raw: String?) -> ExecApprovalDecision? {
|
||||
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return ExecApprovalDecision(rawValue: trimmed)
|
||||
}
|
||||
|
||||
static func requiresAsk(
|
||||
ask: ExecAsk,
|
||||
security: ExecSecurity,
|
||||
allowlistMatch: ExecAllowlistEntry?,
|
||||
skillAllow: Bool) -> Bool
|
||||
{
|
||||
if ask == .always { return true }
|
||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
static func allowlistPattern(command: [String], resolution: ExecCommandResolution?) -> String? {
|
||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||
return pattern.isEmpty ? nil : pattern
|
||||
}
|
||||
}
|
||||
|
||||
enum ExecAllowlistMatcher {
|
||||
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
||||
guard let resolution, !entries.isEmpty else { return nil }
|
||||
|
||||
@@ -314,7 +314,7 @@ private enum ExecHostExecutor {
|
||||
}
|
||||
|
||||
var approvedByAsk = approvalDecision != nil
|
||||
if ExecApprovalHelpers.requiresAsk(
|
||||
if self.requiresAsk(
|
||||
ask: context.ask,
|
||||
security: context.security,
|
||||
allowlistMatch: context.allowlistMatch,
|
||||
@@ -417,20 +417,36 @@ private enum ExecHostExecutor {
|
||||
skillAllow: skillAllow)
|
||||
}
|
||||
|
||||
private static func requiresAsk(
|
||||
ask: ExecAsk,
|
||||
security: ExecSecurity,
|
||||
allowlistMatch: ExecAllowlistEntry?,
|
||||
skillAllow: Bool) -> Bool
|
||||
{
|
||||
if ask == .always { return true }
|
||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private static func persistAllowlistEntry(
|
||||
decision: ExecApprovalDecision?,
|
||||
context: ExecApprovalContext)
|
||||
{
|
||||
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(
|
||||
command: context.command,
|
||||
resolution: context.resolution)
|
||||
else {
|
||||
guard let pattern = self.allowlistPattern(command: context.command, resolution: context.resolution) else {
|
||||
return
|
||||
}
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
||||
}
|
||||
|
||||
private static func allowlistPattern(
|
||||
command: [String],
|
||||
resolution: ExecCommandResolution?) -> String?
|
||||
{
|
||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
||||
return pattern.isEmpty ? nil : pattern
|
||||
}
|
||||
|
||||
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
|
||||
guard needsScreenRecording == true else { return nil }
|
||||
let authorized = await PermissionManager
|
||||
|
||||
@@ -191,6 +191,7 @@ struct GeneralSettings: View {
|
||||
if self.state.connectionMode == .remote {
|
||||
self.remoteCard
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -480,26 +480,26 @@ actor MacNodeRuntime {
|
||||
message: "SYSTEM_RUN_DISABLED: security=deny")
|
||||
}
|
||||
|
||||
let approval = await self.resolveSystemRunApproval(
|
||||
req: req,
|
||||
params: params,
|
||||
context: ExecRunContext(
|
||||
displayCommand: displayCommand,
|
||||
security: security,
|
||||
ask: ask,
|
||||
agentId: agentId,
|
||||
resolution: resolution,
|
||||
allowlistMatch: allowlistMatch,
|
||||
skillAllow: skillAllow,
|
||||
sessionKey: sessionKey,
|
||||
runId: runId))
|
||||
if let response = approval.response { return response }
|
||||
let approvedByAsk = approval.approvedByAsk
|
||||
let persistAllowlist = approval.persistAllowlist
|
||||
if persistAllowlist, security == .allowlist,
|
||||
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution)
|
||||
{
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||
let requiresAsk: Bool = {
|
||||
if ask == .always { return true }
|
||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
||||
return false
|
||||
}()
|
||||
|
||||
let approvedByAsk = params.approved == true
|
||||
if requiresAsk, !approvedByAsk {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "approval-required"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: approval required")
|
||||
}
|
||||
|
||||
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
||||
@@ -619,99 +619,6 @@ actor MacNodeRuntime {
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private struct ExecApprovalOutcome {
|
||||
var approvedByAsk: Bool
|
||||
var persistAllowlist: Bool
|
||||
var response: BridgeInvokeResponse?
|
||||
}
|
||||
|
||||
private struct ExecRunContext {
|
||||
var displayCommand: String
|
||||
var security: ExecSecurity
|
||||
var ask: ExecAsk
|
||||
var agentId: String?
|
||||
var resolution: ExecCommandResolution?
|
||||
var allowlistMatch: ExecAllowlistEntry?
|
||||
var skillAllow: Bool
|
||||
var sessionKey: String
|
||||
var runId: String
|
||||
}
|
||||
|
||||
private func resolveSystemRunApproval(
|
||||
req: BridgeInvokeRequest,
|
||||
params: ClawdbotSystemRunParams,
|
||||
context: ExecRunContext) async -> ExecApprovalOutcome
|
||||
{
|
||||
let requiresAsk = ExecApprovalHelpers.requiresAsk(
|
||||
ask: context.ask,
|
||||
security: context.security,
|
||||
allowlistMatch: context.allowlistMatch,
|
||||
skillAllow: context.skillAllow)
|
||||
|
||||
let decisionFromParams = ExecApprovalHelpers.parseDecision(params.approvalDecision)
|
||||
var approvedByAsk = params.approved == true || decisionFromParams != nil
|
||||
var persistAllowlist = decisionFromParams == .allowAlways
|
||||
if decisionFromParams == .deny {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: context.sessionKey,
|
||||
runId: context.runId,
|
||||
host: "node",
|
||||
command: context.displayCommand,
|
||||
reason: "user-denied"))
|
||||
return ExecApprovalOutcome(
|
||||
approvedByAsk: approvedByAsk,
|
||||
persistAllowlist: persistAllowlist,
|
||||
response: Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied"))
|
||||
}
|
||||
|
||||
if requiresAsk, !approvedByAsk {
|
||||
let decision = await MainActor.run {
|
||||
ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: context.displayCommand,
|
||||
cwd: params.cwd,
|
||||
host: "node",
|
||||
security: context.security.rawValue,
|
||||
ask: context.ask.rawValue,
|
||||
agentId: context.agentId,
|
||||
resolvedPath: context.resolution?.resolvedPath))
|
||||
}
|
||||
switch decision {
|
||||
case .deny:
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: context.sessionKey,
|
||||
runId: context.runId,
|
||||
host: "node",
|
||||
command: context.displayCommand,
|
||||
reason: "user-denied"))
|
||||
return ExecApprovalOutcome(
|
||||
approvedByAsk: approvedByAsk,
|
||||
persistAllowlist: persistAllowlist,
|
||||
response: Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied"))
|
||||
case .allowAlways:
|
||||
approvedByAsk = true
|
||||
persistAllowlist = true
|
||||
case .allowOnce:
|
||||
approvedByAsk = true
|
||||
}
|
||||
}
|
||||
|
||||
return ExecApprovalOutcome(
|
||||
approvedByAsk: approvedByAsk,
|
||||
persistAllowlist: persistAllowlist,
|
||||
response: nil)
|
||||
}
|
||||
|
||||
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
||||
|
||||
@@ -47,6 +47,7 @@ struct PermissionStatusList: View {
|
||||
.font(.footnote)
|
||||
.padding(.top, 2)
|
||||
.help("Refresh status")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,6 @@ final class TailscaleService {
|
||||
}
|
||||
|
||||
nonisolated static func fallbackTailnetIPv4() -> String? {
|
||||
self.detectTailnetIPv4()
|
||||
Self.detectTailnetIPv4()
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
public let caps: [String]?
|
||||
public let commands: [String]?
|
||||
public let permissions: [String: AnyCodable]?
|
||||
public let pathenv: String?
|
||||
public let role: String?
|
||||
public let scopes: [String]?
|
||||
public let device: [String: AnyCodable]?
|
||||
@@ -33,7 +32,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
caps: [String]?,
|
||||
commands: [String]?,
|
||||
permissions: [String: AnyCodable]?,
|
||||
pathenv: String?,
|
||||
role: String?,
|
||||
scopes: [String]?,
|
||||
device: [String: AnyCodable]?,
|
||||
@@ -47,7 +45,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.permissions = permissions
|
||||
self.pathenv = pathenv
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
self.device = device
|
||||
@@ -62,7 +59,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
case caps
|
||||
case commands
|
||||
case permissions
|
||||
case pathenv = "pathEnv"
|
||||
case role
|
||||
case scopes
|
||||
case device
|
||||
@@ -1908,7 +1904,6 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let id: String?
|
||||
public let command: String
|
||||
public let cwd: String?
|
||||
public let host: String?
|
||||
@@ -1920,7 +1915,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
id: String?,
|
||||
command: String,
|
||||
cwd: String?,
|
||||
host: String?,
|
||||
@@ -1931,7 +1925,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
sessionkey: String?,
|
||||
timeoutms: Int?
|
||||
) {
|
||||
self.id = id
|
||||
self.command = command
|
||||
self.cwd = cwd
|
||||
self.host = host
|
||||
@@ -1943,7 +1936,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
self.timeoutms = timeoutms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case command
|
||||
case cwd
|
||||
case host
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct ExecApprovalHelpersTests {
|
||||
@Test func parseDecisionTrimsAndRejectsInvalid() {
|
||||
#expect(ExecApprovalHelpers.parseDecision("allow-once") == .allowOnce)
|
||||
#expect(ExecApprovalHelpers.parseDecision(" allow-always ") == .allowAlways)
|
||||
#expect(ExecApprovalHelpers.parseDecision("deny") == .deny)
|
||||
#expect(ExecApprovalHelpers.parseDecision("") == nil)
|
||||
#expect(ExecApprovalHelpers.parseDecision("nope") == nil)
|
||||
}
|
||||
|
||||
@Test func allowlistPatternPrefersResolution() {
|
||||
let resolved = ExecCommandResolution(
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
executableName: "rg",
|
||||
cwd: nil)
|
||||
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: resolved) == resolved.resolvedPath)
|
||||
|
||||
let rawOnly = ExecCommandResolution(
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: nil,
|
||||
executableName: "rg",
|
||||
cwd: nil)
|
||||
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: rawOnly) == "rg")
|
||||
#expect(ExecApprovalHelpers.allowlistPattern(command: ["rg"], resolution: nil) == "rg")
|
||||
#expect(ExecApprovalHelpers.allowlistPattern(command: [], resolution: nil) == nil)
|
||||
}
|
||||
|
||||
@Test func requiresAskMatchesPolicy() {
|
||||
let entry = ExecAllowlistEntry(pattern: "/bin/ls", lastUsedAt: nil, lastUsedCommand: nil, lastResolvedPath: nil)
|
||||
#expect(ExecApprovalHelpers.requiresAsk(
|
||||
ask: .always,
|
||||
security: .deny,
|
||||
allowlistMatch: nil,
|
||||
skillAllow: false))
|
||||
#expect(ExecApprovalHelpers.requiresAsk(
|
||||
ask: .onMiss,
|
||||
security: .allowlist,
|
||||
allowlistMatch: nil,
|
||||
skillAllow: false))
|
||||
#expect(!ExecApprovalHelpers.requiresAsk(
|
||||
ask: .onMiss,
|
||||
security: .allowlist,
|
||||
allowlistMatch: entry,
|
||||
skillAllow: false))
|
||||
#expect(!ExecApprovalHelpers.requiresAsk(
|
||||
ask: .onMiss,
|
||||
security: .allowlist,
|
||||
allowlistMatch: nil,
|
||||
skillAllow: true))
|
||||
#expect(!ExecApprovalHelpers.requiresAsk(
|
||||
ask: .off,
|
||||
security: .allowlist,
|
||||
allowlistMatch: nil,
|
||||
skillAllow: false))
|
||||
}
|
||||
}
|
||||
@@ -48,10 +48,7 @@ import Testing
|
||||
|
||||
@Test func expectedGatewayVersionFromStringUsesParser() {
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(
|
||||
major: 2026,
|
||||
minor: 1,
|
||||
patch: 11))
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
|
||||
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
|
||||
}
|
||||
}
|
||||
@@ -571,14 +571,7 @@ public actor GatewayChannelActor {
|
||||
id: id,
|
||||
method: method,
|
||||
params: paramsObject)
|
||||
let data: Data
|
||||
do {
|
||||
data = try self.encoder.encode(frame)
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"gateway request encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
let data = try self.encoder.encode(frame)
|
||||
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
||||
self.pending[id] = cont
|
||||
Task { [weak self] in
|
||||
|
||||
@@ -219,8 +219,8 @@ public actor GatewayNodeSession {
|
||||
}
|
||||
if let error = response.error {
|
||||
params["error"] = AnyCodable([
|
||||
"code": error.code.rawValue,
|
||||
"message": error.message,
|
||||
"code": AnyCodable(error.code.rawValue),
|
||||
"message": AnyCodable(error.message),
|
||||
])
|
||||
}
|
||||
do {
|
||||
|
||||
@@ -30,7 +30,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||
public var agentId: String?
|
||||
public var sessionKey: String?
|
||||
public var approved: Bool?
|
||||
public var approvalDecision: String?
|
||||
|
||||
public init(
|
||||
command: [String],
|
||||
@@ -41,8 +40,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||
needsScreenRecording: Bool? = nil,
|
||||
agentId: String? = nil,
|
||||
sessionKey: String? = nil,
|
||||
approved: Bool? = nil,
|
||||
approvalDecision: String? = nil)
|
||||
approved: Bool? = nil)
|
||||
{
|
||||
self.command = command
|
||||
self.rawCommand = rawCommand
|
||||
@@ -53,7 +51,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||
self.agentId = agentId
|
||||
self.sessionKey = sessionKey
|
||||
self.approved = approved
|
||||
self.approvalDecision = approvalDecision
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
public let caps: [String]?
|
||||
public let commands: [String]?
|
||||
public let permissions: [String: AnyCodable]?
|
||||
public let pathenv: String?
|
||||
public let role: String?
|
||||
public let scopes: [String]?
|
||||
public let device: [String: AnyCodable]?
|
||||
@@ -33,7 +32,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
caps: [String]?,
|
||||
commands: [String]?,
|
||||
permissions: [String: AnyCodable]?,
|
||||
pathenv: String?,
|
||||
role: String?,
|
||||
scopes: [String]?,
|
||||
device: [String: AnyCodable]?,
|
||||
@@ -47,7 +45,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
self.caps = caps
|
||||
self.commands = commands
|
||||
self.permissions = permissions
|
||||
self.pathenv = pathenv
|
||||
self.role = role
|
||||
self.scopes = scopes
|
||||
self.device = device
|
||||
@@ -62,7 +59,6 @@ public struct ConnectParams: Codable, Sendable {
|
||||
case caps
|
||||
case commands
|
||||
case permissions
|
||||
case pathenv = "pathEnv"
|
||||
case role
|
||||
case scopes
|
||||
case device
|
||||
@@ -1908,7 +1904,6 @@ public struct ExecApprovalsSnapshot: Codable, Sendable {
|
||||
}
|
||||
|
||||
public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let id: String?
|
||||
public let command: String
|
||||
public let cwd: String?
|
||||
public let host: String?
|
||||
@@ -1920,7 +1915,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let timeoutms: Int?
|
||||
|
||||
public init(
|
||||
id: String?,
|
||||
command: String,
|
||||
cwd: String?,
|
||||
host: String?,
|
||||
@@ -1931,7 +1925,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
sessionkey: String?,
|
||||
timeoutms: Int?
|
||||
) {
|
||||
self.id = id
|
||||
self.command = command
|
||||
self.cwd = cwd
|
||||
self.host = host
|
||||
@@ -1943,7 +1936,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
self.timeoutms = timeoutms
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case command
|
||||
case cwd
|
||||
case host
|
||||
|
||||
@@ -334,7 +334,6 @@ WhatsApp sends audio as **voice notes** (PTT bubble).
|
||||
- `agents.defaults.heartbeat.model` (optional override)
|
||||
- `agents.defaults.heartbeat.target`
|
||||
- `agents.defaults.heartbeat.to`
|
||||
- `agents.defaults.heartbeat.session`
|
||||
- `agents.list[].heartbeat.*` (per-agent overrides)
|
||||
- `session.*` (scope, idle, store, mainKey)
|
||||
- `web.enabled` (disable channel startup when false)
|
||||
|
||||
@@ -38,7 +38,7 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
|
||||
- Provider: `anthropic`
|
||||
- Auth: `ANTHROPIC_API_KEY` or `claude setup-token`
|
||||
- Example model: `anthropic/claude-opus-4-5`
|
||||
- CLI: `clawdbot onboard --auth-choice token` (paste setup-token) or `clawdbot models auth paste-token --provider anthropic`
|
||||
- CLI: `clawdbot onboard --auth-choice setup-token`
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -58,8 +58,8 @@ Exact allowlist is enforced in `src/gateway/server-bridge.ts`.
|
||||
|
||||
## Exec lifecycle events
|
||||
|
||||
Nodes can emit `exec.finished` or `exec.denied` events to surface system.run activity.
|
||||
These are mapped to system events in the gateway. (Legacy nodes may still emit `exec.started`.)
|
||||
Nodes can emit `exec.started`, `exec.finished`, or `exec.denied` events to surface
|
||||
system.run activity. These are mapped to system events in the gateway.
|
||||
|
||||
Payload fields (all optional unless noted):
|
||||
- `sessionKey` (required): agent session to receive the system event.
|
||||
|
||||
@@ -24,7 +24,7 @@ Unknown keys, malformed types, or invalid values cause the Gateway to **refuse t
|
||||
|
||||
When validation fails:
|
||||
- The Gateway does not boot.
|
||||
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot service`, `clawdbot help`).
|
||||
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot gateway status`, `clawdbot gateway probe`, `clawdbot help`).
|
||||
- Run `clawdbot doctor` to see the exact issues.
|
||||
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs.
|
||||
|
||||
@@ -1414,7 +1414,7 @@ Each `agents.defaults.models` entry can include:
|
||||
- `alias` (optional model shortcut, e.g. `/opus`).
|
||||
- `params` (optional provider-specific API params passed through to the model request).
|
||||
|
||||
`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`. These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change.
|
||||
`params` is also applied to streaming runs (embedded agent + compaction). Supported keys today: `temperature`, `maxTokens`, `cacheControlTtl` (`"5m"` or `"1h"`, Anthropic API + OpenRouter Anthropic models only; ignored for Anthropic OAuth/Claude Code tokens). These merge with call-time options; caller-supplied values win. `temperature` is an advanced knob—leave unset unless you know the model’s defaults and need a change. Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API; keep it if you override provider headers.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -1569,7 +1569,7 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
#### `agents.defaults.contextPruning` (tool-result pruning)
|
||||
#### `agents.defaults.contextPruning` (TTL-aware tool-result pruning)
|
||||
|
||||
`agents.defaults.contextPruning` prunes **old tool results** from the in-memory context right before a request is sent to the LLM.
|
||||
It does **not** modify the session history on disk (`*.jsonl` remains complete).
|
||||
@@ -1580,11 +1580,9 @@ High level:
|
||||
- Never touches user/assistant messages.
|
||||
- Protects the last `keepLastAssistants` assistant messages (no tool results after that point are pruned).
|
||||
- Protects the bootstrap prefix (nothing before the first user message is pruned).
|
||||
- Modes:
|
||||
- `adaptive`: soft-trims oversized tool results (keep head/tail) when the estimated context ratio crosses `softTrimRatio`.
|
||||
Then hard-clears the oldest eligible tool results when the estimated context ratio crosses `hardClearRatio` **and**
|
||||
there’s enough prunable tool-result bulk (`minPrunableToolChars`).
|
||||
- `aggressive`: always replaces eligible tool results before the cutoff with the `hardClear.placeholder` (no ratio checks).
|
||||
- Mode:
|
||||
- `cache-ttl`: pruning only runs when the last Anthropic call for the session is **older** than `ttl`.
|
||||
When it runs, it uses the same soft-trim + hard-clear behavior as before.
|
||||
|
||||
Soft vs hard pruning (what changes in the context sent to the LLM):
|
||||
- **Soft-trim**: only for *oversized* tool results. Keeps the beginning + end and inserts `...` in the middle.
|
||||
@@ -1598,44 +1596,41 @@ Notes / current limitations:
|
||||
- Tool results containing **image blocks are skipped** (never trimmed/cleared) right now.
|
||||
- The estimated “context ratio” is based on **characters** (approximate), not exact tokens.
|
||||
- If the session doesn’t contain at least `keepLastAssistants` assistant messages yet, pruning is skipped.
|
||||
- In `aggressive` mode, `hardClear.enabled` is ignored (eligible tool results are always replaced with `hardClear.placeholder`).
|
||||
- `cache-ttl` only activates for Anthropic API calls (and OpenRouter Anthropic models).
|
||||
- After a prune, the TTL window resets so subsequent requests keep cache until `ttl` expires again.
|
||||
- For best results, match `contextPruning.ttl` to the model `cacheControlTtl` you set in `agents.defaults.models.*.params`.
|
||||
|
||||
Default (adaptive):
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { contextPruning: { mode: "adaptive" } } }
|
||||
}
|
||||
```
|
||||
|
||||
To disable:
|
||||
Default (off, unless Anthropic auth profiles are detected):
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { contextPruning: { mode: "off" } } }
|
||||
}
|
||||
```
|
||||
|
||||
Defaults (when `mode` is `"adaptive"` or `"aggressive"`):
|
||||
- `keepLastAssistants`: `3`
|
||||
- `softTrimRatio`: `0.3` (adaptive only)
|
||||
- `hardClearRatio`: `0.5` (adaptive only)
|
||||
- `minPrunableToolChars`: `50000` (adaptive only)
|
||||
- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }` (adaptive only)
|
||||
- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }`
|
||||
|
||||
Example (aggressive, minimal):
|
||||
Enable TTL-aware pruning:
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { contextPruning: { mode: "aggressive" } } }
|
||||
agents: { defaults: { contextPruning: { mode: "cache-ttl" } } }
|
||||
}
|
||||
```
|
||||
|
||||
Example (adaptive tuned):
|
||||
Defaults (when `mode` is `"cache-ttl"`):
|
||||
- `ttl`: `"5m"`
|
||||
- `keepLastAssistants`: `3`
|
||||
- `softTrimRatio`: `0.3`
|
||||
- `hardClearRatio`: `0.5`
|
||||
- `minPrunableToolChars`: `50000`
|
||||
- `softTrim`: `{ maxChars: 4000, headChars: 1500, tailChars: 1500 }`
|
||||
- `hardClear`: `{ enabled: true, placeholder: "[Old tool result content cleared]" }`
|
||||
|
||||
Example (cache-ttl tuned):
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
contextPruning: {
|
||||
mode: "adaptive",
|
||||
mode: "cache-ttl",
|
||||
ttl: "5m",
|
||||
keepLastAssistants: 3,
|
||||
softTrimRatio: 0.3,
|
||||
hardClearRatio: 0.5,
|
||||
@@ -1742,9 +1737,12 @@ Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
|
||||
`30m`. Set `0m` to disable.
|
||||
- `model`: optional override model for heartbeat runs (`provider/model`).
|
||||
- `includeReasoning`: when `true`, heartbeats will also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). Default: `false`.
|
||||
- `session`: optional session key to control which session the heartbeat runs in. Default: `main`.
|
||||
- `activeHours`: optional local-time window that controls when heartbeats run.
|
||||
- `start`: start time (HH:MM, 24h). Inclusive.
|
||||
- `end`: end time (HH:MM, 24h). Exclusive. Use `"24:00"` for end-of-day.
|
||||
- `timezone`: `"user"` (default), `"local"`, or an IANA timezone id.
|
||||
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
|
||||
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
|
||||
- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `msteams`, `signal`, `imessage`, `none`). Default: `last`.
|
||||
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md` line if you still want the file read.
|
||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 300).
|
||||
|
||||
@@ -1775,6 +1773,7 @@ Note: `applyPatch` is only under `tools.exec`.
|
||||
- `tools.web.fetch.maxChars` (default 50000)
|
||||
- `tools.web.fetch.timeoutSeconds` (default 30)
|
||||
- `tools.web.fetch.cacheTtlMinutes` (default 15)
|
||||
- `tools.web.fetch.maxRedirects` (default 3)
|
||||
- `tools.web.fetch.userAgent` (optional override)
|
||||
- `tools.web.fetch.readability` (default true; disable to use basic HTML cleanup only)
|
||||
- `tools.web.fetch.firecrawl.enabled` (default true when an API key is set)
|
||||
@@ -1841,7 +1840,7 @@ Example:
|
||||
|
||||
`agents.defaults.subagents` configures sub-agent defaults:
|
||||
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
|
||||
- `maxConcurrent`: max concurrent sub-agent runs (default 1)
|
||||
- `maxConcurrent`: max concurrent sub-agent runs (default 8)
|
||||
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
|
||||
- Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny` (deny wins)
|
||||
|
||||
@@ -1975,7 +1974,7 @@ Notes:
|
||||
|
||||
`agents.defaults.maxConcurrent` sets the maximum number of embedded agent runs that can
|
||||
execute in parallel across sessions. Each session is still serialized (one run
|
||||
per session key at a time). Default: 1.
|
||||
per session key at a time). Default: 4.
|
||||
|
||||
### `agents.defaults.sandbox`
|
||||
|
||||
@@ -2453,6 +2452,9 @@ Controls session scoping, reset policy, reset triggers, and where the session st
|
||||
dm: { mode: "idle", idleMinutes: 240 },
|
||||
group: { mode: "idle", idleMinutes: 120 }
|
||||
},
|
||||
resetByChannel: {
|
||||
discord: { mode: "idle", idleMinutes: 10080 }
|
||||
},
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
// Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
|
||||
// You can override with {agentId} templating:
|
||||
@@ -2488,7 +2490,7 @@ Fields:
|
||||
- `idleMinutes`: sliding idle window in minutes. When daily + idle are both configured, whichever expires first wins.
|
||||
- `resetByType`: per-session overrides for `dm`, `group`, and `thread`.
|
||||
- If you only set legacy `session.idleMinutes` without any `reset`/`resetByType`, Clawdbot stays in idle-only mode for backward compatibility.
|
||||
- `heartbeatIdleMinutes`: optional idle override for heartbeat checks (daily reset still applies when enabled).
|
||||
- `resetByChannel`: channel-specific reset policy overrides (keyed by channel id, applies to all session types for that channel; overrides `reset`/`resetByType`).
|
||||
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
|
||||
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
|
||||
- `sendPolicy.rules[]`: match by `channel`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.
|
||||
@@ -2615,10 +2617,13 @@ Defaults:
|
||||
// noSandbox: false,
|
||||
// executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
||||
// attachOnly: false, // set true when tunneling a remote CDP to localhost
|
||||
// snapshotDefaults: { mode: "efficient" }, // tool/CLI default snapshot preset
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: `browser.snapshotDefaults` only affects Clawdbot's browser tool + CLI. Direct HTTP clients must pass `mode` explicitly.
|
||||
|
||||
### `ui` (Appearance)
|
||||
|
||||
Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).
|
||||
@@ -2642,6 +2647,13 @@ Defaults:
|
||||
- bind: `loopback`
|
||||
- port: `18789` (single port for WS + HTTP)
|
||||
|
||||
Bind modes:
|
||||
- `loopback`: `127.0.0.1` (local-only)
|
||||
- `lan`: `0.0.0.0` (all interfaces)
|
||||
- `tailnet`: Tailscale IPv4 address (100.64.0.0/10)
|
||||
- `auto`: prefer loopback, fall back to LAN if loopback cannot bind
|
||||
- `custom`: `gateway.customBindHost` (IPv4), fallback to LAN if unavailable
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
@@ -2659,8 +2671,6 @@ Control UI base path:
|
||||
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
|
||||
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
|
||||
- Default: root (`/`) (unchanged).
|
||||
- `gateway.controlUi.allowInsecureAuth` allows token-only auth over **HTTP** (no device identity).
|
||||
Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`.
|
||||
|
||||
Related docs:
|
||||
- [Control UI](/web/control-ui)
|
||||
@@ -2672,14 +2682,15 @@ Notes:
|
||||
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
|
||||
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
|
||||
- OpenAI Chat Completions endpoint: **disabled by default**; enable with `gateway.http.endpoints.chatCompletions.enabled: true`.
|
||||
- OpenResponses endpoint: **disabled by default**; enable with `gateway.http.endpoints.responses.enabled: true`.
|
||||
- Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
|
||||
- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
|
||||
- Non-loopback binds (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`).
|
||||
- The onboarding wizard generates a gateway token by default (even on loopback).
|
||||
- `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored.
|
||||
|
||||
Auth and Tailscale:
|
||||
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
|
||||
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
|
||||
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine and as the bootstrap credential for device pairing).
|
||||
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
|
||||
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
|
||||
- `gateway.auth.allowTailscale` allows Tailscale Serve identity headers
|
||||
@@ -2688,6 +2699,9 @@ Auth and Tailscale:
|
||||
`true`, Serve requests do not need a token/password; set `false` to require
|
||||
explicit credentials. Defaults to `true` when `tailscale.mode = "serve"` and
|
||||
auth mode is not `password`.
|
||||
- After pairing, the Gateway issues **device tokens** scoped to the device role + scopes.
|
||||
These are returned in `hello-ok.auth.deviceToken`; clients should persist and reuse them
|
||||
instead of the shared token. Rotate/revoke via `device.token.rotate`/`device.token.revoke`.
|
||||
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
|
||||
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
|
||||
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.
|
||||
@@ -2696,6 +2710,7 @@ Remote client defaults (CLI):
|
||||
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
|
||||
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
|
||||
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
|
||||
- `gateway.remote.tlsFingerprint` pins the gateway TLS cert fingerprint (sha256).
|
||||
|
||||
macOS app behavior:
|
||||
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
|
||||
@@ -2709,12 +2724,36 @@ macOS app behavior:
|
||||
remote: {
|
||||
url: "ws://gateway.tailnet:18789",
|
||||
token: "your-token",
|
||||
password: "your-password"
|
||||
password: "your-password",
|
||||
tlsFingerprint: "sha256:ab12cd34..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `gateway.nodes` (Node command allowlist)
|
||||
|
||||
The Gateway enforces a per-platform command allowlist for `node.invoke`. Nodes must both
|
||||
**declare** a command and have it **allowed** by the Gateway to run it.
|
||||
|
||||
Use this section to extend or deny commands:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
nodes: {
|
||||
allowCommands: ["custom.vendor.command"], // extra commands beyond defaults
|
||||
denyCommands: ["sms.send"] // block a command even if declared
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `allowCommands` extends the built-in per-platform defaults.
|
||||
- `denyCommands` always wins (even if the node claims the command).
|
||||
- `node.invoke` rejects commands that are not declared by the node.
|
||||
|
||||
### `gateway.reload` (Config hot reload)
|
||||
|
||||
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
|
||||
@@ -2962,7 +3001,7 @@ Auto-generated certs require `openssl` on PATH; if generation fails, the bridge
|
||||
|
||||
### `discovery.wideArea` (Wide-Area Bonjour / unicast DNS‑SD)
|
||||
|
||||
When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-bridge._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.`
|
||||
When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-gw._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.`
|
||||
|
||||
To make iOS/Android discover across networks (Vienna ⇄ London), pair this with:
|
||||
- a DNS server on the gateway host serving `clawdbot.internal.` (CoreDNS is recommended)
|
||||
@@ -2992,6 +3031,9 @@ Template placeholders are expanded in `tools.media.*.models[].args` and `tools.m
|
||||
| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per channel) |
|
||||
| `{{To}}` | Destination identifier |
|
||||
| `{{MessageSid}}` | Channel message id (when available) |
|
||||
| `{{MessageSidFull}}` | Provider-specific full message id when `MessageSid` is shortened |
|
||||
| `{{ReplyToId}}` | Reply-to message id (when available) |
|
||||
| `{{ReplyToIdFull}}` | Provider-specific full reply-to id when `ReplyToId` is shortened |
|
||||
| `{{SessionId}}` | Current session UUID |
|
||||
| `{{IsNewSession}}` | `"true"` when a new session was created |
|
||||
| `{{MediaUrl}}` | Inbound media pseudo-URL (if present) |
|
||||
|
||||
@@ -127,26 +127,18 @@ Example: two agents, only the second agent runs heartbeats.
|
||||
- `every`: heartbeat interval (duration string; default unit = minutes).
|
||||
- `model`: optional model override for heartbeat runs (`provider/model`).
|
||||
- `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`).
|
||||
- `session`: optional session key for heartbeat runs.
|
||||
- `main` (default): agent main session.
|
||||
- Explicit session key (copy from `clawdbot sessions --json` or the [sessions CLI](/cli/sessions)).
|
||||
- Session key formats: see [Sessions](/concepts/session) and [Groups](/concepts/groups).
|
||||
- `target`:
|
||||
- `last` (default): deliver to the last used external channel.
|
||||
- explicit channel: `whatsapp` / `telegram` / `discord` / `slack` / `msteams` / `signal` / `imessage`.
|
||||
- explicit channel: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage`.
|
||||
- `none`: run the heartbeat but **do not deliver** externally.
|
||||
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id).
|
||||
- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram, etc.).
|
||||
- `prompt`: overrides the default prompt body (not merged).
|
||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery.
|
||||
|
||||
## Delivery behavior
|
||||
|
||||
- Heartbeats run in the agent’s main session by default (`agent:<id>:<mainKey>`),
|
||||
or `global` when `session.scope = "global"`. Set `session` to override to a
|
||||
specific channel session (Discord/WhatsApp/etc.).
|
||||
- `session` only affects the run context; delivery is controlled by `target` and `to`.
|
||||
- To deliver to a specific channel/recipient, set `target` + `to`. With
|
||||
`target: "last"`, delivery uses the last external channel for that session.
|
||||
- Heartbeats run in each agent’s **main session** (`agent:<id>:<mainKey>`), or `global`
|
||||
when `session.scope = "global"`.
|
||||
- If the main queue is busy, the heartbeat is skipped and retried later.
|
||||
- If `target` resolves to no external destination, the run still happens but no
|
||||
outbound message is sent.
|
||||
|
||||
@@ -198,7 +198,6 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
||||
- **Local** connects include loopback and the gateway host’s own tailnet address
|
||||
(so same‑host tailnet binds can still auto‑approve).
|
||||
- All WS clients must include `device` identity during `connect` (operator + node).
|
||||
Control UI can omit it **only** when `gateway.controlUi.allowInsecureAuth` is enabled.
|
||||
- Non-local connections must sign the server-provided `connect.challenge` nonce.
|
||||
|
||||
## TLS + pinning
|
||||
|
||||
@@ -52,15 +52,6 @@ When the audit prints findings, treat this as a priority order:
|
||||
5. **Plugins/extensions**: only load what you explicitly trust.
|
||||
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
|
||||
|
||||
## Control UI over HTTP
|
||||
|
||||
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
|
||||
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
|
||||
to **token-only auth** on plain HTTP and skips device pairing. This is a security
|
||||
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
|
||||
|
||||
`clawdbot security audit` warns when this setting is enabled.
|
||||
|
||||
## Local session logs live on disk
|
||||
|
||||
Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.
|
||||
|
||||
@@ -31,19 +31,6 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Control UI fails on HTTP ("device identity required" / "connect failed")
|
||||
|
||||
If you open the dashboard over plain HTTP (e.g. `http://<lan-ip>:18789/` or
|
||||
`http://<tailscale-ip>:18789/`), the browser runs in a **non-secure context** and
|
||||
blocks WebCrypto, so device identity can’t be generated.
|
||||
|
||||
**Fix:**
|
||||
- Prefer HTTPS via [Tailscale Serve](/gateway/tailscale).
|
||||
- Or open locally on the gateway host: `http://127.0.0.1:18789/`.
|
||||
- If you must stay on HTTP, enable `gateway.controlUi.allowInsecureAuth: true` and
|
||||
use a gateway token (token-only; no device identity/pairing). See
|
||||
[Control UI](/web/control-ui#insecure-http).
|
||||
|
||||
### CI Secrets Scan Failed
|
||||
|
||||
This means `detect-secrets` found new candidates not yet in the baseline.
|
||||
@@ -82,34 +69,6 @@ Doctor/service will show runtime state (PID/last exit) and log hints.
|
||||
|
||||
See [/logging](/logging) for a full overview of formats, config, and access.
|
||||
|
||||
### "Gateway start blocked: set gateway.mode=local"
|
||||
|
||||
This means the config exists but `gateway.mode` is unset (or not `local`), so the
|
||||
Gateway refuses to start.
|
||||
|
||||
**Fix (recommended):**
|
||||
- Run the wizard and set the Gateway run mode to **Local**:
|
||||
```bash
|
||||
clawdbot configure
|
||||
```
|
||||
- Or set it directly:
|
||||
```bash
|
||||
clawdbot config set gateway.mode local
|
||||
```
|
||||
|
||||
**If you meant to run a remote Gateway instead:**
|
||||
- Set a remote URL and keep `gateway.mode=remote`:
|
||||
```bash
|
||||
clawdbot config set gateway.mode remote
|
||||
clawdbot config set gateway.remote.url "wss://gateway.example.com"
|
||||
```
|
||||
|
||||
**Ad-hoc/dev only:** pass `--allow-unconfigured` to start the gateway without
|
||||
`gateway.mode=local`.
|
||||
|
||||
**No config file yet?** Run `clawdbot setup` to create a starter config, then rerun
|
||||
the gateway.
|
||||
|
||||
### Service Environment (PATH + runtime)
|
||||
|
||||
The gateway service runs with a **minimal PATH** to avoid shell/manager cruft:
|
||||
|
||||
@@ -38,11 +38,6 @@ Almost always a Node/npm PATH issue. Start here:
|
||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||
- [Gateway authentication](/gateway/authentication)
|
||||
|
||||
### Control UI fails on HTTP (device identity required)
|
||||
|
||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||
- [Control UI](/web/control-ui#insecure-http)
|
||||
|
||||
### Service says running, but RPC probe fails
|
||||
|
||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||
|
||||
@@ -24,7 +24,6 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
|
||||
Notes:
|
||||
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
|
||||
- Defaults to the current architecture (`$(uname -m)`). For release/universal builds, set `BUILD_ARCHS="arm64 x86_64"` (or `BUILD_ARCHS=all`).
|
||||
- Use `scripts/package-mac-dist.sh` for release artifacts (zip + DMG + notarization). Use `scripts/package-mac-app.sh` for local/dev packaging.
|
||||
|
||||
```bash
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
|
||||
@@ -70,9 +70,11 @@ Setup-tokens are created by the **Claude Code CLI**, not the Anthropic Console.
|
||||
claude setup-token
|
||||
```
|
||||
|
||||
Paste the token into Clawdbot (wizard: **Anthropic token (paste setup-token)**), or run it on the gateway host:
|
||||
Paste the token into Clawdbot (wizard: **Anthropic token (paste setup-token)**), or let Clawdbot run the command locally:
|
||||
|
||||
```bash
|
||||
clawdbot onboard --auth-choice setup-token
|
||||
# or
|
||||
clawdbot models auth setup-token --provider anthropic
|
||||
```
|
||||
|
||||
@@ -85,6 +87,9 @@ clawdbot models auth paste-token --provider anthropic
|
||||
### CLI setup
|
||||
|
||||
```bash
|
||||
# Run setup-token locally (wizard can run it for you)
|
||||
clawdbot onboard --auth-choice setup-token
|
||||
|
||||
# Reuse Claude Code CLI OAuth credentials if already logged in
|
||||
clawdbot onboard --auth-choice claude-cli
|
||||
```
|
||||
@@ -99,7 +104,7 @@ clawdbot onboard --auth-choice claude-cli
|
||||
|
||||
## Notes
|
||||
|
||||
- Generate the setup-token with `claude setup-token` and paste it, or run `clawdbot models auth setup-token` on the gateway host.
|
||||
- The wizard can run `claude setup-token` locally and store the token, or you can paste a token generated elsewhere.
|
||||
- Clawdbot writes `auth.profiles["anthropic:claude-cli"].mode` as `"oauth"` so the profile
|
||||
accepts both OAuth and setup-token credentials. Older configs using `"token"` are
|
||||
auto-migrated on load.
|
||||
|
||||
+3
-3
@@ -241,7 +241,7 @@ It also warns if your configured model is unknown or missing auth.
|
||||
|
||||
### How does Anthropic "setup-token" auth work?
|
||||
|
||||
`claude setup-token` generates a **token string** via the Claude Code CLI (it is not available in the web console). You can run it on **any machine**. If Claude Code CLI credentials are present on the gateway host, Clawdbot can reuse them; otherwise choose **Anthropic token (paste setup-token)** and paste the string. The token is stored as an auth profile for the **anthropic** provider and used like an API key or OAuth profile. More detail: [OAuth](/concepts/oauth).
|
||||
`claude setup-token` generates a **token string** via the Claude Code CLI (it is not available in the web console). You can run it on **any machine**. If you run it on the gateway host, the wizard can auto-detect the CLI credentials. If you run it elsewhere, choose **Anthropic token (paste setup-token)** and paste the string. The token is stored as an auth profile for the **anthropic** provider and used like an API key or OAuth profile. More detail: [OAuth](/concepts/oauth).
|
||||
|
||||
Clawdbot keeps `auth.profiles["anthropic:claude-cli"].mode` set to `"oauth"` so
|
||||
the profile accepts both OAuth and setup-token credentials; older `"token"` mode
|
||||
@@ -255,11 +255,11 @@ It is **not** in the Anthropic Console. The setup-token is generated by the **Cl
|
||||
claude setup-token
|
||||
```
|
||||
|
||||
Copy the token it prints, then choose **Anthropic token (paste setup-token)** in the wizard. If you want to run it on the gateway host, use `clawdbot models auth setup-token --provider anthropic`. If you ran `claude setup-token` elsewhere, paste it on the gateway host with `clawdbot models auth paste-token --provider anthropic`. See [Anthropic](/providers/anthropic).
|
||||
Copy the token it prints, then choose **Anthropic token (paste setup-token)** in the wizard. If you want Clawdbot to run the command for you, use `clawdbot onboard --auth-choice setup-token` or `clawdbot models auth setup-token --provider anthropic`. If you ran `claude setup-token` elsewhere, paste it on the gateway host with `clawdbot models auth paste-token --provider anthropic`. See [Anthropic](/providers/anthropic).
|
||||
|
||||
### Do you support Claude subscription auth (Claude Code OAuth)?
|
||||
|
||||
Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also supports **setup-token**. If you have a Claude subscription, we recommend **setup-token** for long‑running setups (requires Claude Pro/Max + the `claude` CLI). You can generate it anywhere and paste it on the gateway host. OAuth reuse is supported, but avoid logging in separately via Clawdbot and Claude Code to prevent token conflicts. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth).
|
||||
Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also supports **setup-token**. If you have a Claude subscription, we recommend **setup-token** for long‑running setups (requires Claude Pro/Max + the `claude` CLI). You can generate it anywhere and paste it on the gateway host, or run it locally on the gateway so it auto-syncs. OAuth reuse is supported, but avoid logging in separately via Clawdbot and Claude Code to prevent token conflicts. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth).
|
||||
|
||||
Note: Claude subscription access is governed by Anthropic’s terms. For production or multi‑user workloads, API keys are usually the safer choice.
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
## What the wizard does
|
||||
|
||||
**Local mode (default)** walks you through:
|
||||
- Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or setup-token (paste), plus MiniMax/GLM/Moonshot/AI Gateway options)
|
||||
- Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or `claude setup-token`, plus MiniMax/GLM/Moonshot/AI Gateway options)
|
||||
- Workspace location + bootstrap files
|
||||
- Gateway settings (port/bind/auth/tailscale)
|
||||
- Providers (Telegram, WhatsApp, Discord, Signal)
|
||||
@@ -79,8 +79,9 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
||||
|
||||
2) **Model/Auth**
|
||||
- **Anthropic API key (recommended)**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
|
||||
- **Anthropic token (setup-token)**: run `claude setup-token` locally (the wizard can run it for you and reuse the token) or run it elsewhere and paste the token.
|
||||
- **Anthropic OAuth (Claude Code CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
|
||||
- **Anthropic token (paste setup-token)**: run `claude setup-token` on any machine, then paste the token (you can name it; blank = default).
|
||||
- **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default).
|
||||
- **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
|
||||
- **OpenAI Code (Codex) subscription (OAuth)**: browser flow; paste the `code#state`.
|
||||
- Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
|
||||
|
||||
@@ -87,7 +87,6 @@ If a prompt is required but no UI is reachable, fallback decides:
|
||||
|
||||
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
|
||||
editing in the macOS app. Patterns are **case-insensitive glob matches**.
|
||||
Patterns should resolve to **binary paths** (basename-only entries are ignored).
|
||||
|
||||
Examples:
|
||||
- `~/Projects/**/bin/bird`
|
||||
@@ -105,15 +104,6 @@ When **Auto-allow skill CLIs** is enabled, executables referenced by known skill
|
||||
are treated as allowlisted on nodes (macOS node or headless node host). This uses the Bridge RPC to ask the
|
||||
gateway for the skill bin list. Disable this if you want strict manual allowlists.
|
||||
|
||||
## Safe bins (stdin-only)
|
||||
|
||||
`tools.exec.safeBins` defines a small list of **stdin-only** binaries (for example `jq`)
|
||||
that can run in allowlist mode **without** explicit allowlist entries. Safe bins reject
|
||||
positional file args and path-like tokens, so they can only operate on the incoming stream.
|
||||
Shell chaining and redirections are not auto-allowed in allowlist mode.
|
||||
|
||||
Default safe bins: `jq`, `grep`, `cut`, `sort`, `uniq`, `head`, `tail`, `tr`, `wc`.
|
||||
|
||||
## Control UI editing
|
||||
|
||||
Use the **Control UI → Nodes → Exec approvals** card to edit defaults, per‑agent
|
||||
@@ -134,10 +124,6 @@ When a prompt is required, the gateway broadcasts `exec.approval.requested` to o
|
||||
The Control UI and macOS app resolve it via `exec.approval.resolve`, then the gateway forwards the
|
||||
approved request to the node host.
|
||||
|
||||
When approvals are required, the exec tool returns immediately with an approval id. Use that id to
|
||||
correlate later system events (`Exec finished` / `Exec denied`). If no decision arrives before the
|
||||
timeout, the request is treated as an approval timeout and surfaced as a denial reason.
|
||||
|
||||
The confirmation dialog includes:
|
||||
- command + args
|
||||
- cwd
|
||||
@@ -166,13 +152,11 @@ Security notes:
|
||||
## System events
|
||||
|
||||
Exec lifecycle is surfaced as system messages:
|
||||
- `Exec running` (only if the command exceeds the running notice threshold)
|
||||
- `Exec finished`
|
||||
- `Exec denied`
|
||||
- `exec.started`
|
||||
- `exec.finished`
|
||||
- `exec.denied`
|
||||
|
||||
These are posted to the agent’s session after the node reports the event.
|
||||
Gateway-host exec approvals emit the same lifecycle events when the command finishes (and optionally when running longer than the threshold).
|
||||
Approval-gated execs reuse the approval id as the `runId` in these messages for easy correlation.
|
||||
|
||||
## Implications
|
||||
|
||||
|
||||
+1
-16
@@ -38,13 +38,11 @@ Notes:
|
||||
## Config
|
||||
|
||||
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
||||
- `tools.exec.approvalRunningNoticeMs` (default: 10000): emit a single “running” notice when an approval-gated exec runs longer than this (0 disables).
|
||||
- `tools.exec.host` (default: `sandbox`)
|
||||
- `tools.exec.security` (default: `deny` for sandbox, `allowlist` for gateway + node when unset)
|
||||
- `tools.exec.ask` (default: `on-miss`)
|
||||
- `tools.exec.node` (default: unset)
|
||||
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
|
||||
- `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
@@ -66,8 +64,7 @@ Example:
|
||||
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
|
||||
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
|
||||
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
|
||||
if the exec call already sets `env.PATH`. Node PATH overrides are accepted only when they prepend
|
||||
the node host PATH (no replacement).
|
||||
if the exec call already sets `env.PATH`.
|
||||
|
||||
Per-agent node binding (use the agent list index in config):
|
||||
|
||||
@@ -93,18 +90,6 @@ Example:
|
||||
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
|
||||
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
||||
|
||||
When approvals are required, the exec tool returns immediately with
|
||||
`status: "approval-pending"` and an approval id. Once approved (or denied / timed out),
|
||||
the Gateway emits system events (`Exec finished` / `Exec denied`). If the command is still
|
||||
running after `tools.exec.approvalRunningNoticeMs`, a single `Exec running` notice is emitted.
|
||||
|
||||
## Allowlist + safe bins
|
||||
|
||||
Allowlist enforcement matches **resolved binary paths only** (no basename matches). When
|
||||
`security=allowlist`, shell commands are auto-allowed only if every pipeline segment is
|
||||
allowlisted or a safe bin. Chaining (`;`, `&&`, `||`) and redirections are rejected in
|
||||
allowlist mode.
|
||||
|
||||
## Examples
|
||||
|
||||
Foreground:
|
||||
|
||||
@@ -86,33 +86,6 @@ Then open:
|
||||
|
||||
Paste the token into the UI settings (sent as `connect.params.auth.token`).
|
||||
|
||||
## Insecure HTTP
|
||||
|
||||
If you open the dashboard over plain HTTP (`http://<lan-ip>` or `http://<tailscale-ip>`),
|
||||
the browser runs in a **non-secure context** and blocks WebCrypto. By default,
|
||||
Clawdbot **blocks** Control UI connections without device identity.
|
||||
|
||||
**Recommended fix:** use HTTPS (Tailscale Serve) or open the UI locally:
|
||||
- `https://<magicdns>/` (Serve)
|
||||
- `http://127.0.0.1:18789/` (on the gateway host)
|
||||
|
||||
**Downgrade example (token-only over HTTP):**
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
controlUi: { allowInsecureAuth: true },
|
||||
bind: "tailnet",
|
||||
auth: { mode: "token", token: "replace-me" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This disables device identity + pairing for the Control UI. Use only if you
|
||||
trust the network.
|
||||
|
||||
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
|
||||
|
||||
## Building the UI
|
||||
|
||||
The Gateway serves static files from `dist/control-ui`. Build them with:
|
||||
|
||||
@@ -114,36 +114,11 @@ export function runZcaInteractive(
|
||||
});
|
||||
}
|
||||
|
||||
function stripAnsi(str: string): string {
|
||||
return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
|
||||
}
|
||||
|
||||
export function parseJsonOutput<T>(stdout: string): T | null {
|
||||
try {
|
||||
return JSON.parse(stdout) as T;
|
||||
} catch {
|
||||
const cleaned = stripAnsi(stdout);
|
||||
|
||||
try {
|
||||
return JSON.parse(cleaned) as T;
|
||||
} catch {
|
||||
// zca may prefix output with INFO/log lines, try to find JSON
|
||||
const lines = cleaned.split("\n");
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith("{") || line.startsWith("[")) {
|
||||
// Try parsing from this line to the end
|
||||
const jsonCandidate = lines.slice(i).join("\n").trim();
|
||||
try {
|
||||
return JSON.parse(jsonCandidate) as T;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,6 @@ set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
# Default to universal binary for distribution builds (supports both Apple Silicon and Intel Macs)
|
||||
export BUILD_ARCHS="${BUILD_ARCHS:-all}"
|
||||
|
||||
"$ROOT_DIR/scripts/package-mac-app.sh"
|
||||
|
||||
APP="$ROOT_DIR/dist/Clawdbot.app"
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./tools/gateway.js", () => ({
|
||||
callGatewayTool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./tools/nodes-utils.js", () => ({
|
||||
listNodes: vi.fn(async () => [
|
||||
{ nodeId: "node-1", commands: ["system.run"], platform: "darwin" },
|
||||
]),
|
||||
resolveNodeIdFromList: vi.fn((nodes: Array<{ nodeId: string }>) => nodes[0]?.nodeId),
|
||||
}));
|
||||
|
||||
describe("exec approvals", () => {
|
||||
let previousHome: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
previousHome = process.env.HOME;
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-"));
|
||||
process.env.HOME = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses approval id as the node runId", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
let invokeParams: unknown;
|
||||
let resolveInvoke: (() => void) | undefined;
|
||||
const invokeSeen = new Promise<void>((resolve) => {
|
||||
resolveInvoke = resolve;
|
||||
});
|
||||
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
|
||||
if (method === "exec.approval.request") {
|
||||
return { decision: "allow-once" };
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
invokeParams = params;
|
||||
resolveInvoke?.();
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const { createExecTool } = await import("./bash-tools.exec.js");
|
||||
const tool = createExecTool({
|
||||
host: "node",
|
||||
ask: "always",
|
||||
approvalRunningNoticeMs: 0,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call1", { command: "ls -la" });
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
const approvalId = (result.details as { approvalId: string }).approvalId;
|
||||
|
||||
await invokeSeen;
|
||||
|
||||
const runId = (invokeParams as { params?: { runId?: string } } | undefined)?.params?.runId;
|
||||
expect(runId).toBe(approvalId);
|
||||
});
|
||||
});
|
||||
+406
-771
File diff suppressed because it is too large.
Load diff
+9
-47
@@ -1,7 +1,15 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js";
|
||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
||||
|
||||
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
path: "/tmp/AGENTS.md",
|
||||
content: "",
|
||||
missing: false,
|
||||
...overrides,
|
||||
});
|
||||
describe("sanitizeSessionMessagesImages", () => {
|
||||
it("keeps tool call + tool result IDs unchanged by default", async () => {
|
||||
const input = [
|
||||
@@ -42,8 +50,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
expect(toolResult.role).toBe("toolResult");
|
||||
expect(toolResult.toolCallId).toBe("call_123|fc_456");
|
||||
});
|
||||
|
||||
it("sanitizes tool call + tool result IDs in standard mode (preserves underscores)", async () => {
|
||||
it("sanitizes tool call + tool result IDs when enabled", async () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
@@ -75,7 +82,6 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find(
|
||||
(b) => b.type === "toolCall",
|
||||
);
|
||||
// Standard mode preserves underscores for readability, replaces invalid chars
|
||||
expect(toolCall?.id).toBe("call_123_fc_456");
|
||||
|
||||
const toolResult = out[1] as unknown as {
|
||||
@@ -85,50 +91,6 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
expect(toolResult.role).toBe("toolResult");
|
||||
expect(toolResult.toolCallId).toBe("call_123_fc_456");
|
||||
});
|
||||
|
||||
it("sanitizes tool call + tool result IDs in strict mode (alphanumeric only)", async () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_123|fc_456",
|
||||
name: "read",
|
||||
arguments: { path: "package.json" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_123|fc_456",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
isError: false,
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test", {
|
||||
sanitizeToolCallIds: true,
|
||||
toolCallIdMode: "strict",
|
||||
});
|
||||
|
||||
const assistant = out[0] as unknown as { role?: string; content?: unknown };
|
||||
expect(assistant.role).toBe("assistant");
|
||||
expect(Array.isArray(assistant.content)).toBe(true);
|
||||
const toolCall = (assistant.content as Array<{ type?: string; id?: string }>).find(
|
||||
(b) => b.type === "toolCall",
|
||||
);
|
||||
// Strict mode strips all non-alphanumeric characters
|
||||
expect(toolCall?.id).toBe("call123fc456");
|
||||
|
||||
const toolResult = out[1] as unknown as {
|
||||
role?: string;
|
||||
toolCallId?: string;
|
||||
};
|
||||
expect(toolResult.role).toBe("toolResult");
|
||||
expect(toolResult.toolCallId).toBe("call123fc456");
|
||||
});
|
||||
it("drops assistant blocks after a tool call when enforceToolCallLast is enabled", async () => {
|
||||
const input = [
|
||||
{
|
||||
|
||||
+9
-38
@@ -1,7 +1,15 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeSessionMessagesImages } from "./pi-embedded-helpers.js";
|
||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
||||
|
||||
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
path: "/tmp/AGENTS.md",
|
||||
content: "",
|
||||
missing: false,
|
||||
...overrides,
|
||||
});
|
||||
describe("sanitizeSessionMessagesImages", () => {
|
||||
it("removes empty assistant text blocks but preserves tool calls", async () => {
|
||||
const input = [
|
||||
@@ -22,8 +30,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
expect(content).toHaveLength(1);
|
||||
expect((content as Array<{ type?: string }>)[0]?.type).toBe("toolCall");
|
||||
});
|
||||
|
||||
it("sanitizes tool ids in standard mode (preserves underscores)", async () => {
|
||||
it("sanitizes tool ids for assistant blocks and tool results when enabled", async () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
@@ -48,7 +55,6 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
sanitizeToolCallIds: true,
|
||||
});
|
||||
|
||||
// Standard mode preserves underscores for readability
|
||||
const assistant = out[0] as { content?: Array<{ id?: string }> };
|
||||
expect(assistant.content?.[0]?.id).toBe("call_abc_item_123");
|
||||
expect(assistant.content?.[1]?.id).toBe("call_abc_item_456");
|
||||
@@ -56,41 +62,6 @@ describe("sanitizeSessionMessagesImages", () => {
|
||||
const toolResult = out[1] as { toolUseId?: string };
|
||||
expect(toolResult.toolUseId).toBe("call_abc_item_123");
|
||||
});
|
||||
|
||||
it("sanitizes tool ids in strict mode (alphanumeric only)", async () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolUse", id: "call_abc|item:123", name: "test", input: {} },
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_abc|item:456",
|
||||
name: "exec",
|
||||
arguments: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolUseId: "call_abc|item:123",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionMessagesImages(input, "test", {
|
||||
sanitizeToolCallIds: true,
|
||||
toolCallIdMode: "strict",
|
||||
});
|
||||
|
||||
// Strict mode strips all non-alphanumeric characters
|
||||
const assistant = out[0] as { content?: Array<{ id?: string }> };
|
||||
expect(assistant.content?.[0]?.id).toBe("callabcitem123");
|
||||
expect(assistant.content?.[1]?.id).toBe("callabcitem456");
|
||||
|
||||
const toolResult = out[1] as { toolUseId?: string };
|
||||
expect(toolResult.toolUseId).toBe("callabcitem123");
|
||||
});
|
||||
it("filters whitespace-only assistant text blocks", async () => {
|
||||
const input = [
|
||||
{
|
||||
|
||||
@@ -1,43 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeToolCallId } from "./pi-embedded-helpers.js";
|
||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
||||
|
||||
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
path: "/tmp/AGENTS.md",
|
||||
content: "",
|
||||
missing: false,
|
||||
...overrides,
|
||||
});
|
||||
describe("sanitizeToolCallId", () => {
|
||||
describe("standard mode (default)", () => {
|
||||
it("keeps valid alphanumeric tool call IDs", () => {
|
||||
expect(sanitizeToolCallId("callabc123")).toBe("callabc123");
|
||||
});
|
||||
it("keeps underscores and hyphens for readability", () => {
|
||||
expect(sanitizeToolCallId("call_abc-123")).toBe("call_abc-123");
|
||||
expect(sanitizeToolCallId("call_abc_def")).toBe("call_abc_def");
|
||||
});
|
||||
it("replaces invalid characters with underscores", () => {
|
||||
expect(sanitizeToolCallId("call_abc|item:456")).toBe("call_abc_item_456");
|
||||
});
|
||||
it("returns default for empty IDs", () => {
|
||||
expect(sanitizeToolCallId("")).toBe("default_tool_id");
|
||||
});
|
||||
it("keeps valid tool call IDs", () => {
|
||||
expect(sanitizeToolCallId("call_abc-123")).toBe("call_abc-123");
|
||||
});
|
||||
|
||||
describe("strict mode (alphanumeric only)", () => {
|
||||
it("strips all non-alphanumeric characters", () => {
|
||||
expect(sanitizeToolCallId("call_abc-123", "strict")).toBe("callabc123");
|
||||
expect(sanitizeToolCallId("call_abc|item:456", "strict")).toBe("callabcitem456");
|
||||
expect(sanitizeToolCallId("whatsapp_login_1768799841527_1", "strict")).toBe(
|
||||
"whatsapplogin17687998415271",
|
||||
);
|
||||
});
|
||||
it("returns default for empty IDs", () => {
|
||||
expect(sanitizeToolCallId("", "strict")).toBe("defaulttoolid");
|
||||
});
|
||||
it("replaces invalid characters with underscores", () => {
|
||||
expect(sanitizeToolCallId("call_abc|item:456")).toBe("call_abc_item_456");
|
||||
});
|
||||
|
||||
describe("strict9 mode (Mistral tool call IDs)", () => {
|
||||
it("returns alphanumeric IDs with length 9", () => {
|
||||
const out = sanitizeToolCallId("call_abc|item:456", "strict9");
|
||||
expect(out).toMatch(/^[a-zA-Z0-9]{9}$/);
|
||||
});
|
||||
it("returns default for empty IDs", () => {
|
||||
expect(sanitizeToolCallId("", "strict9")).toMatch(/^[a-zA-Z0-9]{9}$/);
|
||||
});
|
||||
it("returns default for empty IDs", () => {
|
||||
expect(sanitizeToolCallId("")).toBe("default_tool_id");
|
||||
});
|
||||
});
|
||||
@@ -50,5 +50,4 @@ export {
|
||||
} from "./pi-embedded-helpers/turns.js";
|
||||
export type { EmbeddedContextFile, FailoverReason } from "./pi-embedded-helpers/types.js";
|
||||
|
||||
export type { ToolCallIdMode } from "./tool-call-id.js";
|
||||
export { isValidCloudCodeAssistToolId, sanitizeToolCallId } from "./tool-call-id.js";
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AgentMessage, AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
|
||||
import type { ToolCallIdMode } from "../tool-call-id.js";
|
||||
import { sanitizeToolCallIdsForCloudCodeAssist } from "../tool-call-id.js";
|
||||
import { sanitizeContentBlocksImages } from "../tool-images.js";
|
||||
import { stripThoughtSignatures } from "./bootstrap.js";
|
||||
@@ -33,13 +32,6 @@ export async function sanitizeSessionMessagesImages(
|
||||
label: string,
|
||||
options?: {
|
||||
sanitizeToolCallIds?: boolean;
|
||||
/**
|
||||
* Mode for tool call ID sanitization:
|
||||
* - "standard" (default, preserves _-)
|
||||
* - "strict" (alphanumeric only)
|
||||
* - "strict9" (alphanumeric only, length 9)
|
||||
*/
|
||||
toolCallIdMode?: ToolCallIdMode;
|
||||
enforceToolCallLast?: boolean;
|
||||
preserveSignatures?: boolean;
|
||||
sanitizeThoughtSignatures?: {
|
||||
@@ -51,7 +43,7 @@ export async function sanitizeSessionMessagesImages(
|
||||
// We sanitize historical session messages because Anthropic can reject a request
|
||||
// if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX).
|
||||
const sanitizedIds = options?.sanitizeToolCallIds
|
||||
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
|
||||
? sanitizeToolCallIdsForCloudCodeAssist(messages)
|
||||
: messages;
|
||||
const out: AgentMessage[] = [];
|
||||
for (const msg of sanitizedIds) {
|
||||
|
||||
@@ -54,25 +54,6 @@ describe("sanitizeSessionHistory", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes tool call ids with strict9 for Mistral models", async () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||
|
||||
await sanitizeSessionHistory({
|
||||
messages: mockMessages,
|
||||
modelApi: "openai-responses",
|
||||
provider: "openrouter",
|
||||
modelId: "mistralai/devstral-2512:free",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
|
||||
mockMessages,
|
||||
"session:history",
|
||||
expect.objectContaining({ sanitizeToolCallIds: true, toolCallIdMode: "strict9" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not sanitize tool call ids for non-Google, non-OpenAI APIs", async () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@ import { log } from "./logger.js";
|
||||
import { describeUnknownError } from "./utils.js";
|
||||
import { isAntigravityClaude } from "../pi-embedded-helpers/google.js";
|
||||
import { cleanToolSchemaForGemini } from "../pi-tools.schema.js";
|
||||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import type { ToolCallIdMode } from "../tool-call-id.js";
|
||||
|
||||
const GOOGLE_TURN_ORDERING_CUSTOM_TYPE = "google-turn-ordering-bootstrap";
|
||||
const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
|
||||
@@ -46,29 +44,12 @@ const OPENAI_TOOL_CALL_ID_APIS = new Set([
|
||||
"openai-responses",
|
||||
"openai-codex-responses",
|
||||
]);
|
||||
const MISTRAL_MODEL_HINTS = [
|
||||
"mistral",
|
||||
"mixtral",
|
||||
"codestral",
|
||||
"pixtral",
|
||||
"devstral",
|
||||
"ministral",
|
||||
"mistralai",
|
||||
];
|
||||
|
||||
function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
|
||||
if (!modelApi) return false;
|
||||
return isGoogleModelApi(modelApi) || OPENAI_TOOL_CALL_ID_APIS.has(modelApi);
|
||||
}
|
||||
|
||||
function isMistralModel(params: { provider?: string | null; modelId?: string | null }): boolean {
|
||||
const provider = normalizeProviderId(params.provider ?? "");
|
||||
if (provider === "mistral") return true;
|
||||
const modelId = (params.modelId ?? "").toLowerCase();
|
||||
if (!modelId) return false;
|
||||
return MISTRAL_MODEL_HINTS.some((hint) => modelId.includes(hint));
|
||||
}
|
||||
|
||||
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
|
||||
if (!schema || typeof schema !== "object") return [];
|
||||
if (Array.isArray(schema)) {
|
||||
@@ -210,16 +191,12 @@ export async function sanitizeSessionHistory(params: {
|
||||
sessionId: string;
|
||||
}): Promise<AgentMessage[]> {
|
||||
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
|
||||
const provider = normalizeProviderId(params.provider ?? "");
|
||||
const provider = (params.provider ?? "").toLowerCase();
|
||||
const modelId = (params.modelId ?? "").toLowerCase();
|
||||
const isOpenRouterGemini =
|
||||
(provider === "openrouter" || provider === "opencode") && modelId.includes("gemini");
|
||||
const isMistral = isMistralModel({ provider, modelId });
|
||||
const toolCallIdMode: ToolCallIdMode | undefined = isMistral ? "strict9" : undefined;
|
||||
const sanitizeToolCallIds = shouldSanitizeToolCallIds(params.modelApi) || isMistral;
|
||||
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
||||
sanitizeToolCallIds,
|
||||
toolCallIdMode,
|
||||
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
|
||||
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
||||
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
|
||||
sanitizeThoughtSignatures: isOpenRouterGemini
|
||||
|
||||
@@ -84,7 +84,6 @@ function resolveExecConfig(cfg: ClawdbotConfig | undefined) {
|
||||
pathPrepend: globalExec?.pathPrepend,
|
||||
backgroundMs: globalExec?.backgroundMs,
|
||||
timeoutSec: globalExec?.timeoutSec,
|
||||
approvalRunningNoticeMs: globalExec?.approvalRunningNoticeMs,
|
||||
cleanupMs: globalExec?.cleanupMs,
|
||||
notifyOnExit: globalExec?.notifyOnExit,
|
||||
applyPatch: globalExec?.applyPatch,
|
||||
@@ -220,8 +219,6 @@ export function createClawdbotCodingTools(options?: {
|
||||
messageProvider: options?.messageProvider,
|
||||
backgroundMs: options?.exec?.backgroundMs ?? execConfig.backgroundMs,
|
||||
timeoutSec: options?.exec?.timeoutSec ?? execConfig.timeoutSec,
|
||||
approvalRunningNoticeMs:
|
||||
options?.exec?.approvalRunningNoticeMs ?? execConfig.approvalRunningNoticeMs,
|
||||
notifyOnExit: options?.exec?.notifyOnExit ?? execConfig.notifyOnExit,
|
||||
sandbox: sandbox
|
||||
? {
|
||||
|
||||
+90
-246
@@ -7,262 +7,106 @@ import {
|
||||
} from "./tool-call-id.js";
|
||||
|
||||
describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
|
||||
describe("standard mode (default)", () => {
|
||||
it("is a no-op for already-valid non-colliding IDs", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
it("is a no-op for already-valid non-colliding IDs", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
||||
expect(out).toBe(input);
|
||||
});
|
||||
|
||||
it("replaces invalid characters with underscores (preserves readability)", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call|item:123", name: "read", arguments: {} }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call|item:123",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
||||
expect(out).not.toBe(input);
|
||||
|
||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const toolCall = assistant.content?.[0] as { id?: string };
|
||||
// Standard mode preserves underscores for readability
|
||||
expect(toolCall.id).toBe("call_item_123");
|
||||
expect(isValidCloudCodeAssistToolId(toolCall.id as string)).toBe(true);
|
||||
|
||||
const result = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
expect(result.toolCallId).toBe(toolCall.id);
|
||||
});
|
||||
|
||||
it("avoids collisions when sanitization would produce duplicate IDs", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_a|b", name: "read", arguments: {} },
|
||||
{ type: "toolCall", id: "call_a:b", name: "read", arguments: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_a|b",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "one" }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_a:b",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "two" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
||||
expect(out).not.toBe(input);
|
||||
|
||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const a = assistant.content?.[0] as { id?: string };
|
||||
const b = assistant.content?.[1] as { id?: string };
|
||||
expect(typeof a.id).toBe("string");
|
||||
expect(typeof b.id).toBe("string");
|
||||
expect(a.id).not.toBe(b.id);
|
||||
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
|
||||
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true);
|
||||
|
||||
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
expect(r1.toolCallId).toBe(a.id);
|
||||
expect(r2.toolCallId).toBe(b.id);
|
||||
});
|
||||
|
||||
it("caps tool call IDs at 40 chars while preserving uniqueness", () => {
|
||||
const longA = `call_${"a".repeat(60)}`;
|
||||
const longB = `call_${"a".repeat(59)}b`;
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: longA, name: "read", arguments: {} },
|
||||
{ type: "toolCall", id: longB, name: "read", arguments: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: longA,
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "one" }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: longB,
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "two" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const a = assistant.content?.[0] as { id?: string };
|
||||
const b = assistant.content?.[1] as { id?: string };
|
||||
|
||||
expect(typeof a.id).toBe("string");
|
||||
expect(typeof b.id).toBe("string");
|
||||
expect(a.id).not.toBe(b.id);
|
||||
expect(a.id?.length).toBeLessThanOrEqual(40);
|
||||
expect(b.id?.length).toBeLessThanOrEqual(40);
|
||||
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
|
||||
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true);
|
||||
|
||||
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
expect(r1.toolCallId).toBe(a.id);
|
||||
expect(r2.toolCallId).toBe(b.id);
|
||||
});
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
||||
expect(out).toBe(input);
|
||||
});
|
||||
|
||||
describe("strict mode (alphanumeric only)", () => {
|
||||
it("strips underscores and hyphens from tool call IDs", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "whatsapp_login_1768799841527_1",
|
||||
name: "login",
|
||||
arguments: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "whatsapp_login_1768799841527_1",
|
||||
toolName: "login",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
it("avoids collisions when sanitization would produce duplicate IDs", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_a|b", name: "read", arguments: {} },
|
||||
{ type: "toolCall", id: "call_a:b", name: "read", arguments: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_a|b",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "one" }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_a:b",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "two" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
|
||||
expect(out).not.toBe(input);
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
||||
expect(out).not.toBe(input);
|
||||
|
||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const toolCall = assistant.content?.[0] as { id?: string };
|
||||
// Strict mode strips all non-alphanumeric characters
|
||||
expect(toolCall.id).toBe("whatsapplogin17687998415271");
|
||||
expect(isValidCloudCodeAssistToolId(toolCall.id as string, "strict")).toBe(true);
|
||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const a = assistant.content?.[0] as { id?: string };
|
||||
const b = assistant.content?.[1] as { id?: string };
|
||||
expect(typeof a.id).toBe("string");
|
||||
expect(typeof b.id).toBe("string");
|
||||
expect(a.id).not.toBe(b.id);
|
||||
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
|
||||
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true);
|
||||
|
||||
const result = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
expect(result.toolCallId).toBe(toolCall.id);
|
||||
});
|
||||
|
||||
it("avoids collisions with alphanumeric-only suffixes", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_a|b", name: "read", arguments: {} },
|
||||
{ type: "toolCall", id: "call_a:b", name: "read", arguments: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_a|b",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "one" }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_a:b",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "two" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict");
|
||||
expect(out).not.toBe(input);
|
||||
|
||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const a = assistant.content?.[0] as { id?: string };
|
||||
const b = assistant.content?.[1] as { id?: string };
|
||||
expect(typeof a.id).toBe("string");
|
||||
expect(typeof b.id).toBe("string");
|
||||
expect(a.id).not.toBe(b.id);
|
||||
// Both should be strictly alphanumeric
|
||||
expect(isValidCloudCodeAssistToolId(a.id as string, "strict")).toBe(true);
|
||||
expect(isValidCloudCodeAssistToolId(b.id as string, "strict")).toBe(true);
|
||||
// Should not contain underscores or hyphens
|
||||
expect(a.id).not.toMatch(/[_-]/);
|
||||
expect(b.id).not.toMatch(/[_-]/);
|
||||
|
||||
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
expect(r1.toolCallId).toBe(a.id);
|
||||
expect(r2.toolCallId).toBe(b.id);
|
||||
});
|
||||
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
expect(r1.toolCallId).toBe(a.id);
|
||||
expect(r2.toolCallId).toBe(b.id);
|
||||
});
|
||||
|
||||
describe("strict9 mode (Mistral tool call IDs)", () => {
|
||||
it("enforces alphanumeric IDs with length 9", () => {
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call_abc|item:123", name: "read", arguments: {} },
|
||||
{ type: "toolCall", id: "call_abc|item:456", name: "read", arguments: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_abc|item:123",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "one" }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call_abc|item:456",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "two" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
it("caps tool call IDs at 40 chars while preserving uniqueness", () => {
|
||||
const longA = `call_${"a".repeat(60)}`;
|
||||
const longB = `call_${"a".repeat(59)}b`;
|
||||
const input = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: longA, name: "read", arguments: {} },
|
||||
{ type: "toolCall", id: longB, name: "read", arguments: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: longA,
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "one" }],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: longB,
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "two" }],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input, "strict9");
|
||||
expect(out).not.toBe(input);
|
||||
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
|
||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const a = assistant.content?.[0] as { id?: string };
|
||||
const b = assistant.content?.[1] as { id?: string };
|
||||
|
||||
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
|
||||
const a = assistant.content?.[0] as { id?: string };
|
||||
const b = assistant.content?.[1] as { id?: string };
|
||||
expect(typeof a.id).toBe("string");
|
||||
expect(typeof b.id).toBe("string");
|
||||
expect(a.id).not.toBe(b.id);
|
||||
expect(a.id?.length).toBeLessThanOrEqual(40);
|
||||
expect(b.id?.length).toBeLessThanOrEqual(40);
|
||||
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
|
||||
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true);
|
||||
|
||||
expect(typeof a.id).toBe("string");
|
||||
expect(typeof b.id).toBe("string");
|
||||
expect(a.id).not.toBe(b.id);
|
||||
expect(a.id?.length).toBe(9);
|
||||
expect(b.id?.length).toBe(9);
|
||||
expect(isValidCloudCodeAssistToolId(a.id as string, "strict9")).toBe(true);
|
||||
expect(isValidCloudCodeAssistToolId(b.id as string, "strict9")).toBe(true);
|
||||
|
||||
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
expect(r1.toolCallId).toBe(a.id);
|
||||
expect(r2.toolCallId).toBe(b.id);
|
||||
});
|
||||
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
expect(r1.toolCallId).toBe(a.id);
|
||||
expect(r2.toolCallId).toBe(b.id);
|
||||
});
|
||||
});
|
||||
+21
-82
@@ -2,96 +2,46 @@ import { createHash } from "node:crypto";
|
||||
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
|
||||
export type ToolCallIdMode = "standard" | "strict" | "strict9";
|
||||
export function sanitizeToolCallId(id: string): string {
|
||||
if (!id || typeof id !== "string") return "default_tool_id";
|
||||
|
||||
const STRICT9_LEN = 9;
|
||||
const cloudCodeAssistPatternReplacement = id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
const trimmedInvalidStartChars = cloudCodeAssistPatternReplacement.replace(
|
||||
/^[^a-zA-Z0-9_-]+/,
|
||||
"",
|
||||
);
|
||||
|
||||
/**
|
||||
* Sanitize a tool call ID to be compatible with various providers.
|
||||
*
|
||||
* - "standard" mode: allows [a-zA-Z0-9_-], better readability (default)
|
||||
* - "strict" mode: only [a-zA-Z0-9]
|
||||
* - "strict9" mode: only [a-zA-Z0-9], length 9 (Mistral tool call requirement)
|
||||
*/
|
||||
export function sanitizeToolCallId(id: string, mode: ToolCallIdMode = "standard"): string {
|
||||
if (!id || typeof id !== "string") {
|
||||
if (mode === "strict9") return "defaultid";
|
||||
return mode === "strict" ? "defaulttoolid" : "default_tool_id";
|
||||
}
|
||||
|
||||
if (mode === "strict") {
|
||||
// Some providers require strictly alphanumeric tool call IDs.
|
||||
const alphanumericOnly = id.replace(/[^a-zA-Z0-9]/g, "");
|
||||
return alphanumericOnly.length > 0 ? alphanumericOnly : "sanitizedtoolid";
|
||||
}
|
||||
|
||||
if (mode === "strict9") {
|
||||
const alphanumericOnly = id.replace(/[^a-zA-Z0-9]/g, "");
|
||||
if (alphanumericOnly.length >= STRICT9_LEN) return alphanumericOnly.slice(0, STRICT9_LEN);
|
||||
if (alphanumericOnly.length > 0) return shortHash(alphanumericOnly, STRICT9_LEN);
|
||||
return shortHash("sanitized", STRICT9_LEN);
|
||||
}
|
||||
|
||||
// Standard mode: allow underscores and hyphens for better readability in logs
|
||||
const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
const trimmed = sanitized.replace(/^[^a-zA-Z0-9_-]+/, "");
|
||||
return trimmed.length > 0 ? trimmed : "sanitized_tool_id";
|
||||
return trimmedInvalidStartChars.length > 0 ? trimmedInvalidStartChars : "sanitized_tool_id";
|
||||
}
|
||||
|
||||
export function isValidCloudCodeAssistToolId(
|
||||
id: string,
|
||||
mode: ToolCallIdMode = "standard",
|
||||
): boolean {
|
||||
export function isValidCloudCodeAssistToolId(id: string): boolean {
|
||||
if (!id || typeof id !== "string") return false;
|
||||
if (mode === "strict") {
|
||||
// Strictly alphanumeric for providers with tighter tool ID constraints
|
||||
return /^[a-zA-Z0-9]+$/.test(id);
|
||||
}
|
||||
if (mode === "strict9") {
|
||||
return /^[a-zA-Z0-9]{9}$/.test(id);
|
||||
}
|
||||
// Standard mode allows underscores and hyphens
|
||||
return /^[a-zA-Z0-9_-]+$/.test(id);
|
||||
}
|
||||
|
||||
function shortHash(text: string, length = 8): string {
|
||||
return createHash("sha1").update(text).digest("hex").slice(0, length);
|
||||
function shortHash(text: string): string {
|
||||
return createHash("sha1").update(text).digest("hex").slice(0, 8);
|
||||
}
|
||||
|
||||
function makeUniqueToolId(params: { id: string; used: Set<string>; mode: ToolCallIdMode }): string {
|
||||
if (params.mode === "strict9") {
|
||||
const base = sanitizeToolCallId(params.id, params.mode);
|
||||
const candidate = base.length >= STRICT9_LEN ? base.slice(0, STRICT9_LEN) : "";
|
||||
if (candidate && !params.used.has(candidate)) return candidate;
|
||||
|
||||
for (let i = 0; i < 1000; i += 1) {
|
||||
const hashed = shortHash(`${params.id}:${i}`, STRICT9_LEN);
|
||||
if (!params.used.has(hashed)) return hashed;
|
||||
}
|
||||
|
||||
return shortHash(`${params.id}:${Date.now()}`, STRICT9_LEN);
|
||||
}
|
||||
|
||||
function makeUniqueToolId(params: { id: string; used: Set<string> }): string {
|
||||
const MAX_LEN = 40;
|
||||
|
||||
const base = sanitizeToolCallId(params.id, params.mode).slice(0, MAX_LEN);
|
||||
const base = sanitizeToolCallId(params.id).slice(0, MAX_LEN);
|
||||
if (!params.used.has(base)) return base;
|
||||
|
||||
const hash = shortHash(params.id);
|
||||
// Use separator based on mode: underscore for standard (readable), none for strict
|
||||
const separator = params.mode === "strict" ? "" : "_";
|
||||
const maxBaseLen = MAX_LEN - separator.length - hash.length;
|
||||
const maxBaseLen = MAX_LEN - 1 - hash.length;
|
||||
const clippedBase = base.length > maxBaseLen ? base.slice(0, maxBaseLen) : base;
|
||||
const candidate = `${clippedBase}${separator}${hash}`;
|
||||
const candidate = `${clippedBase}_${hash}`;
|
||||
if (!params.used.has(candidate)) return candidate;
|
||||
|
||||
for (let i = 2; i < 1000; i += 1) {
|
||||
const suffix = params.mode === "strict" ? `x${i}` : `_${i}`;
|
||||
const suffix = `_${i}`;
|
||||
const next = `${candidate.slice(0, MAX_LEN - suffix.length)}${suffix}`;
|
||||
if (!params.used.has(next)) return next;
|
||||
}
|
||||
|
||||
const ts = params.mode === "strict" ? `t${Date.now()}` : `_${Date.now()}`;
|
||||
const ts = `_${Date.now()}`;
|
||||
return `${candidate.slice(0, MAX_LEN - ts.length)}${ts}`;
|
||||
}
|
||||
|
||||
@@ -150,20 +100,9 @@ function rewriteToolResultIds(params: {
|
||||
} as Extract<AgentMessage, { role: "toolResult" }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize tool call IDs for provider compatibility.
|
||||
*
|
||||
* @param messages - The messages to sanitize
|
||||
* @param mode - "standard" (default, allows _-), "strict" (alphanumeric only), or "strict9" (alphanumeric length 9)
|
||||
*/
|
||||
export function sanitizeToolCallIdsForCloudCodeAssist(
|
||||
messages: AgentMessage[],
|
||||
mode: ToolCallIdMode = "standard",
|
||||
): AgentMessage[] {
|
||||
// Standard mode: allows [a-zA-Z0-9_-] for better readability in session logs
|
||||
// Strict mode: only [a-zA-Z0-9]
|
||||
// Strict9 mode: only [a-zA-Z0-9], length 9 (Mistral tool call requirement)
|
||||
// Sanitization can introduce collisions (e.g. `a|b` and `a:b` -> `a_b` or `ab`).
|
||||
export function sanitizeToolCallIdsForCloudCodeAssist(messages: AgentMessage[]): AgentMessage[] {
|
||||
// Cloud Code Assist requires tool IDs matching ^[a-zA-Z0-9_-]+$.
|
||||
// Sanitization can introduce collisions (e.g. `a|b` and `a:b` -> `a_b`).
|
||||
// Fix by applying a stable, transcript-wide mapping and de-duping via suffix.
|
||||
const map = new Map<string, string>();
|
||||
const used = new Set<string>();
|
||||
@@ -171,7 +110,7 @@ export function sanitizeToolCallIdsForCloudCodeAssist(
|
||||
const resolve = (id: string) => {
|
||||
const existing = map.get(id);
|
||||
if (existing) return existing;
|
||||
const next = makeUniqueToolId({ id, used, mode });
|
||||
const next = makeUniqueToolId({ id, used });
|
||||
map.set(id, next);
|
||||
used.add(next);
|
||||
return next;
|
||||
|
||||
@@ -10,11 +10,10 @@ describe("extractModelDirective", () => {
|
||||
expect(result.cleaned).toBe("");
|
||||
});
|
||||
|
||||
it("extracts /models with argument", () => {
|
||||
it("does not extract /models with argument", () => {
|
||||
const result = extractModelDirective("/models gpt-5");
|
||||
expect(result.hasDirective).toBe(true);
|
||||
expect(result.rawModel).toBe("gpt-5");
|
||||
expect(result.cleaned).toBe("");
|
||||
expect(result.hasDirective).toBe(false);
|
||||
expect(result.cleaned).toBe("/models gpt-5");
|
||||
});
|
||||
|
||||
it("extracts /model with provider/model format", () => {
|
||||
@@ -114,9 +113,10 @@ describe("extractModelDirective", () => {
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("absorbs path-like segments when /model includes extra slashes", () => {
|
||||
it("extracts models with multiple path segments", () => {
|
||||
const result = extractModelDirective("thats not /model gpt-5/tmp/hello");
|
||||
expect(result.hasDirective).toBe(true);
|
||||
expect(result.rawModel).toBe("gpt-5/tmp/hello");
|
||||
expect(result.cleaned).toBe("thats not");
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export function extractModelDirective(
|
||||
if (!body) return { cleaned: "", hasDirective: false };
|
||||
|
||||
const modelMatch = body.match(
|
||||
/(?:^|\s)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
|
||||
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
|
||||
);
|
||||
|
||||
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
|
||||
|
||||
+79
-24
@@ -60,13 +60,13 @@ describe("directive behavior", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("aliases /model list to /models", async () => {
|
||||
it("lists allowlisted models on /models <provider>", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -84,22 +84,40 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Providers:");
|
||||
expect(text).toContain("- anthropic");
|
||||
expect(text).toContain("- openai");
|
||||
expect(text).toContain("Use: /models <provider>");
|
||||
expect(text).toContain("Switch: /model <provider/model>");
|
||||
expect(text).toContain("Models (anthropic)");
|
||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||
|
||||
const openaiRes = await getReplyFromConfig(
|
||||
{ Body: "/models openai", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
const openaiText = Array.isArray(openaiRes) ? openaiRes[0]?.text : openaiRes?.text;
|
||||
expect(openaiText).toContain("Models (openai)");
|
||||
expect(openaiText).toContain("openai/gpt-4.1-mini");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
it("shows current model when catalog is unavailable", async () => {
|
||||
it("falls back to configured models when catalog is unavailable", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([]);
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -117,17 +135,36 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Current: anthropic/claude-opus-4-5");
|
||||
expect(text).toContain("Switch: /model <provider/model>");
|
||||
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
|
||||
expect(text).toContain("More: /model status");
|
||||
expect(text).toContain("Models (anthropic)");
|
||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
||||
|
||||
const openaiRes = await getReplyFromConfig(
|
||||
{ Body: "/models openai", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "anthropic/claude-opus-4-5" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
models: {
|
||||
"anthropic/claude-opus-4-5": {},
|
||||
"openai/gpt-4.1-mini": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
const openaiText = Array.isArray(openaiRes) ? openaiRes[0]?.text : openaiRes?.text;
|
||||
expect(openaiText).toContain("Models (openai)");
|
||||
expect(openaiText).toContain("openai/gpt-4.1-mini");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
it("includes catalog providers when no allowlist is set", async () => {
|
||||
it("includes catalog models when no allowlist is set", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
vi.mocked(loadModelCatalog).mockResolvedValue([
|
||||
vi.mocked(loadModelCatalog).mockResolvedValueOnce([
|
||||
{ id: "claude-opus-4-5", name: "Opus 4.5", provider: "anthropic" },
|
||||
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", provider: "openai" },
|
||||
{ id: "grok-4", name: "Grok 4", provider: "xai" },
|
||||
@@ -135,7 +172,7 @@ describe("directive behavior", () => {
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{ Body: "/models xai", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -153,15 +190,33 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Providers:");
|
||||
expect(text).toContain("- anthropic");
|
||||
expect(text).toContain("- openai");
|
||||
expect(text).toContain("- xai");
|
||||
expect(text).toContain("Use: /models <provider>");
|
||||
expect(text).toContain("Models (xai)");
|
||||
expect(text).toContain("xai/grok-4");
|
||||
|
||||
const minimaxRes = await getReplyFromConfig(
|
||||
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-5",
|
||||
fallbacks: ["openai/gpt-4.1-mini"],
|
||||
},
|
||||
imageModel: { primary: "minimax/MiniMax-M2.1" },
|
||||
workspace: path.join(home, "clawd"),
|
||||
},
|
||||
},
|
||||
session: { store: storePath },
|
||||
},
|
||||
);
|
||||
const minimaxText = Array.isArray(minimaxRes) ? minimaxRes[0]?.text : minimaxRes?.text;
|
||||
expect(minimaxText).toContain("Models (minimax)");
|
||||
expect(minimaxText).toContain("minimax/MiniMax-M2.1");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
it("lists config-only providers when catalog is present", async () => {
|
||||
it("merges config allowlist models even when catalog is present", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
// Catalog present but missing custom providers: /model should still include
|
||||
@@ -206,7 +261,7 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Model set to minimax");
|
||||
expect(text).toContain("Models (minimax)");
|
||||
expect(text).toContain("minimax/MiniMax-M2.1");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -234,7 +289,7 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Providers:");
|
||||
expect(text).toContain("Model listing moved.");
|
||||
expect(text).not.toContain("missing (missing)");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
+4
-6
@@ -62,13 +62,13 @@ describe("directive behavior", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("prefers alias matches when fuzzy selection is ambiguous", async () => {
|
||||
it("selects exact alias matches even when ambiguous", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{ Body: "/model ki", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
await getReplyFromConfig(
|
||||
{ Body: "/model Kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||
{},
|
||||
{
|
||||
agents: {
|
||||
@@ -103,11 +103,9 @@ describe("directive behavior", () => {
|
||||
},
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Model set to Kimi (moonshot/kimi-k2-0905-preview).");
|
||||
assertModelSelection(storePath, {
|
||||
provider: "moonshot",
|
||||
model: "kimi-k2-0905-preview",
|
||||
provider: "moonshot",
|
||||
});
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
+9
-15
@@ -95,11 +95,9 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview.");
|
||||
assertModelSelection(storePath, {
|
||||
provider: "moonshot",
|
||||
model: "kimi-k2-0905-preview",
|
||||
});
|
||||
expect(text).toContain("Did you mean:");
|
||||
expect(text).toContain("moonshot/kimi-k2-0905-preview");
|
||||
assertModelSelection(storePath);
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -143,11 +141,9 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview.");
|
||||
assertModelSelection(storePath, {
|
||||
provider: "moonshot",
|
||||
model: "kimi-k2-0905-preview",
|
||||
});
|
||||
expect(text).toContain("Did you mean:");
|
||||
expect(text).toContain("moonshot/kimi-k2-0905-preview");
|
||||
assertModelSelection(storePath);
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -186,11 +182,9 @@ describe("directive behavior", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Model set to moonshot/kimi-k2-0905-preview.");
|
||||
assertModelSelection(storePath, {
|
||||
provider: "moonshot",
|
||||
model: "kimi-k2-0905-preview",
|
||||
});
|
||||
expect(text).toContain("Did you mean:");
|
||||
expect(text).toContain("moonshot/kimi-k2-0905-preview");
|
||||
assertModelSelection(storePath);
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+2
-4
@@ -187,7 +187,7 @@ describe("directive behavior", () => {
|
||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
it("shows summary on /model", async () => {
|
||||
it("shows a model summary on /model", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
@@ -212,10 +212,8 @@ describe("directive behavior", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(text).toContain("Current: anthropic/claude-opus-4-5");
|
||||
expect(text).toContain("Browse: /models");
|
||||
expect(text).toContain("Switch: /model <provider/model>");
|
||||
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
|
||||
expect(text).toContain("More: /model status");
|
||||
expect(text).not.toContain("openai/gpt-4.1-mini");
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+14
-14
@@ -95,7 +95,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("trigger handling", () => {
|
||||
it("shows a /model summary and points to /models", async () => {
|
||||
it("shows a /model summary with browse hints", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = makeCfg(home);
|
||||
const res = await getReplyFromConfig(
|
||||
@@ -116,19 +116,17 @@ describe("trigger handling", () => {
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
const normalized = normalizeTestText(text ?? "");
|
||||
expect(normalized).toContain("Current: anthropic/claude-opus-4-5");
|
||||
expect(normalized).toContain("Browse: /models");
|
||||
expect(normalized).toContain("Switch: /model <provider/model>");
|
||||
expect(normalized).toContain("Browse: /models (providers) or /models <provider> (models)");
|
||||
expect(normalized).toContain("More: /model status");
|
||||
expect(normalized).not.toContain("reasoning");
|
||||
expect(normalized).not.toContain("image");
|
||||
});
|
||||
});
|
||||
it("aliases /model list to /models", async () => {
|
||||
it("lists providers on /models", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = makeCfg(home);
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
Body: "/model list",
|
||||
Body: "/models",
|
||||
From: "telegram:111",
|
||||
To: "telegram:111",
|
||||
ChatType: "direct",
|
||||
@@ -144,8 +142,11 @@ describe("trigger handling", () => {
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
const normalized = normalizeTestText(text ?? "");
|
||||
expect(normalized).toContain("Providers:");
|
||||
expect(normalized).toContain("Use: /models <provider>");
|
||||
expect(normalized).toContain("Switch: /model <provider/model>");
|
||||
expect(normalized).toContain("anthropic");
|
||||
expect(normalized).toContain("openrouter");
|
||||
expect(normalized).toContain("openai");
|
||||
expect(normalized).toContain("openai-codex");
|
||||
expect(normalized).toContain("minimax");
|
||||
});
|
||||
});
|
||||
it("selects the exact provider/model pair for openrouter", async () => {
|
||||
@@ -199,17 +200,16 @@ describe("trigger handling", () => {
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
const normalized = normalizeTestText(text ?? "");
|
||||
expect(normalized).toContain("Numeric model selection is not supported in chat.");
|
||||
expect(normalized).toContain("Browse: /models or /models <provider>");
|
||||
expect(normalized).toContain("Switch: /model <provider/model>");
|
||||
expect(normalizeTestText(text ?? "")).toContain(
|
||||
"Numeric model selection is not supported in chat.",
|
||||
);
|
||||
|
||||
const store = loadSessionStore(cfg.session.store);
|
||||
expect(store[sessionKey]?.providerOverride).toBeUndefined();
|
||||
expect(store[sessionKey]?.modelOverride).toBeUndefined();
|
||||
});
|
||||
});
|
||||
it("resets to the default model via /model <provider/model>", async () => {
|
||||
it("selects exact provider/model combo via /model <provider/model>", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const cfg = makeCfg(home);
|
||||
const sessionKey = "telegram:slash:111";
|
||||
@@ -231,7 +231,7 @@ describe("trigger handling", () => {
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
expect(normalizeTestText(text ?? "")).toContain(
|
||||
"Model reset to default (anthropic/claude-opus-4-5)",
|
||||
"Model reset to default (anthropic/claude-opus-4-5).",
|
||||
);
|
||||
|
||||
const store = loadSessionStore(cfg.session.store);
|
||||
|
||||
@@ -80,14 +80,47 @@ describe("/models command", () => {
|
||||
expect(result.reply?.text).toContain("All: /models anthropic all");
|
||||
});
|
||||
|
||||
it("ignores page argument when all flag is present", async () => {
|
||||
const params = buildParams("/models anthropic 3 all", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (anthropic)");
|
||||
expect(result.reply?.text).toContain("page 1/1");
|
||||
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
||||
expect(result.reply?.text).not.toContain("Page out of range");
|
||||
it("includes configured providers and defaults", async () => {
|
||||
const configuredCfg = {
|
||||
commands: { text: true },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "synthetic/synth-1",
|
||||
fallbacks: ["synthetic/synth-2"],
|
||||
},
|
||||
imageModel: {
|
||||
primary: "synthetic/synth-image",
|
||||
fallbacks: ["synthetic/synth-image-2"],
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
synthetic: {
|
||||
baseUrl: "https://example.com",
|
||||
models: [
|
||||
{
|
||||
id: "synth-3",
|
||||
name: "Synth 3",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ClawdbotConfig;
|
||||
|
||||
const providersResult = await handleCommands(buildParams("/models", configuredCfg));
|
||||
expect(providersResult.shouldContinue).toBe(false);
|
||||
expect(providersResult.reply?.text).toContain("synthetic");
|
||||
|
||||
const modelsResult = await handleCommands(buildParams("/models synthetic", configuredCfg));
|
||||
expect(modelsResult.shouldContinue).toBe(false);
|
||||
expect(modelsResult.reply?.text).toContain("synthetic/synth-1");
|
||||
expect(modelsResult.reply?.text).toContain("synthetic/synth-2");
|
||||
expect(modelsResult.reply?.text).toContain("synthetic/synth-3");
|
||||
expect(modelsResult.reply?.text).toContain("synthetic/synth-image");
|
||||
expect(modelsResult.reply?.text).toContain("synthetic/synth-image-2");
|
||||
});
|
||||
|
||||
it("errors on out-of-range pages", async () => {
|
||||
@@ -105,29 +138,4 @@ describe("/models command", () => {
|
||||
expect(result.reply?.text).toContain("Unknown provider");
|
||||
expect(result.reply?.text).toContain("Available providers");
|
||||
});
|
||||
|
||||
it("lists configured models outside the curated catalog", async () => {
|
||||
const customCfg = {
|
||||
commands: { text: true },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "localai/ultra-chat",
|
||||
fallbacks: ["anthropic/claude-opus-4-5"],
|
||||
},
|
||||
imageModel: "visionpro/studio-v1",
|
||||
},
|
||||
},
|
||||
} as unknown as ClawdbotConfig;
|
||||
|
||||
const providerList = await handleCommands(buildParams("/models", customCfg));
|
||||
expect(providerList.reply?.text).toContain("localai");
|
||||
expect(providerList.reply?.text).toContain("visionpro");
|
||||
|
||||
const result = await handleCommands(buildParams("/models localai", customCfg));
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Models (localai)");
|
||||
expect(result.reply?.text).toContain("localai/ultra-chat");
|
||||
expect(result.reply?.text).not.toContain("Unknown provider");
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
resolveModelRefFromString,
|
||||
} from "../../agents/model-selection.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
@@ -18,6 +17,15 @@ function formatProviderLine(params: { provider: string; count: number }): string
|
||||
return `- ${params.provider} (${params.count})`;
|
||||
}
|
||||
|
||||
function addModelRef(map: Map<string, Set<string>>, provider: string, model: string): void {
|
||||
const normalizedProvider = normalizeProviderId(provider);
|
||||
const normalizedModel = String(model ?? "").trim();
|
||||
if (!normalizedProvider || !normalizedModel) return;
|
||||
const set = map.get(normalizedProvider) ?? new Set<string>();
|
||||
set.add(normalizedModel);
|
||||
map.set(normalizedProvider, set);
|
||||
}
|
||||
|
||||
function parseModelsArgs(raw: string): {
|
||||
provider?: string;
|
||||
page: number;
|
||||
@@ -69,11 +77,10 @@ function parseModelsArgs(raw: string): {
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveModelsCommandReply(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
commandBodyNormalized: string;
|
||||
}): Promise<ReplyPayload | null> {
|
||||
const body = params.commandBodyNormalized.trim();
|
||||
export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
|
||||
const body = params.command.commandBodyNormalized.trim();
|
||||
if (!body.startsWith("/models")) return null;
|
||||
|
||||
const argText = body.replace(/^\/models\b/i, "").trim();
|
||||
@@ -93,66 +100,57 @@ export async function resolveModelsCommandReply(params: {
|
||||
defaultModel: resolvedDefault.model,
|
||||
});
|
||||
|
||||
const byProvider = new Map<string, Set<string>>();
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: params.cfg,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
});
|
||||
|
||||
const byProvider = new Map<string, Set<string>>();
|
||||
const add = (p: string, m: string) => {
|
||||
const key = normalizeProviderId(p);
|
||||
const set = byProvider.get(key) ?? new Set<string>();
|
||||
set.add(m);
|
||||
byProvider.set(key, set);
|
||||
};
|
||||
|
||||
const addRawModelRef = (raw?: string) => {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return;
|
||||
const addRaw = (raw?: string) => {
|
||||
const value = String(raw ?? "").trim();
|
||||
if (!value) return;
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: trimmed,
|
||||
raw: value,
|
||||
defaultProvider: resolvedDefault.provider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) return;
|
||||
add(resolved.ref.provider, resolved.ref.model);
|
||||
};
|
||||
|
||||
const addModelConfigEntries = () => {
|
||||
const modelConfig = params.cfg.agents?.defaults?.model;
|
||||
if (typeof modelConfig === "string") {
|
||||
addRawModelRef(modelConfig);
|
||||
} else if (modelConfig && typeof modelConfig === "object") {
|
||||
addRawModelRef(modelConfig.primary);
|
||||
for (const fallback of modelConfig.fallbacks ?? []) {
|
||||
addRawModelRef(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
const imageConfig = params.cfg.agents?.defaults?.imageModel;
|
||||
if (typeof imageConfig === "string") {
|
||||
addRawModelRef(imageConfig);
|
||||
} else if (imageConfig && typeof imageConfig === "object") {
|
||||
addRawModelRef(imageConfig.primary);
|
||||
for (const fallback of imageConfig.fallbacks ?? []) {
|
||||
addRawModelRef(fallback);
|
||||
}
|
||||
}
|
||||
addModelRef(byProvider, resolved.ref.provider, resolved.ref.model);
|
||||
};
|
||||
|
||||
for (const entry of allowed.allowedCatalog) {
|
||||
add(entry.provider, entry.id);
|
||||
addModelRef(byProvider, entry.provider, entry.id);
|
||||
}
|
||||
|
||||
addModelRef(byProvider, resolvedDefault.provider, resolvedDefault.model);
|
||||
|
||||
const modelConfig = params.cfg.agents?.defaults?.model;
|
||||
const modelFallbacks =
|
||||
modelConfig && typeof modelConfig === "object" ? (modelConfig.fallbacks ?? []) : [];
|
||||
for (const fallback of modelFallbacks) {
|
||||
addRaw(String(fallback ?? ""));
|
||||
}
|
||||
|
||||
const imageConfig = params.cfg.agents?.defaults?.imageModel;
|
||||
if (imageConfig) {
|
||||
if (typeof imageConfig === "string") {
|
||||
addRaw(imageConfig);
|
||||
} else {
|
||||
addRaw(imageConfig.primary);
|
||||
for (const fallback of imageConfig.fallbacks ?? []) {
|
||||
addRaw(String(fallback ?? ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Include config-only allowlist keys that aren't in the curated catalog.
|
||||
for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
|
||||
addRawModelRef(raw);
|
||||
addRaw(String(raw ?? ""));
|
||||
}
|
||||
|
||||
// Ensure configured defaults/fallbacks/image models show up even when the
|
||||
// curated catalog doesn't know about them (custom providers, dev builds, etc.).
|
||||
add(resolvedDefault.provider, resolvedDefault.model);
|
||||
addModelConfigEntries();
|
||||
for (const [provider, providerConfig] of Object.entries(params.cfg.models?.providers ?? {})) {
|
||||
for (const modelDef of providerConfig?.models ?? []) {
|
||||
addModelRef(byProvider, provider, modelDef?.id ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
const providers = [...byProvider.keys()].sort();
|
||||
|
||||
@@ -166,7 +164,7 @@ export async function resolveModelsCommandReply(params: {
|
||||
"Use: /models <provider>",
|
||||
"Switch: /model <provider/model>",
|
||||
];
|
||||
return { text: lines.join("\n") };
|
||||
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
||||
}
|
||||
|
||||
if (!byProvider.has(provider)) {
|
||||
@@ -178,7 +176,7 @@ export async function resolveModelsCommandReply(params: {
|
||||
"",
|
||||
"Use: /models <provider>",
|
||||
];
|
||||
return { text: lines.join("\n") };
|
||||
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
||||
}
|
||||
|
||||
const models = [...(byProvider.get(provider) ?? new Set<string>())].sort();
|
||||
@@ -191,7 +189,7 @@ export async function resolveModelsCommandReply(params: {
|
||||
"Browse: /models",
|
||||
"Switch: /model <provider/model>",
|
||||
];
|
||||
return { text: lines.join("\n") };
|
||||
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
||||
}
|
||||
|
||||
const effectivePageSize = all ? total : pageSize;
|
||||
@@ -205,7 +203,7 @@ export async function resolveModelsCommandReply(params: {
|
||||
`Try: /models ${provider} ${safePage}`,
|
||||
`All: /models ${provider} all`,
|
||||
];
|
||||
return { text: lines.join("\n") };
|
||||
return { reply: { text: lines.join("\n") }, shouldContinue: false };
|
||||
}
|
||||
|
||||
const startIndex = (safePage - 1) * effectivePageSize;
|
||||
@@ -228,16 +226,5 @@ export async function resolveModelsCommandReply(params: {
|
||||
}
|
||||
|
||||
const payload: ReplyPayload = { text: lines.join("\n") };
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const handleModelsCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
|
||||
const reply = await resolveModelsCommandReply({
|
||||
cfg: params.cfg,
|
||||
commandBodyNormalized: params.command.commandBodyNormalized,
|
||||
});
|
||||
if (!reply) return null;
|
||||
return { reply, shouldContinue: false };
|
||||
return { reply: payload, shouldContinue: false };
|
||||
};
|
||||
@@ -36,7 +36,7 @@ describe("/model chat UX", () => {
|
||||
expect(reply?.text).toContain("Switch: /model <provider/model>");
|
||||
});
|
||||
|
||||
it("auto-applies closest match for typos", () => {
|
||||
it("suggests closest match for typos without switching", () => {
|
||||
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
|
||||
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
|
||||
|
||||
@@ -52,11 +52,9 @@ describe("/model chat UX", () => {
|
||||
provider: "anthropic",
|
||||
});
|
||||
|
||||
expect(resolved.modelSelection).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
isDefault: true,
|
||||
});
|
||||
expect(resolved.errorText).toBeUndefined();
|
||||
expect(resolved.modelSelection).toBeUndefined();
|
||||
expect(resolved.errorText).toContain("Did you mean:");
|
||||
expect(resolved.errorText).toContain("anthropic/claude-opus-4-5");
|
||||
expect(resolved.errorText).toContain("Try: /model anthropic/claude-opus-4-5");
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
resolveProviderEndpointLabel,
|
||||
} from "./directive-handling.model-picker.js";
|
||||
import type { InlineDirectives } from "./directive-handling.parse.js";
|
||||
import { resolveModelsCommandReply } from "./commands-models.js";
|
||||
import { type ModelDirectiveSelection, resolveModelDirectiveSelection } from "./model-selection.js";
|
||||
|
||||
function buildModelPickerCatalog(params: {
|
||||
@@ -186,11 +185,14 @@ export async function maybeHandleModelDirectiveInfo(params: {
|
||||
});
|
||||
|
||||
if (wantsLegacyList) {
|
||||
const reply = await resolveModelsCommandReply({
|
||||
cfg: params.cfg,
|
||||
commandBodyNormalized: "/models",
|
||||
});
|
||||
return reply ?? { text: "No models available." };
|
||||
return {
|
||||
text: [
|
||||
"Model listing moved.",
|
||||
"",
|
||||
"Use: /models (providers) or /models <provider> (models)",
|
||||
"Switch: /model <provider/model>",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
if (wantsSummary) {
|
||||
@@ -338,7 +340,17 @@ export function resolveModelSelectionFromDirective(params: {
|
||||
}
|
||||
|
||||
if (resolved.selection) {
|
||||
modelSelection = resolved.selection;
|
||||
const suggestion = `${resolved.selection.provider}/${resolved.selection.model}`;
|
||||
return {
|
||||
errorText: [
|
||||
`Unrecognized model: ${raw}`,
|
||||
"",
|
||||
`Did you mean: ${suggestion}`,
|
||||
`Try: /model ${suggestion}`,
|
||||
"",
|
||||
"Browse: /models or /models <provider>",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ function boundedLevenshteinDistance(a: string, b: string, maxDistance: number):
|
||||
if (Math.abs(aLen - bLen) > maxDistance) return null;
|
||||
|
||||
// Standard DP with early exit. O(maxDistance * minLen) in common cases.
|
||||
const prev = Array.from({ length: bLen + 1 }, (_, idx) => idx);
|
||||
const prev = Array.from({ length: bLen + 1 }, (_, index) => index);
|
||||
const curr = Array.from({ length: bLen + 1 }, () => 0);
|
||||
|
||||
for (let i = 1; i <= aLen; i++) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { startGatewayServer } from "../../gateway/server.js";
|
||||
import { acquireGatewayLock } from "../../infra/gateway-lock.js";
|
||||
import {
|
||||
consumeGatewaySigusr1RestartAuthorization,
|
||||
isGatewaySigusr1RestartExternallyAllowed,
|
||||
@@ -15,7 +14,6 @@ export async function runGatewayLoop(params: {
|
||||
start: () => Promise<Awaited<ReturnType<typeof startGatewayServer>>>;
|
||||
runtime: typeof defaultRuntime;
|
||||
}) {
|
||||
const lock = await acquireGatewayLock();
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
|
||||
let shuttingDown = false;
|
||||
let restartResolver: (() => void) | null = null;
|
||||
@@ -98,7 +96,6 @@ export async function runGatewayLoop(params: {
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await lock?.release();
|
||||
cleanupSignals();
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ const callGateway = vi.fn(async (opts: { method?: string }) => {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (opts.method === "exec.approvals.node.get") {
|
||||
if (opts.method === "exec.approvals.get") {
|
||||
return {
|
||||
path: "/tmp/exec-approvals.json",
|
||||
exists: true,
|
||||
|
||||
@@ -38,7 +38,6 @@ type ExecDefaults = {
|
||||
ask?: ExecAsk;
|
||||
node?: string;
|
||||
pathPrepend?: string[];
|
||||
safeBins?: string[];
|
||||
};
|
||||
|
||||
function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
|
||||
@@ -96,7 +95,6 @@ function resolveExecDefaults(
|
||||
ask: globalExec.ask,
|
||||
node: globalExec.node,
|
||||
pathPrepend: globalExec.pathPrepend,
|
||||
safeBins: globalExec.safeBins,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
@@ -106,7 +104,6 @@ function resolveExecDefaults(
|
||||
ask: agentExec?.ask ?? globalExec?.ask,
|
||||
node: agentExec?.node ?? globalExec?.node,
|
||||
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
|
||||
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,9 +230,7 @@ export function registerNodesInvokeCommands(nodes: Command) {
|
||||
const security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
|
||||
const ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
|
||||
|
||||
const approvalsSnapshot = (await callGatewayCli("exec.approvals.node.get", opts, {
|
||||
nodeId,
|
||||
})) as {
|
||||
const approvalsSnapshot = (await callGatewayCli("exec.approvals.get", opts, {})) as {
|
||||
file?: unknown;
|
||||
} | null;
|
||||
const approvalsFile =
|
||||
|
||||
@@ -46,7 +46,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
||||
value: "anthropic",
|
||||
label: "Anthropic",
|
||||
hint: "Claude Code CLI + API key",
|
||||
choices: ["claude-cli", "token", "apiKey"],
|
||||
choices: ["claude-cli", "setup-token", "token", "apiKey"],
|
||||
},
|
||||
{
|
||||
value: "minimax",
|
||||
@@ -170,6 +170,12 @@ export function buildAuthChoiceOptions(params: {
|
||||
});
|
||||
}
|
||||
|
||||
options.push({
|
||||
value: "setup-token",
|
||||
label: "Anthropic token (run setup-token)",
|
||||
hint: "runs `claude setup-token` · opens browser for fresh OAuth login",
|
||||
});
|
||||
|
||||
options.push({
|
||||
value: "token",
|
||||
label: "Anthropic token (paste setup-token)",
|
||||
|
||||
@@ -87,19 +87,6 @@ export async function doctorCommand(
|
||||
});
|
||||
let cfg: ClawdbotConfig = configResult.cfg;
|
||||
|
||||
const configPath = configResult.path ?? CONFIG_PATH_CLAWDBOT;
|
||||
if (!cfg.gateway?.mode) {
|
||||
const lines = [
|
||||
"gateway.mode is unset; gateway start will be blocked.",
|
||||
`Fix: run ${formatCliCommand("clawdbot configure")} and set Gateway mode (local/remote).`,
|
||||
`Or set directly: ${formatCliCommand("clawdbot config set gateway.mode local")}`,
|
||||
];
|
||||
if (!fs.existsSync(configPath)) {
|
||||
lines.push(`Missing config: run ${formatCliCommand("clawdbot setup")} first.`);
|
||||
}
|
||||
note(lines.join("\n"), "Gateway");
|
||||
}
|
||||
|
||||
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
|
||||
await noteAuthProfileHealth({
|
||||
cfg,
|
||||
|
||||
@@ -220,18 +220,12 @@ describe("legacy config detection", () => {
|
||||
});
|
||||
it("keeps gateway.bind tailnet", async () => {
|
||||
vi.resetModules();
|
||||
const { migrateLegacyConfig, validateConfigObject } = await import("./config.js");
|
||||
const { migrateLegacyConfig } = await import("./config.js");
|
||||
const res = migrateLegacyConfig({
|
||||
gateway: { bind: "tailnet" as const },
|
||||
});
|
||||
expect(res.changes).not.toContain("Migrated gateway.bind from 'tailnet' to 'auto'.");
|
||||
expect(res.config).toBeNull();
|
||||
|
||||
const validated = validateConfigObject({ gateway: { bind: "tailnet" as const } });
|
||||
expect(validated.ok).toBe(true);
|
||||
if (validated.ok) {
|
||||
expect(validated.config.gateway?.bind).toBe("tailnet");
|
||||
}
|
||||
expect(res.config?.gateway?.bind).toBe("tailnet");
|
||||
});
|
||||
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
@@ -202,12 +202,10 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
|
||||
|
||||
let mutated = false;
|
||||
const nextDefaults = { ...defaults };
|
||||
const contextPruning = defaults.contextPruning ?? {};
|
||||
const heartbeat = defaults.heartbeat ?? {};
|
||||
|
||||
if (defaults.contextPruning?.mode === undefined) {
|
||||
nextDefaults.contextPruning = {
|
||||
...contextPruning,
|
||||
...defaults.contextPruning,
|
||||
mode: "cache-ttl",
|
||||
ttl: defaults.contextPruning?.ttl ?? "1h",
|
||||
};
|
||||
@@ -216,7 +214,7 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
|
||||
|
||||
if (defaults.heartbeat?.every === undefined) {
|
||||
nextDefaults.heartbeat = {
|
||||
...heartbeat,
|
||||
...defaults.heartbeat,
|
||||
every: authMode === "oauth" ? "1h" : "30m",
|
||||
};
|
||||
mutated = true;
|
||||
|
||||
@@ -7,7 +7,11 @@ export function migrateLegacyConfig(raw: unknown): {
|
||||
changes: string[];
|
||||
} {
|
||||
const { next, changes } = applyLegacyMigrations(raw);
|
||||
if (!next) return { config: null, changes: [] };
|
||||
if (!next) {
|
||||
const validated = validateConfigObjectWithPlugins(raw);
|
||||
if (!validated.ok) return { config: null, changes: [] };
|
||||
return { config: validated.config, changes: [] };
|
||||
}
|
||||
const validated = validateConfigObjectWithPlugins(next);
|
||||
if (!validated.ok) {
|
||||
changes.push("Migration applied, but config still invalid; fix remaining issues manually.");
|
||||
|
||||
@@ -161,13 +161,11 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.exec.applyPatch.enabled": "Enable apply_patch",
|
||||
"tools.exec.applyPatch.allowModels": "apply_patch Model Allowlist",
|
||||
"tools.exec.notifyOnExit": "Exec Notify On Exit",
|
||||
"tools.exec.approvalRunningNoticeMs": "Exec Approval Running Notice (ms)",
|
||||
"tools.exec.host": "Exec Host",
|
||||
"tools.exec.security": "Exec Security",
|
||||
"tools.exec.ask": "Exec Ask",
|
||||
"tools.exec.node": "Exec Node Binding",
|
||||
"tools.exec.pathPrepend": "Exec PATH Prepend",
|
||||
"tools.exec.safeBins": "Exec Safe Bins",
|
||||
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
|
||||
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
|
||||
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
|
||||
@@ -188,7 +186,6 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
|
||||
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
||||
"gateway.controlUi.basePath": "Control UI Base Path",
|
||||
"gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth",
|
||||
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
||||
"gateway.reload.mode": "Config Reload Mode",
|
||||
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
||||
@@ -347,8 +344,6 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"gateway.auth.password": "Required for Tailscale funnel.",
|
||||
"gateway.controlUi.basePath":
|
||||
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).",
|
||||
"gateway.controlUi.allowInsecureAuth":
|
||||
"Allow Control UI auth over insecure HTTP (token-only; not recommended).",
|
||||
"gateway.http.endpoints.chatCompletions.enabled":
|
||||
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
||||
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
|
||||
@@ -372,8 +367,6 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"tools.exec.notifyOnExit":
|
||||
"When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.",
|
||||
"tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).",
|
||||
"tools.exec.safeBins":
|
||||
"Allow stdin-only safe binaries to run without explicit allowlist entries.",
|
||||
"tools.message.allowCrossContextSend":
|
||||
"Legacy override: allow cross-context sends across all providers.",
|
||||
"tools.message.crossContext.allowWithinProvider":
|
||||
|
||||
@@ -173,8 +173,6 @@ export type AgentDefaultsConfig = {
|
||||
};
|
||||
/** Heartbeat model override (provider/model). */
|
||||
model?: string;
|
||||
/** Session key for heartbeat runs ("main" or explicit session key). */
|
||||
session?: string;
|
||||
/** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */
|
||||
target?:
|
||||
| "last"
|
||||
|
||||
@@ -51,8 +51,6 @@ export type GatewayControlUiConfig = {
|
||||
enabled?: boolean;
|
||||
/** Optional base path prefix for the Control UI (e.g. "/clawdbot"). */
|
||||
basePath?: string;
|
||||
/** Allow token-only auth over insecure HTTP (default: false). */
|
||||
allowInsecureAuth?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayAuthMode = "token" | "password";
|
||||
|
||||
@@ -131,14 +131,10 @@ export type ExecToolConfig = {
|
||||
node?: string;
|
||||
/** Directories to prepend to PATH when running exec (gateway/sandbox). */
|
||||
pathPrepend?: string[];
|
||||
/** Safe stdin-only binaries that can run without allowlist entries. */
|
||||
safeBins?: string[];
|
||||
/** Default time (ms) before an exec command auto-backgrounds. */
|
||||
backgroundMs?: number;
|
||||
/** Default timeout (seconds) before auto-killing exec commands. */
|
||||
timeoutSec?: number;
|
||||
/** Emit a running notice (ms) when approval-backed exec runs long (default: 10000, 0 = off). */
|
||||
approvalRunningNoticeMs?: number;
|
||||
/** How long to keep finished sessions in memory (ms). */
|
||||
cleanupMs?: number;
|
||||
/** Emit a system event and heartbeat when a backgrounded exec exits. */
|
||||
|
||||
@@ -20,7 +20,6 @@ export const HeartbeatSchema = z
|
||||
.strict()
|
||||
.optional(),
|
||||
model: z.string().optional(),
|
||||
session: z.string().optional(),
|
||||
includeReasoning: z.boolean().optional(),
|
||||
target: z
|
||||
.union([
|
||||
@@ -258,10 +257,8 @@ export const AgentToolsSchema = z
|
||||
ask: z.enum(["off", "on-miss", "always"]).optional(),
|
||||
node: z.string().optional(),
|
||||
pathPrepend: z.array(z.string()).optional(),
|
||||
safeBins: z.array(z.string()).optional(),
|
||||
backgroundMs: z.number().int().positive().optional(),
|
||||
timeoutSec: z.number().int().positive().optional(),
|
||||
approvalRunningNoticeMs: z.number().int().nonnegative().optional(),
|
||||
cleanupMs: z.number().int().positive().optional(),
|
||||
notifyOnExit: z.boolean().optional(),
|
||||
applyPatch: z
|
||||
@@ -488,7 +485,6 @@ export const ToolsSchema = z
|
||||
ask: z.enum(["off", "on-miss", "always"]).optional(),
|
||||
node: z.string().optional(),
|
||||
pathPrepend: z.array(z.string()).optional(),
|
||||
safeBins: z.array(z.string()).optional(),
|
||||
backgroundMs: z.number().int().positive().optional(),
|
||||
timeoutSec: z.number().int().positive().optional(),
|
||||
cleanupMs: z.number().int().positive().optional(),
|
||||
|
||||
@@ -282,7 +282,6 @@ export const ClawdbotSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
basePath: z.string().optional(),
|
||||
allowInsecureAuth: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -33,15 +33,11 @@ type PendingEntry = {
|
||||
export class ExecApprovalManager {
|
||||
private pending = new Map<string, PendingEntry>();
|
||||
|
||||
create(
|
||||
request: ExecApprovalRequestPayload,
|
||||
timeoutMs: number,
|
||||
id?: string | null,
|
||||
): ExecApprovalRecord {
|
||||
create(request: ExecApprovalRequestPayload, timeoutMs: number): ExecApprovalRecord {
|
||||
const now = Date.now();
|
||||
const resolvedId = id && id.trim().length > 0 ? id.trim() : randomUUID();
|
||||
const id = randomUUID();
|
||||
const record: ExecApprovalRecord = {
|
||||
id: resolvedId,
|
||||
id,
|
||||
request,
|
||||
createdAtMs: now,
|
||||
expiresAtMs: now + timeoutMs,
|
||||
|
||||
@@ -89,7 +89,6 @@ export const ExecApprovalsNodeSetParamsSchema = Type.Object(
|
||||
|
||||
export const ExecApprovalRequestParamsSchema = Type.Object(
|
||||
{
|
||||
id: Type.Optional(NonEmptyString),
|
||||
command: NonEmptyString,
|
||||
cwd: Type.Optional(Type.String()),
|
||||
host: Type.Optional(Type.String()),
|
||||
|
||||
@@ -2,14 +2,7 @@ export const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size
|
||||
export const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit
|
||||
|
||||
export const MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits
|
||||
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
|
||||
export const getHandshakeTimeoutMs = () => {
|
||||
if (process.env.VITEST && process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS) {
|
||||
const parsed = Number(process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
return DEFAULT_HANDSHAKE_TIMEOUT_MS;
|
||||
};
|
||||
export const HANDSHAKE_TIMEOUT_MS = 10_000;
|
||||
export const TICK_INTERVAL_MS = 30_000;
|
||||
export const HEALTH_REFRESH_INTERVAL_MS = 60_000;
|
||||
export const DEDUPE_TTL_MS = 5 * 60_000;
|
||||
|
||||
@@ -60,120 +60,4 @@ describe("exec approval handlers", () => {
|
||||
);
|
||||
expect(broadcasts.some((entry) => entry.event === "exec.approval.resolved")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts explicit approval ids", async () => {
|
||||
const manager = new ExecApprovalManager();
|
||||
const handlers = createExecApprovalHandlers(manager);
|
||||
const broadcasts: Array<{ event: string; payload: unknown }> = [];
|
||||
|
||||
const respond = vi.fn();
|
||||
const context = {
|
||||
broadcast: (event: string, payload: unknown) => {
|
||||
broadcasts.push({ event, payload });
|
||||
},
|
||||
};
|
||||
|
||||
const requestPromise = handlers["exec.approval.request"]({
|
||||
params: {
|
||||
id: "approval-123",
|
||||
command: "echo ok",
|
||||
cwd: "/tmp",
|
||||
host: "gateway",
|
||||
timeoutMs: 2000,
|
||||
},
|
||||
respond,
|
||||
context: context as unknown as Parameters<
|
||||
(typeof handlers)["exec.approval.request"]
|
||||
>[0]["context"],
|
||||
client: null,
|
||||
req: { id: "req-1", type: "req", method: "exec.approval.request" },
|
||||
isWebchatConnect: noop,
|
||||
});
|
||||
|
||||
const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
|
||||
const id = (requested?.payload as { id?: string })?.id ?? "";
|
||||
expect(id).toBe("approval-123");
|
||||
|
||||
const resolveRespond = vi.fn();
|
||||
await handlers["exec.approval.resolve"]({
|
||||
params: { id, decision: "allow-once" },
|
||||
respond: resolveRespond,
|
||||
context: context as unknown as Parameters<
|
||||
(typeof handlers)["exec.approval.resolve"]
|
||||
>[0]["context"],
|
||||
client: { connect: { client: { id: "cli", displayName: "CLI" } } },
|
||||
req: { id: "req-2", type: "req", method: "exec.approval.resolve" },
|
||||
isWebchatConnect: noop,
|
||||
});
|
||||
|
||||
await requestPromise;
|
||||
expect(respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ id: "approval-123", decision: "allow-once" }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate approval ids", async () => {
|
||||
const manager = new ExecApprovalManager();
|
||||
const handlers = createExecApprovalHandlers(manager);
|
||||
const respondA = vi.fn();
|
||||
const respondB = vi.fn();
|
||||
const broadcasts: Array<{ event: string; payload: unknown }> = [];
|
||||
const context = {
|
||||
broadcast: (event: string, payload: unknown) => {
|
||||
broadcasts.push({ event, payload });
|
||||
},
|
||||
};
|
||||
|
||||
const requestPromise = handlers["exec.approval.request"]({
|
||||
params: {
|
||||
id: "dup-1",
|
||||
command: "echo ok",
|
||||
},
|
||||
respond: respondA,
|
||||
context: context as unknown as Parameters<
|
||||
(typeof handlers)["exec.approval.request"]
|
||||
>[0]["context"],
|
||||
client: null,
|
||||
req: { id: "req-1", type: "req", method: "exec.approval.request" },
|
||||
isWebchatConnect: noop,
|
||||
});
|
||||
|
||||
await handlers["exec.approval.request"]({
|
||||
params: {
|
||||
id: "dup-1",
|
||||
command: "echo again",
|
||||
},
|
||||
respond: respondB,
|
||||
context: context as unknown as Parameters<
|
||||
(typeof handlers)["exec.approval.request"]
|
||||
>[0]["context"],
|
||||
client: null,
|
||||
req: { id: "req-2", type: "req", method: "exec.approval.request" },
|
||||
isWebchatConnect: noop,
|
||||
});
|
||||
|
||||
expect(respondB).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: "approval id already pending" }),
|
||||
);
|
||||
|
||||
const requested = broadcasts.find((entry) => entry.event === "exec.approval.requested");
|
||||
const id = (requested?.payload as { id?: string })?.id ?? "";
|
||||
const resolveRespond = vi.fn();
|
||||
await handlers["exec.approval.resolve"]({
|
||||
params: { id, decision: "deny" },
|
||||
respond: resolveRespond,
|
||||
context: context as unknown as Parameters<
|
||||
(typeof handlers)["exec.approval.resolve"]
|
||||
>[0]["context"],
|
||||
client: { connect: { client: { id: "cli", displayName: "CLI" } } },
|
||||
req: { id: "req-3", type: "req", method: "exec.approval.resolve" },
|
||||
isWebchatConnect: noop,
|
||||
});
|
||||
|
||||
await requestPromise;
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,6 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa
|
||||
return;
|
||||
}
|
||||
const p = params as {
|
||||
id?: string;
|
||||
command: string;
|
||||
cwd?: string;
|
||||
host?: string;
|
||||
@@ -38,15 +37,6 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa
|
||||
timeoutMs?: number;
|
||||
};
|
||||
const timeoutMs = typeof p.timeoutMs === "number" ? p.timeoutMs : 120_000;
|
||||
const explicitId = typeof p.id === "string" && p.id.trim().length > 0 ? p.id.trim() : null;
|
||||
if (explicitId && manager.getSnapshot(explicitId)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, "approval id already pending"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const request = {
|
||||
command: p.command,
|
||||
cwd: p.cwd ?? null,
|
||||
@@ -57,7 +47,7 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa
|
||||
resolvedPath: p.resolvedPath ?? null,
|
||||
sessionKey: p.sessionKey ?? null,
|
||||
};
|
||||
const record = manager.create(request, timeoutMs, explicitId);
|
||||
const record = manager.create(request, timeoutMs);
|
||||
context.broadcast(
|
||||
"exec.approval.requested",
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||
import { getHandshakeTimeoutMs } from "./server-constants.js";
|
||||
import { HANDSHAKE_TIMEOUT_MS } from "./server-constants.js";
|
||||
import {
|
||||
connectReq,
|
||||
getFreePort,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
startServerWithClient,
|
||||
testState,
|
||||
} from "./test-helpers.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
@@ -29,21 +28,10 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean
|
||||
describe("gateway server auth/connect", () => {
|
||||
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
|
||||
vi.useRealTimers();
|
||||
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "250";
|
||||
try {
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const handshakeTimeoutMs = getHandshakeTimeoutMs();
|
||||
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 2_000);
|
||||
expect(closed).toBe(true);
|
||||
await server.close();
|
||||
} finally {
|
||||
if (prevHandshakeTimeout === undefined) {
|
||||
delete process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||
} else {
|
||||
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout;
|
||||
}
|
||||
}
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const closed = await waitForWsClose(ws, HANDSHAKE_TIMEOUT_MS + 2_000);
|
||||
expect(closed).toBe(true);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("connect (req) handshake returns hello-ok payload", async () => {
|
||||
@@ -128,52 +116,6 @@ describe("gateway server auth/connect", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("rejects control ui without device identity by default", async () => {
|
||||
const { server, ws, prevToken } = await startServerWithClient("secret");
|
||||
const res = await connectReq(ws, {
|
||||
token: "secret",
|
||||
device: null,
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
version: "1.0.0",
|
||||
platform: "web",
|
||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message ?? "").toContain("secure context");
|
||||
ws.close();
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("allows control ui without device identity when insecure auth is enabled", async () => {
|
||||
testState.gatewayControlUi = { allowInsecureAuth: true };
|
||||
const { server, ws, prevToken } = await startServerWithClient("secret");
|
||||
const res = await connectReq(ws, {
|
||||
token: "secret",
|
||||
device: null,
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
version: "1.0.0",
|
||||
platform: "web",
|
||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
ws.close();
|
||||
await server.close();
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts device token auth for paired device", async () => {
|
||||
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
||||
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
|
||||
|
||||
@@ -8,7 +8,7 @@ import { isWebchatClient } from "../../utils/message-channel.js";
|
||||
|
||||
import type { ResolvedGatewayAuth } from "../auth.js";
|
||||
import { isLoopbackAddress } from "../net.js";
|
||||
import { getHandshakeTimeoutMs } from "../server-constants.js";
|
||||
import { HANDSHAKE_TIMEOUT_MS } from "../server-constants.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "../server-methods/types.js";
|
||||
import { formatError } from "../server-utils.js";
|
||||
import { logWs } from "../ws-log.js";
|
||||
@@ -210,7 +210,6 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||
close();
|
||||
});
|
||||
|
||||
const handshakeTimeoutMs = getHandshakeTimeoutMs();
|
||||
const handshakeTimer = setTimeout(() => {
|
||||
if (!client) {
|
||||
handshakeState = "failed";
|
||||
@@ -220,7 +219,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||
logWsControl.warn(`handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`);
|
||||
close();
|
||||
}
|
||||
}, handshakeTimeoutMs);
|
||||
}, HANDSHAKE_TIMEOUT_MS);
|
||||
|
||||
attachGatewayWsMessageHandler({
|
||||
socket,
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
validateConnectParams,
|
||||
validateRequestFrame,
|
||||
} from "../../protocol/index.js";
|
||||
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
|
||||
import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js";
|
||||
import { handleGatewayRequest } from "../../server-methods.js";
|
||||
@@ -294,53 +293,24 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
|
||||
const device = connectParams.device;
|
||||
let devicePublicKey: string | null = null;
|
||||
const hasTokenAuth = Boolean(connectParams.auth?.token);
|
||||
const hasPasswordAuth = Boolean(connectParams.auth?.password);
|
||||
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
||||
|
||||
if (!device) {
|
||||
const allowInsecureControlUi =
|
||||
isControlUi && loadConfig().gateway?.controlUi?.allowInsecureAuth === true;
|
||||
const canSkipDevice =
|
||||
isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth;
|
||||
|
||||
if (isControlUi && !allowInsecureControlUi) {
|
||||
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("control-ui-insecure-auth", {
|
||||
client: connectParams.client.id,
|
||||
clientDisplayName: connectParams.client.displayName,
|
||||
mode: connectParams.client.mode,
|
||||
version: connectParams.client.version,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.INVALID_REQUEST, errorMessage),
|
||||
});
|
||||
close(1008, errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow token-authenticated connections (e.g., control-ui) to skip device identity
|
||||
if (!canSkipDevice) {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("device-required", {
|
||||
client: connectParams.client.id,
|
||||
clientDisplayName: connectParams.client.displayName,
|
||||
mode: connectParams.client.mode,
|
||||
version: connectParams.client.version,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.NOT_PAIRED, "device identity required"),
|
||||
});
|
||||
close(1008, "device identity required");
|
||||
return;
|
||||
}
|
||||
// Allow token-authenticated connections (e.g., control-ui) to skip device identity
|
||||
const hasTokenAuth = !!connectParams.auth?.token;
|
||||
if (!device && !hasTokenAuth) {
|
||||
setHandshakeState("failed");
|
||||
setCloseCause("device-required", {
|
||||
client: connectParams.client.id,
|
||||
clientDisplayName: connectParams.client.displayName,
|
||||
mode: connectParams.client.mode,
|
||||
version: connectParams.client.version,
|
||||
});
|
||||
send({
|
||||
type: "res",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.NOT_PAIRED, "device identity required"),
|
||||
});
|
||||
close(1008, "device identity required");
|
||||
return;
|
||||
}
|
||||
if (device) {
|
||||
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
|
||||
|
||||
@@ -210,7 +210,6 @@ export const testState = {
|
||||
cronEnabled: false as boolean | undefined,
|
||||
gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined,
|
||||
gatewayAuth: undefined as Record<string, unknown> | undefined,
|
||||
gatewayControlUi: undefined as Record<string, unknown> | undefined,
|
||||
hooksConfig: undefined as HooksConfig | undefined,
|
||||
canvasHostPort: undefined as number | undefined,
|
||||
legacyIssues: [] as Array<{ path: string; message: string }>,
|
||||
@@ -444,7 +443,6 @@ vi.mock("../config/config.js", async () => {
|
||||
: {};
|
||||
if (testState.gatewayBind) fileGateway.bind = testState.gatewayBind;
|
||||
if (testState.gatewayAuth) fileGateway.auth = testState.gatewayAuth;
|
||||
if (testState.gatewayControlUi) fileGateway.controlUi = testState.gatewayControlUi;
|
||||
const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined;
|
||||
|
||||
const fileCanvasHost =
|
||||
|
||||
@@ -101,7 +101,6 @@ export function installGatewayTestHooks() {
|
||||
testTailnetIPv4.value = undefined;
|
||||
testState.gatewayBind = undefined;
|
||||
testState.gatewayAuth = undefined;
|
||||
testState.gatewayControlUi = undefined;
|
||||
testState.hooksConfig = undefined;
|
||||
testState.canvasHostPort = undefined;
|
||||
testState.legacyIssues = [];
|
||||
@@ -281,7 +280,7 @@ export async function connectReq(
|
||||
signature: string;
|
||||
signedAt: number;
|
||||
nonce?: string;
|
||||
} | null;
|
||||
};
|
||||
},
|
||||
): Promise<ConnectResponse> {
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
@@ -295,7 +294,6 @@ export async function connectReq(
|
||||
const role = opts?.role ?? "operator";
|
||||
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
|
||||
const device = (() => {
|
||||
if (opts?.device === null) return undefined;
|
||||
if (opts?.device) return opts.device;
|
||||
const identity = loadOrCreateDeviceIdentity();
|
||||
const signedAtMs = Date.now();
|
||||
|
||||
@@ -5,13 +5,9 @@ import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
analyzeArgvCommand,
|
||||
analyzeShellCommand,
|
||||
isSafeBinUsage,
|
||||
matchAllowlist,
|
||||
maxAsk,
|
||||
minSecurity,
|
||||
normalizeSafeBins,
|
||||
resolveCommandResolution,
|
||||
resolveExecApprovals,
|
||||
type ExecAllowlistEntry,
|
||||
@@ -22,7 +18,7 @@ function makeTempDir() {
|
||||
}
|
||||
|
||||
describe("exec approvals allowlist matching", () => {
|
||||
it("ignores basename-only patterns", () => {
|
||||
it("matches by executable name (case-insensitive)", () => {
|
||||
const resolution = {
|
||||
rawExecutable: "rg",
|
||||
resolvedPath: "/opt/homebrew/bin/rg",
|
||||
@@ -30,7 +26,7 @@ describe("exec approvals allowlist matching", () => {
|
||||
};
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "RG" }];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match).toBeNull();
|
||||
expect(match?.pattern).toBe("RG");
|
||||
});
|
||||
|
||||
it("matches by resolved path with **", () => {
|
||||
@@ -55,7 +51,7 @@ describe("exec approvals allowlist matching", () => {
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it("requires a resolved path", () => {
|
||||
it("falls back to raw executable when no resolved path", () => {
|
||||
const resolution = {
|
||||
rawExecutable: "bin/rg",
|
||||
resolvedPath: undefined,
|
||||
@@ -63,7 +59,7 @@ describe("exec approvals allowlist matching", () => {
|
||||
};
|
||||
const entries: ExecAllowlistEntry[] = [{ pattern: "bin/rg" }];
|
||||
const match = matchAllowlist(entries, resolution);
|
||||
expect(match).toBeNull();
|
||||
expect(match?.pattern).toBe("bin/rg");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,13 +68,11 @@ describe("exec approvals command resolution", () => {
|
||||
const dir = makeTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const exeName = process.platform === "win32" ? "rg.exe" : "rg";
|
||||
const exe = path.join(binDir, exeName);
|
||||
const exe = path.join(binDir, "rg");
|
||||
fs.writeFileSync(exe, "");
|
||||
fs.chmodSync(exe, 0o755);
|
||||
const res = resolveCommandResolution("rg -n foo", undefined, { PATH: binDir });
|
||||
expect(res?.resolvedPath).toBe(exe);
|
||||
expect(res?.executableName).toBe(exeName);
|
||||
expect(res?.executableName).toBe("rg");
|
||||
});
|
||||
|
||||
it("resolves relative paths against cwd", () => {
|
||||
@@ -87,7 +81,6 @@ describe("exec approvals command resolution", () => {
|
||||
const script = path.join(cwd, "scripts", "run.sh");
|
||||
fs.mkdirSync(path.dirname(script), { recursive: true });
|
||||
fs.writeFileSync(script, "");
|
||||
fs.chmodSync(script, 0o755);
|
||||
const res = resolveCommandResolution("./scripts/run.sh --flag", cwd, undefined);
|
||||
expect(res?.resolvedPath).toBe(script);
|
||||
});
|
||||
@@ -98,83 +91,11 @@ describe("exec approvals command resolution", () => {
|
||||
const script = path.join(cwd, "bin", "tool");
|
||||
fs.mkdirSync(path.dirname(script), { recursive: true });
|
||||
fs.writeFileSync(script, "");
|
||||
fs.chmodSync(script, 0o755);
|
||||
const res = resolveCommandResolution('"./bin/tool" --version', cwd, undefined);
|
||||
expect(res?.resolvedPath).toBe(script);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals shell parsing", () => {
|
||||
it("parses simple pipelines", () => {
|
||||
const res = analyzeShellCommand({ command: "echo ok | jq .foo" });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.segments.map((seg) => seg.argv[0])).toEqual(["echo", "jq"]);
|
||||
});
|
||||
|
||||
it("rejects chained commands", () => {
|
||||
const res = analyzeShellCommand({ command: "ls && rm -rf /" });
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("parses argv commands", () => {
|
||||
const res = analyzeArgvCommand({ argv: ["/bin/echo", "ok"] });
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.segments[0]?.argv).toEqual(["/bin/echo", "ok"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals safe bins", () => {
|
||||
it("allows safe bins with non-path args", () => {
|
||||
const dir = makeTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const exeName = process.platform === "win32" ? "jq.exe" : "jq";
|
||||
const exe = path.join(binDir, exeName);
|
||||
fs.writeFileSync(exe, "");
|
||||
fs.chmodSync(exe, 0o755);
|
||||
const res = analyzeShellCommand({
|
||||
command: "jq .foo",
|
||||
cwd: dir,
|
||||
env: { PATH: binDir },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const segment = res.segments[0];
|
||||
const ok = isSafeBinUsage({
|
||||
argv: segment.argv,
|
||||
resolution: segment.resolution,
|
||||
safeBins: normalizeSafeBins(["jq"]),
|
||||
cwd: dir,
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks safe bins with file args", () => {
|
||||
const dir = makeTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const exeName = process.platform === "win32" ? "jq.exe" : "jq";
|
||||
const exe = path.join(binDir, exeName);
|
||||
fs.writeFileSync(exe, "");
|
||||
fs.chmodSync(exe, 0o755);
|
||||
const file = path.join(dir, "secret.json");
|
||||
fs.writeFileSync(file, "{}");
|
||||
const res = analyzeShellCommand({
|
||||
command: "jq .foo secret.json",
|
||||
cwd: dir,
|
||||
env: { PATH: binDir },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
const segment = res.segments[0];
|
||||
const ok = isSafeBinUsage({
|
||||
argv: segment.argv,
|
||||
resolution: segment.resolution,
|
||||
safeBins: normalizeSafeBins(["jq"]),
|
||||
cwd: dir,
|
||||
});
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exec approvals policy helpers", () => {
|
||||
it("minSecurity returns the more restrictive value", () => {
|
||||
expect(minSecurity("deny", "full")).toBe("deny");
|
||||
|
||||
+13
-314
@@ -60,7 +60,6 @@ const DEFAULT_ASK_FALLBACK: ExecSecurity = "deny";
|
||||
const DEFAULT_AUTO_ALLOW_SKILLS = false;
|
||||
const DEFAULT_SOCKET = "~/.clawdbot/exec-approvals.sock";
|
||||
const DEFAULT_FILE = "~/.clawdbot/exec-approvals.json";
|
||||
export const DEFAULT_SAFE_BINS = ["jq", "grep", "cut", "sort", "uniq", "head", "tail", "tr", "wc"];
|
||||
|
||||
function hashExecApprovalsRaw(raw: string | null): string {
|
||||
return crypto
|
||||
@@ -284,19 +283,6 @@ type CommandResolution = {
|
||||
executableName: string;
|
||||
};
|
||||
|
||||
function isExecutableFile(filePath: string): boolean {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile()) return false;
|
||||
if (process.platform !== "win32") {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseFirstToken(command: string): string | null {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -313,26 +299,15 @@ function parseFirstToken(command: string): string | null {
|
||||
function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS.ProcessEnv) {
|
||||
const expanded = rawExecutable.startsWith("~") ? expandHome(rawExecutable) : rawExecutable;
|
||||
if (expanded.includes("/") || expanded.includes("\\")) {
|
||||
if (path.isAbsolute(expanded)) {
|
||||
return isExecutableFile(expanded) ? expanded : undefined;
|
||||
}
|
||||
if (path.isAbsolute(expanded)) return expanded;
|
||||
const base = cwd && cwd.trim() ? cwd.trim() : process.cwd();
|
||||
const candidate = path.resolve(base, expanded);
|
||||
return isExecutableFile(candidate) ? candidate : undefined;
|
||||
return path.resolve(base, expanded);
|
||||
}
|
||||
const envPath = env?.PATH ?? process.env.PATH ?? "";
|
||||
const entries = envPath.split(path.delimiter).filter(Boolean);
|
||||
const extensions =
|
||||
process.platform === "win32"
|
||||
? (env?.PATHEXT ?? process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
|
||||
.split(";")
|
||||
.map((ext) => ext.toLowerCase())
|
||||
: [""];
|
||||
for (const entry of entries) {
|
||||
for (const ext of extensions) {
|
||||
const candidate = path.join(entry, expanded + ext);
|
||||
if (isExecutableFile(candidate)) return candidate;
|
||||
}
|
||||
const candidate = path.join(entry, expanded);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -349,18 +324,6 @@ export function resolveCommandResolution(
|
||||
return { rawExecutable, resolvedPath, executableName };
|
||||
}
|
||||
|
||||
export function resolveCommandResolutionFromArgv(
|
||||
argv: string[],
|
||||
cwd?: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
): CommandResolution | null {
|
||||
const rawExecutable = argv[0]?.trim();
|
||||
if (!rawExecutable) return null;
|
||||
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
|
||||
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
|
||||
return { rawExecutable, resolvedPath, executableName };
|
||||
}
|
||||
|
||||
function normalizeMatchTarget(value: string): string {
|
||||
return value.replace(/\\\\/g, "/").toLowerCase();
|
||||
}
|
||||
@@ -407,288 +370,24 @@ export function matchAllowlist(
|
||||
entries: ExecAllowlistEntry[],
|
||||
resolution: CommandResolution | null,
|
||||
): ExecAllowlistEntry | null {
|
||||
if (!entries.length || !resolution?.resolvedPath) return null;
|
||||
if (!entries.length || !resolution) return null;
|
||||
const rawExecutable = resolution.rawExecutable;
|
||||
const resolvedPath = resolution.resolvedPath;
|
||||
const executableName = resolution.executableName;
|
||||
for (const entry of entries) {
|
||||
const pattern = entry.pattern?.trim();
|
||||
if (!pattern) continue;
|
||||
const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~");
|
||||
if (!hasPath) continue;
|
||||
if (matchesPattern(pattern, resolvedPath)) return entry;
|
||||
if (hasPath) {
|
||||
const target = resolvedPath ?? rawExecutable;
|
||||
if (target && matchesPattern(pattern, target)) return entry;
|
||||
continue;
|
||||
}
|
||||
if (executableName && matchesPattern(pattern, executableName)) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type ExecCommandSegment = {
|
||||
raw: string;
|
||||
argv: string[];
|
||||
resolution: CommandResolution | null;
|
||||
};
|
||||
|
||||
export type ExecCommandAnalysis = {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
segments: ExecCommandSegment[];
|
||||
};
|
||||
|
||||
const DISALLOWED_TOKENS = new Set([";", "&", ">", "<", "`", "\n", "\r", "(", ")"]);
|
||||
|
||||
function splitShellPipeline(command: string): { ok: boolean; reason?: string; segments: string[] } {
|
||||
const segments: string[] = [];
|
||||
let buf = "";
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let escaped = false;
|
||||
|
||||
const pushSegment = () => {
|
||||
const trimmed = buf.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
segments.push(trimmed);
|
||||
buf = "";
|
||||
return true;
|
||||
};
|
||||
|
||||
for (let i = 0; i < command.length; i += 1) {
|
||||
const ch = command[i];
|
||||
if (escaped) {
|
||||
buf += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && ch === "\\") {
|
||||
escaped = true;
|
||||
buf += ch;
|
||||
continue;
|
||||
}
|
||||
if (inSingle) {
|
||||
if (ch === "'") inSingle = false;
|
||||
buf += ch;
|
||||
continue;
|
||||
}
|
||||
if (inDouble) {
|
||||
if (ch === '"') inDouble = false;
|
||||
buf += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'") {
|
||||
inSingle = true;
|
||||
buf += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inDouble = true;
|
||||
buf += ch;
|
||||
continue;
|
||||
}
|
||||
if (ch === "|" && command[i + 1] === "|") {
|
||||
return { ok: false, reason: "unsupported shell token: ||", segments: [] };
|
||||
}
|
||||
if (ch === "|" && command[i + 1] === "&") {
|
||||
return { ok: false, reason: "unsupported shell token: |&", segments: [] };
|
||||
}
|
||||
if (ch === "|") {
|
||||
if (!pushSegment()) {
|
||||
return { ok: false, reason: "empty pipeline segment", segments: [] };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (DISALLOWED_TOKENS.has(ch)) {
|
||||
return { ok: false, reason: `unsupported shell token: ${ch}`, segments: [] };
|
||||
}
|
||||
if (ch === "$" && command[i + 1] === "(") {
|
||||
return { ok: false, reason: "unsupported shell token: $()", segments: [] };
|
||||
}
|
||||
buf += ch;
|
||||
}
|
||||
|
||||
if (escaped || inSingle || inDouble) {
|
||||
return { ok: false, reason: "unterminated shell quote/escape", segments: [] };
|
||||
}
|
||||
if (!pushSegment()) {
|
||||
return { ok: false, reason: "empty command", segments: [] };
|
||||
}
|
||||
return { ok: true, segments };
|
||||
}
|
||||
|
||||
function tokenizeShellSegment(segment: string): string[] | null {
|
||||
const tokens: string[] = [];
|
||||
let buf = "";
|
||||
let inSingle = false;
|
||||
let inDouble = false;
|
||||
let escaped = false;
|
||||
|
||||
const pushToken = () => {
|
||||
if (buf.length > 0) {
|
||||
tokens.push(buf);
|
||||
buf = "";
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < segment.length; i += 1) {
|
||||
const ch = segment[i];
|
||||
if (escaped) {
|
||||
buf += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (!inSingle && ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (inSingle) {
|
||||
if (ch === "'") {
|
||||
inSingle = false;
|
||||
} else {
|
||||
buf += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (inDouble) {
|
||||
if (ch === '"') {
|
||||
inDouble = false;
|
||||
} else {
|
||||
buf += ch;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === "'") {
|
||||
inSingle = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inDouble = true;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(ch)) {
|
||||
pushToken();
|
||||
continue;
|
||||
}
|
||||
buf += ch;
|
||||
}
|
||||
|
||||
if (escaped || inSingle || inDouble) {
|
||||
return null;
|
||||
}
|
||||
pushToken();
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function analyzeShellCommand(params: {
|
||||
command: string;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ExecCommandAnalysis {
|
||||
const split = splitShellPipeline(params.command);
|
||||
if (!split.ok) {
|
||||
return { ok: false, reason: split.reason, segments: [] };
|
||||
}
|
||||
const segments: ExecCommandSegment[] = [];
|
||||
for (const raw of split.segments) {
|
||||
const argv = tokenizeShellSegment(raw);
|
||||
if (!argv || argv.length === 0) {
|
||||
return { ok: false, reason: "unable to parse shell segment", segments: [] };
|
||||
}
|
||||
segments.push({
|
||||
raw,
|
||||
argv,
|
||||
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
|
||||
});
|
||||
}
|
||||
return { ok: true, segments };
|
||||
}
|
||||
|
||||
export function analyzeArgvCommand(params: {
|
||||
argv: string[];
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): ExecCommandAnalysis {
|
||||
const argv = params.argv.filter((entry) => entry.trim().length > 0);
|
||||
if (argv.length === 0) {
|
||||
return { ok: false, reason: "empty argv", segments: [] };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
segments: [
|
||||
{
|
||||
raw: argv.join(" "),
|
||||
argv,
|
||||
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function isPathLikeToken(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
if (trimmed === "-") return false;
|
||||
if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("~")) return true;
|
||||
if (trimmed.startsWith("/")) return true;
|
||||
return /^[A-Za-z]:[\\/]/.test(trimmed);
|
||||
}
|
||||
|
||||
function defaultFileExists(filePath: string): boolean {
|
||||
try {
|
||||
return fs.existsSync(filePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeSafeBins(entries?: string[]): Set<string> {
|
||||
if (!Array.isArray(entries)) return new Set();
|
||||
const normalized = entries
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter((entry) => entry.length > 0);
|
||||
return new Set(normalized);
|
||||
}
|
||||
|
||||
export function resolveSafeBins(entries?: string[] | null): Set<string> {
|
||||
if (entries === undefined) return normalizeSafeBins(DEFAULT_SAFE_BINS);
|
||||
return normalizeSafeBins(entries ?? []);
|
||||
}
|
||||
|
||||
export function isSafeBinUsage(params: {
|
||||
argv: string[];
|
||||
resolution: CommandResolution | null;
|
||||
safeBins: Set<string>;
|
||||
cwd?: string;
|
||||
fileExists?: (filePath: string) => boolean;
|
||||
}): boolean {
|
||||
if (params.safeBins.size === 0) return false;
|
||||
const resolution = params.resolution;
|
||||
const execName = resolution?.executableName?.toLowerCase();
|
||||
if (!execName) return false;
|
||||
const matchesSafeBin =
|
||||
params.safeBins.has(execName) ||
|
||||
(process.platform === "win32" && params.safeBins.has(path.parse(execName).name));
|
||||
if (!matchesSafeBin) return false;
|
||||
if (!resolution?.resolvedPath) return false;
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
const exists = params.fileExists ?? defaultFileExists;
|
||||
const argv = params.argv.slice(1);
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i];
|
||||
if (!token) continue;
|
||||
if (token === "-") continue;
|
||||
if (token.startsWith("-")) {
|
||||
const eqIndex = token.indexOf("=");
|
||||
if (eqIndex > 0) {
|
||||
const value = token.slice(eqIndex + 1);
|
||||
if (value && (isPathLikeToken(value) || exists(path.resolve(cwd, value)))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (isPathLikeToken(token)) return false;
|
||||
if (exists(path.resolve(cwd, token))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function recordAllowlistUse(
|
||||
approvals: ExecApprovalsFile,
|
||||
agentId: string | undefined,
|
||||
|
||||
@@ -1,55 +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 { acquireGatewayLock, GatewayLockError } from "./gateway-lock.js";
|
||||
|
||||
async function makeEnv() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gateway-lock-"));
|
||||
const configPath = path.join(dir, "clawdbot.json");
|
||||
await fs.writeFile(configPath, "{}", "utf8");
|
||||
return {
|
||||
env: {
|
||||
...process.env,
|
||||
CLAWDBOT_STATE_DIR: dir,
|
||||
CLAWDBOT_CONFIG_PATH: configPath,
|
||||
},
|
||||
cleanup: async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("gateway lock", () => {
|
||||
it("blocks concurrent acquisition until release", async () => {
|
||||
const { env, cleanup } = await makeEnv();
|
||||
const lock = await acquireGatewayLock({
|
||||
env,
|
||||
allowInTests: true,
|
||||
timeoutMs: 200,
|
||||
pollIntervalMs: 20,
|
||||
});
|
||||
expect(lock).not.toBeNull();
|
||||
|
||||
await expect(
|
||||
acquireGatewayLock({
|
||||
env,
|
||||
allowInTests: true,
|
||||
timeoutMs: 200,
|
||||
pollIntervalMs: 20,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(GatewayLockError);
|
||||
|
||||
await lock?.release();
|
||||
const lock2 = await acquireGatewayLock({
|
||||
env,
|
||||
allowInTests: true,
|
||||
timeoutMs: 200,
|
||||
pollIntervalMs: 20,
|
||||
});
|
||||
await lock2?.release();
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,3 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 100;
|
||||
const DEFAULT_STALE_MS = 30_000;
|
||||
|
||||
type LockPayload = {
|
||||
pid: number;
|
||||
createdAt: string;
|
||||
configPath: string;
|
||||
};
|
||||
|
||||
export type GatewayLockHandle = {
|
||||
lockPath: string;
|
||||
configPath: string;
|
||||
release: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type GatewayLockOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
staleMs?: number;
|
||||
allowInTests?: boolean;
|
||||
};
|
||||
|
||||
export class GatewayLockError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -37,117 +7,3 @@ export class GatewayLockError extends Error {
|
||||
this.name = "GatewayLockError";
|
||||
}
|
||||
}
|
||||
|
||||
function isAlive(pid: number): boolean {
|
||||
if (!Number.isFinite(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readLockPayload(lockPath: string): Promise<LockPayload | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(lockPath, "utf8");
|
||||
const parsed = JSON.parse(raw) as Partial<LockPayload>;
|
||||
if (typeof parsed.pid !== "number") return null;
|
||||
if (typeof parsed.createdAt !== "string") return null;
|
||||
if (typeof parsed.configPath !== "string") return null;
|
||||
return {
|
||||
pid: parsed.pid,
|
||||
createdAt: parsed.createdAt,
|
||||
configPath: parsed.configPath,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGatewayLockPath(env: NodeJS.ProcessEnv) {
|
||||
const stateDir = resolveStateDir(env);
|
||||
const configPath = resolveConfigPath(env, stateDir);
|
||||
const hash = createHash("sha1").update(configPath).digest("hex").slice(0, 8);
|
||||
const lockPath = path.join(stateDir, `gateway.${hash}.lock`);
|
||||
return { lockPath, configPath };
|
||||
}
|
||||
|
||||
export async function acquireGatewayLock(
|
||||
opts: GatewayLockOptions = {},
|
||||
): Promise<GatewayLockHandle | null> {
|
||||
const env = opts.env ?? process.env;
|
||||
const allowInTests = opts.allowInTests === true;
|
||||
if (
|
||||
env.CLAWDBOT_ALLOW_MULTI_GATEWAY === "1" ||
|
||||
(!allowInTests && (env.VITEST || env.NODE_ENV === "test"))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
||||
const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
|
||||
const { lockPath, configPath } = resolveGatewayLockPath(env);
|
||||
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
||||
|
||||
const startedAt = Date.now();
|
||||
let lastPayload: LockPayload | null = null;
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const handle = await fs.open(lockPath, "wx");
|
||||
const payload: LockPayload = {
|
||||
pid: process.pid,
|
||||
createdAt: new Date().toISOString(),
|
||||
configPath,
|
||||
};
|
||||
await handle.writeFile(JSON.stringify(payload), "utf8");
|
||||
return {
|
||||
lockPath,
|
||||
configPath,
|
||||
release: async () => {
|
||||
await handle.close().catch(() => undefined);
|
||||
await fs.rm(lockPath, { force: true });
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
const code = (err as { code?: unknown }).code;
|
||||
if (code !== "EEXIST") {
|
||||
throw new GatewayLockError(`failed to acquire gateway lock at ${lockPath}`, err);
|
||||
}
|
||||
|
||||
lastPayload = await readLockPayload(lockPath);
|
||||
const ownerPid = lastPayload?.pid;
|
||||
const ownerAlive = ownerPid ? isAlive(ownerPid) : false;
|
||||
if (!ownerAlive && ownerPid) {
|
||||
await fs.rm(lockPath, { force: true });
|
||||
continue;
|
||||
}
|
||||
if (!ownerAlive) {
|
||||
let stale = false;
|
||||
if (lastPayload?.createdAt) {
|
||||
const createdAt = Date.parse(lastPayload.createdAt);
|
||||
stale = Number.isFinite(createdAt) ? Date.now() - createdAt > staleMs : false;
|
||||
}
|
||||
if (!stale) {
|
||||
try {
|
||||
const st = await fs.stat(lockPath);
|
||||
stale = Date.now() - st.mtimeMs > staleMs;
|
||||
} catch {
|
||||
stale = true;
|
||||
}
|
||||
}
|
||||
if (stale) {
|
||||
await fs.rm(lockPath, { force: true });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
const owner = lastPayload?.pid ? ` (pid ${lastPayload.pid})` : "";
|
||||
throw new GatewayLockError(`gateway already running${owner}; lock timeout after ${timeoutMs}ms`);
|
||||
}
|
||||
@@ -41,6 +41,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "whatsapp",
|
||||
to: "+1555",
|
||||
ackMaxChars: 0,
|
||||
},
|
||||
},
|
||||
@@ -57,7 +58,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
@@ -102,6 +102,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "whatsapp",
|
||||
to: "+1555",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -117,7 +118,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
@@ -164,6 +164,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "whatsapp",
|
||||
to: "+1555",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -179,7 +180,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: originalUpdatedAt,
|
||||
lastChannel: "whatsapp",
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
@@ -231,7 +231,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "whatsapp" },
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
@@ -246,7 +246,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
@@ -292,7 +291,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "telegram" },
|
||||
heartbeat: { every: "5m", target: "telegram", to: "123456" },
|
||||
},
|
||||
},
|
||||
channels: { telegram: { botToken: "test-bot-token-123" } },
|
||||
@@ -307,7 +306,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "telegram",
|
||||
lastProvider: "telegram",
|
||||
lastTo: "123456",
|
||||
},
|
||||
@@ -359,7 +357,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "telegram" },
|
||||
heartbeat: { every: "5m", target: "telegram", to: "123456" },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
@@ -380,7 +378,6 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "telegram",
|
||||
lastProvider: "telegram",
|
||||
lastTo: "123456",
|
||||
},
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
resolveMainSessionKey,
|
||||
resolveStorePath,
|
||||
} from "../config/sessions.js";
|
||||
import { buildAgentPeerSessionKey } from "../routing/session-key.js";
|
||||
import {
|
||||
isHeartbeatEnabledForAgent,
|
||||
resolveHeartbeatIntervalMs,
|
||||
@@ -333,7 +332,7 @@ describe("runHeartbeatOnce", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "whatsapp" },
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
@@ -396,7 +395,7 @@ describe("runHeartbeatOnce", () => {
|
||||
{ id: "main", default: true },
|
||||
{
|
||||
id: "ops",
|
||||
heartbeat: { every: "5m", target: "whatsapp", prompt: "Ops check" },
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555", prompt: "Ops check" },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -452,88 +451,6 @@ describe("runHeartbeatOnce", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("runs heartbeats in the explicit session key when configured", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
const groupId = "120363401234567890@g.us";
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "last",
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
const mainSessionKey = resolveMainSessionKey(cfg);
|
||||
const agentId = resolveAgentIdFromSessionKey(mainSessionKey);
|
||||
const groupSessionKey = buildAgentPeerSessionKey({
|
||||
agentId,
|
||||
channel: "whatsapp",
|
||||
peerKind: "group",
|
||||
peerId: groupId,
|
||||
});
|
||||
if (cfg.agents?.defaults?.heartbeat) {
|
||||
cfg.agents.defaults.heartbeat.session = groupSessionKey;
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[mainSessionKey]: {
|
||||
sessionId: "sid-main",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
[groupSessionKey]: {
|
||||
sessionId: "sid-group",
|
||||
updatedAt: Date.now() + 10_000,
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: groupId,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue([{ text: "Group alert" }]);
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
webAuthExists: async () => true,
|
||||
hasActiveWebListener: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||
expect(sendWhatsApp).toHaveBeenCalledWith(groupId, "Group alert", expect.any(Object));
|
||||
expect(replySpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ SessionKey: groupSessionKey }),
|
||||
{ isHeartbeat: true },
|
||||
cfg,
|
||||
);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("suppresses duplicate heartbeat payloads within 24h", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
@@ -542,7 +459,7 @@ describe("runHeartbeatOnce", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: { every: "5m", target: "whatsapp" },
|
||||
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
@@ -600,6 +517,7 @@ describe("runHeartbeatOnce", () => {
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "whatsapp",
|
||||
to: "+1555",
|
||||
includeReasoning: true,
|
||||
},
|
||||
},
|
||||
@@ -616,7 +534,6 @@ describe("runHeartbeatOnce", () => {
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
@@ -671,6 +588,7 @@ describe("runHeartbeatOnce", () => {
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "whatsapp",
|
||||
to: "+1555",
|
||||
includeReasoning: true,
|
||||
},
|
||||
},
|
||||
@@ -687,7 +605,6 @@ describe("runHeartbeatOnce", () => {
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
@@ -755,7 +672,6 @@ describe("runHeartbeatOnce", () => {
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastProvider: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
|
||||
@@ -15,9 +15,7 @@ import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import {
|
||||
canonicalizeMainSessionAlias,
|
||||
loadSessionStore,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentMainSessionKey,
|
||||
resolveStorePath,
|
||||
saveSessionStore,
|
||||
@@ -29,7 +27,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { getQueueSize } from "../process/command-queue.js";
|
||||
import { CommandLane } from "../process/lanes.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import { normalizeAgentId, toAgentStoreSessionKey } from "../routing/session-key.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { emitHeartbeatEvent } from "./heartbeat-events.js";
|
||||
import {
|
||||
type HeartbeatRunResult,
|
||||
@@ -91,7 +89,7 @@ function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function parseActiveHoursTime(opts: { allow24: boolean }, raw?: string): number | null {
|
||||
function parseActiveHoursTime(raw: string | undefined, opts: { allow24: boolean }): number | null {
|
||||
if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) return null;
|
||||
const [hourStr, minuteStr] = raw.split(":");
|
||||
const hour = Number(hourStr);
|
||||
@@ -133,8 +131,8 @@ function isWithinActiveHours(
|
||||
const active = heartbeat?.activeHours;
|
||||
if (!active) return true;
|
||||
|
||||
const startMin = parseActiveHoursTime({ allow24: false }, active.start);
|
||||
const endMin = parseActiveHoursTime({ allow24: true }, active.end);
|
||||
const startMin = parseActiveHoursTime(active.start, { allow24: false });
|
||||
const endMin = parseActiveHoursTime(active.end, { allow24: true });
|
||||
if (startMin === null || endMin === null) return true;
|
||||
if (startMin === endMin) return true;
|
||||
|
||||
@@ -288,53 +286,17 @@ function resolveHeartbeatAckMaxChars(cfg: ClawdbotConfig, heartbeat?: HeartbeatC
|
||||
);
|
||||
}
|
||||
|
||||
function resolveHeartbeatSession(
|
||||
cfg: ClawdbotConfig,
|
||||
agentId?: string,
|
||||
heartbeat?: HeartbeatConfig,
|
||||
) {
|
||||
function resolveHeartbeatSession(cfg: ClawdbotConfig, agentId?: string) {
|
||||
const sessionCfg = cfg.session;
|
||||
const scope = sessionCfg?.scope ?? "per-sender";
|
||||
const resolvedAgentId = normalizeAgentId(agentId ?? resolveDefaultAgentId(cfg));
|
||||
const mainSessionKey =
|
||||
const sessionKey =
|
||||
scope === "global" ? "global" : resolveAgentMainSessionKey({ cfg, agentId: resolvedAgentId });
|
||||
const storeAgentId = scope === "global" ? resolveDefaultAgentId(cfg) : resolvedAgentId;
|
||||
const storePath = resolveStorePath(sessionCfg?.store, { agentId: storeAgentId });
|
||||
const store = loadSessionStore(storePath);
|
||||
const mainEntry = store[mainSessionKey];
|
||||
|
||||
if (scope === "global") {
|
||||
return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
|
||||
}
|
||||
|
||||
const trimmed = heartbeat?.session?.trim() ?? "";
|
||||
if (!trimmed) {
|
||||
return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
|
||||
}
|
||||
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (normalized === "main" || normalized === "global") {
|
||||
return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
|
||||
}
|
||||
|
||||
const candidate = toAgentStoreSessionKey({
|
||||
agentId: resolvedAgentId,
|
||||
requestKey: trimmed,
|
||||
mainKey: cfg.session?.mainKey,
|
||||
});
|
||||
const canonical = canonicalizeMainSessionAlias({
|
||||
cfg,
|
||||
agentId: resolvedAgentId,
|
||||
sessionKey: candidate,
|
||||
});
|
||||
if (canonical !== "global") {
|
||||
const sessionAgentId = resolveAgentIdFromSessionKey(canonical);
|
||||
if (sessionAgentId === normalizeAgentId(resolvedAgentId)) {
|
||||
return { sessionKey: canonical, storePath, store, entry: store[canonical] };
|
||||
}
|
||||
}
|
||||
|
||||
return { sessionKey: mainSessionKey, storePath, store, entry: mainEntry };
|
||||
const entry = store[sessionKey];
|
||||
return { sessionKey, storePath, store, entry };
|
||||
}
|
||||
|
||||
function resolveHeartbeatReplyPayload(
|
||||
@@ -465,7 +427,7 @@ export async function runHeartbeatOnce(opts: {
|
||||
return { status: "skipped", reason: "requests-in-flight" };
|
||||
}
|
||||
|
||||
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
|
||||
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId);
|
||||
const previousUpdatedAt = entry?.updatedAt;
|
||||
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
||||
const lastChannel = delivery.lastChannel;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import process from "node:process";
|
||||
import type { GatewayLockHandle } from "../infra/gateway-lock.js";
|
||||
|
||||
declare const __CLAWDBOT_VERSION__: string;
|
||||
|
||||
@@ -46,7 +45,6 @@ async function main() {
|
||||
{ startGatewayServer },
|
||||
{ setGatewayWsLogStyle },
|
||||
{ setVerbose },
|
||||
{ acquireGatewayLock, GatewayLockError },
|
||||
{ consumeGatewaySigusr1RestartAuthorization, isGatewaySigusr1RestartExternallyAllowed },
|
||||
{ defaultRuntime },
|
||||
{ enableConsoleCapture, setConsoleTimestampPrefix },
|
||||
@@ -55,11 +53,10 @@ async function main() {
|
||||
import("../gateway/server.js"),
|
||||
import("../gateway/ws-logging.js"),
|
||||
import("../globals.js"),
|
||||
import("../infra/gateway-lock.js"),
|
||||
import("../infra/restart.js"),
|
||||
import("../runtime.js"),
|
||||
import("../logging.js"),
|
||||
] as const);
|
||||
]);
|
||||
|
||||
enableConsoleCapture();
|
||||
setConsoleTimestampPrefix(true);
|
||||
@@ -106,7 +103,6 @@ async function main() {
|
||||
if (token) process.env.CLAWDBOT_GATEWAY_TOKEN = token;
|
||||
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
|
||||
let lock: GatewayLockHandle | null = null;
|
||||
let shuttingDown = false;
|
||||
let forceExitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let restartResolver: (() => void) | null = null;
|
||||
@@ -181,15 +177,6 @@ async function main() {
|
||||
process.on("SIGUSR1", onSigusr1);
|
||||
|
||||
try {
|
||||
try {
|
||||
lock = await acquireGatewayLock();
|
||||
} catch (err) {
|
||||
if (err instanceof GatewayLockError) {
|
||||
defaultRuntime.error(`Gateway start blocked: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
@@ -204,7 +191,6 @@ async function main() {
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await (lock as GatewayLockHandle | null)?.release();
|
||||
cleanupSignals();
|
||||
}
|
||||
}
|
||||
|
||||
+25
-68
@@ -4,16 +4,12 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
type ExecAllowlistEntry,
|
||||
addAllowlistEntry,
|
||||
analyzeArgvCommand,
|
||||
analyzeShellCommand,
|
||||
isSafeBinUsage,
|
||||
matchAllowlist,
|
||||
normalizeExecApprovals,
|
||||
recordAllowlistUse,
|
||||
resolveCommandResolution,
|
||||
resolveExecApprovals,
|
||||
resolveSafeBins,
|
||||
ensureExecApprovals,
|
||||
readExecApprovalsSnapshot,
|
||||
resolveExecApprovalsSocketPath,
|
||||
@@ -29,7 +25,6 @@ import {
|
||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolveAgentConfig } from "../agents/agent-scope.js";
|
||||
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
@@ -57,7 +52,6 @@ type SystemRunParams = {
|
||||
sessionKey?: string | null;
|
||||
approved?: boolean | null;
|
||||
approvalDecision?: string | null;
|
||||
runId?: string | null;
|
||||
};
|
||||
|
||||
type SystemWhichParams = {
|
||||
@@ -116,6 +110,7 @@ const execHostFallbackAllowed =
|
||||
process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0";
|
||||
|
||||
const blockedEnvKeys = new Set([
|
||||
"PATH",
|
||||
"NODE_OPTIONS",
|
||||
"PYTHONHOME",
|
||||
"PYTHONPATH",
|
||||
@@ -161,24 +156,10 @@ function sanitizeEnv(
|
||||
): Record<string, string> | undefined {
|
||||
if (!overrides) return undefined;
|
||||
const merged = { ...process.env } as Record<string, string>;
|
||||
const basePath = process.env.PATH ?? DEFAULT_NODE_PATH;
|
||||
for (const [rawKey, value] of Object.entries(overrides)) {
|
||||
const key = rawKey.trim();
|
||||
if (!key) continue;
|
||||
const upper = key.toUpperCase();
|
||||
if (upper === "PATH") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) continue;
|
||||
if (!basePath || trimmed === basePath) {
|
||||
merged[key] = trimmed;
|
||||
continue;
|
||||
}
|
||||
const suffix = `${path.delimiter}${basePath}`;
|
||||
if (trimmed.endsWith(suffix)) {
|
||||
merged[key] = trimmed;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (blockedEnvKeys.has(upper)) continue;
|
||||
if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) continue;
|
||||
merged[key] = value;
|
||||
@@ -584,34 +565,14 @@ async function handleInvoke(
|
||||
const ask = approvals.agent.ask;
|
||||
const autoAllowSkills = approvals.agent.autoAllowSkills;
|
||||
const sessionKey = params.sessionKey?.trim() || "node";
|
||||
const runId = params.runId?.trim() || crypto.randomUUID();
|
||||
const runId = crypto.randomUUID();
|
||||
const env = sanitizeEnv(params.env ?? undefined);
|
||||
const analysis = rawCommand
|
||||
? analyzeShellCommand({ command: rawCommand, cwd: params.cwd ?? undefined, env })
|
||||
: analyzeArgvCommand({ argv, cwd: params.cwd ?? undefined, env });
|
||||
const cfg = loadConfig();
|
||||
const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined;
|
||||
const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins);
|
||||
const allowlistMatches: ExecAllowlistEntry[] = [];
|
||||
const resolution = resolveCommandResolution(cmdText, params.cwd ?? undefined, env);
|
||||
const allowlistMatch =
|
||||
security === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
|
||||
const bins = autoAllowSkills ? await skillBins.current() : new Set<string>();
|
||||
const allowlistSatisfied =
|
||||
security === "allowlist" && analysis.ok && analysis.segments.length > 0
|
||||
? analysis.segments.every((segment) => {
|
||||
const match = matchAllowlist(approvals.allowlist, segment.resolution);
|
||||
if (match) allowlistMatches.push(match);
|
||||
const safe = isSafeBinUsage({
|
||||
argv: segment.argv,
|
||||
resolution: segment.resolution,
|
||||
safeBins,
|
||||
cwd: params.cwd ?? undefined,
|
||||
});
|
||||
const skillAllow =
|
||||
autoAllowSkills && segment.resolution?.executableName
|
||||
? bins.has(segment.resolution.executableName)
|
||||
: false;
|
||||
return Boolean(match || safe || skillAllow);
|
||||
})
|
||||
: false;
|
||||
const skillAllow =
|
||||
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
|
||||
|
||||
const useMacAppExec = process.platform === "darwin";
|
||||
if (useMacAppExec) {
|
||||
@@ -717,7 +678,7 @@ async function handleInvoke(
|
||||
|
||||
const requiresAsk =
|
||||
ask === "always" ||
|
||||
(ask === "on-miss" && security === "allowlist" && (!analysis.ok || !allowlistSatisfied));
|
||||
(ask === "on-miss" && security === "allowlist" && !allowlistMatch && !skillAllow);
|
||||
|
||||
const approvalDecision =
|
||||
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
|
||||
@@ -743,15 +704,11 @@ async function handleInvoke(
|
||||
return;
|
||||
}
|
||||
if (approvalDecision === "allow-always" && security === "allowlist") {
|
||||
if (analysis.ok) {
|
||||
for (const segment of analysis.segments) {
|
||||
const pattern = segment.resolution?.resolvedPath ?? "";
|
||||
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
|
||||
}
|
||||
}
|
||||
const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? "";
|
||||
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
|
||||
}
|
||||
|
||||
if (security === "allowlist" && (!analysis.ok || !allowlistSatisfied) && !approvedByAsk) {
|
||||
if (security === "allowlist" && !allowlistMatch && !skillAllow && !approvedByAsk) {
|
||||
await sendNodeEvent(
|
||||
client,
|
||||
"exec.denied",
|
||||
@@ -770,19 +727,8 @@ async function handleInvoke(
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowlistMatches.length > 0) {
|
||||
const seen = new Set<string>();
|
||||
for (const match of allowlistMatches) {
|
||||
if (!match?.pattern || seen.has(match.pattern)) continue;
|
||||
seen.add(match.pattern);
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
agentId,
|
||||
match,
|
||||
cmdText,
|
||||
analysis.segments[0]?.resolution?.resolvedPath,
|
||||
);
|
||||
}
|
||||
if (allowlistMatch) {
|
||||
recordAllowlistUse(approvals.file, agentId, allowlistMatch, cmdText, resolution?.resolvedPath);
|
||||
}
|
||||
|
||||
if (params.needsScreenRecording === true) {
|
||||
@@ -804,6 +750,17 @@ async function handleInvoke(
|
||||
return;
|
||||
}
|
||||
|
||||
await sendNodeEvent(
|
||||
client,
|
||||
"exec.started",
|
||||
buildExecEventPayload({
|
||||
sessionKey,
|
||||
runId,
|
||||
host: "node",
|
||||
command: cmdText,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runCommand(
|
||||
argv,
|
||||
params.cwd?.trim() || undefined,
|
||||
|
||||
@@ -227,29 +227,6 @@ describe("security audit", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("warns when control UI allows insecure auth", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
controlUi: { allowInsecureAuth: true },
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "gateway.control_ui.insecure_auth",
|
||||
severity: "warn",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when multiple DM senders share the main session", async () => {
|
||||
const cfg: ClawdbotConfig = { session: { dmScope: "main" } };
|
||||
const plugins: ChannelPlugin[] = [
|
||||
|
||||
@@ -235,17 +235,6 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding
|
||||
});
|
||||
}
|
||||
|
||||
if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
|
||||
findings.push({
|
||||
checkId: "gateway.control_ui.insecure_auth",
|
||||
severity: "warn",
|
||||
title: "Control UI allows insecure HTTP auth",
|
||||
detail:
|
||||
"gateway.controlUi.allowInsecureAuth=true allows token-only auth over HTTP and skips device identity.",
|
||||
remediation: "Disable it or switch to HTTPS (Tailscale Serve) or localhost.",
|
||||
});
|
||||
}
|
||||
|
||||
const token =
|
||||
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
|
||||
if (auth.mode === "token" && token && token.length < 24) {
|
||||
|
||||
+29
-55
@@ -126,27 +126,15 @@ export class GatewayBrowserClient {
|
||||
window.clearTimeout(this.connectTimer);
|
||||
this.connectTimer = null;
|
||||
}
|
||||
|
||||
// crypto.subtle is only available in secure contexts (HTTPS, localhost).
|
||||
// Over plain HTTP, we skip device identity and fall back to token-only auth.
|
||||
// Gateways may reject this unless gateway.controlUi.allowInsecureAuth is enabled.
|
||||
const isSecureContext = typeof crypto !== "undefined" && !!crypto.subtle;
|
||||
|
||||
const deviceIdentity = await loadOrCreateDeviceIdentity();
|
||||
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
|
||||
const role = "operator";
|
||||
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
|
||||
let canFallbackToShared = false;
|
||||
let authToken = this.opts.token;
|
||||
|
||||
if (isSecureContext) {
|
||||
deviceIdentity = await loadOrCreateDeviceIdentity();
|
||||
const storedToken = loadDeviceAuthToken({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
role,
|
||||
})?.token;
|
||||
authToken = storedToken ?? this.opts.token;
|
||||
canFallbackToShared = Boolean(storedToken && this.opts.token);
|
||||
}
|
||||
const storedToken = loadDeviceAuthToken({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
role,
|
||||
})?.token;
|
||||
const authToken = storedToken ?? this.opts.token;
|
||||
const canFallbackToShared = Boolean(storedToken && this.opts.token);
|
||||
const auth =
|
||||
authToken || this.opts.password
|
||||
? {
|
||||
@@ -154,39 +142,19 @@ export class GatewayBrowserClient {
|
||||
password: this.opts.password,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let device:
|
||||
| {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
signature: string;
|
||||
signedAt: number;
|
||||
nonce: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (isSecureContext && deviceIdentity) {
|
||||
const signedAtMs = Date.now();
|
||||
const nonce = this.connectNonce ?? undefined;
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: authToken ?? null,
|
||||
nonce,
|
||||
});
|
||||
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
|
||||
device = {
|
||||
id: deviceIdentity.deviceId,
|
||||
publicKey: deviceIdentity.publicKey,
|
||||
signature,
|
||||
signedAt: signedAtMs,
|
||||
nonce,
|
||||
};
|
||||
}
|
||||
const signedAtMs = Date.now();
|
||||
const nonce = this.connectNonce ?? undefined;
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: authToken ?? null,
|
||||
nonce,
|
||||
});
|
||||
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
|
||||
const params = {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
@@ -199,7 +167,13 @@ export class GatewayBrowserClient {
|
||||
},
|
||||
role,
|
||||
scopes,
|
||||
device,
|
||||
device: {
|
||||
id: deviceIdentity.deviceId,
|
||||
publicKey: deviceIdentity.publicKey,
|
||||
signature,
|
||||
signedAt: signedAtMs,
|
||||
nonce,
|
||||
},
|
||||
caps: [],
|
||||
auth,
|
||||
userAgent: navigator.userAgent,
|
||||
@@ -208,7 +182,7 @@ export class GatewayBrowserClient {
|
||||
|
||||
void this.request<GatewayHelloOk>("connect", params)
|
||||
.then((hello) => {
|
||||
if (hello?.auth?.deviceToken && deviceIdentity) {
|
||||
if (hello?.auth?.deviceToken) {
|
||||
storeDeviceAuthToken({
|
||||
deviceId: deviceIdentity.deviceId,
|
||||
role: hello.auth.role ?? role,
|
||||
@@ -220,7 +194,7 @@ export class GatewayBrowserClient {
|
||||
this.opts.onHello?.(hello);
|
||||
})
|
||||
.catch(() => {
|
||||
if (canFallbackToShared && deviceIdentity) {
|
||||
if (canFallbackToShared) {
|
||||
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
|
||||
}
|
||||
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
|
||||
|
||||
@@ -77,44 +77,6 @@ export function renderOverview(props: OverviewProps) {
|
||||
</div>
|
||||
`;
|
||||
})();
|
||||
const insecureContextHint = (() => {
|
||||
if (props.connected || !props.lastError) return null;
|
||||
const isSecureContext = typeof window !== "undefined" ? window.isSecureContext : true;
|
||||
if (isSecureContext !== false) return null;
|
||||
const lower = props.lastError.toLowerCase();
|
||||
if (!lower.includes("secure context") && !lower.includes("device identity required")) {
|
||||
return null;
|
||||
}
|
||||
return html`
|
||||
<div class="muted" style="margin-top: 8px;">
|
||||
This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or
|
||||
open <span class="mono">http://127.0.0.1:18789</span> on the gateway host.
|
||||
<div style="margin-top: 6px;">
|
||||
If you must stay on HTTP, set
|
||||
<span class="mono">gateway.controlUi.allowInsecureAuth: true</span> (token-only).
|
||||
</div>
|
||||
<div style="margin-top: 6px;">
|
||||
<a
|
||||
class="session-link"
|
||||
href="https://docs.clawd.bot/gateway/tailscale"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Tailscale Serve docs (opens in new tab)"
|
||||
>Docs: Tailscale Serve</a
|
||||
>
|
||||
<span class="muted"> · </span>
|
||||
<a
|
||||
class="session-link"
|
||||
href="https://docs.clawd.bot/web/control-ui#insecure-http"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Insecure HTTP docs (opens in new tab)"
|
||||
>Docs: Insecure HTTP</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})();
|
||||
|
||||
return html`
|
||||
<section class="grid grid-cols-2">
|
||||
@@ -205,7 +167,6 @@ export function renderOverview(props: OverviewProps) {
|
||||
? html`<div class="callout danger" style="margin-top: 14px;">
|
||||
<div>${props.lastError}</div>
|
||||
${authHint ?? ""}
|
||||
${insecureContextHint ?? ""}
|
||||
</div>`
|
||||
: html`<div class="callout" style="margin-top: 14px;">
|
||||
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
|
||||
|
||||
Reference in new issue
Block a user