Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4997a5b93f | ||
|
|
1092b30531 | ||
|
|
7d93de710e | ||
|
|
39b375e32b | ||
|
|
3b6ec501aa | ||
|
|
2b254a9b39 | ||
|
|
429a2d7849 | ||
|
|
1cce83b21e | ||
|
|
8255e4649c | ||
|
|
7eef176afc | ||
|
|
06e496540f | ||
|
|
f76e3c1419 | ||
|
|
b4776af38c | ||
|
|
cd65e8e755 | ||
|
|
28e547f120 | ||
|
|
05a254746e | ||
|
|
529372f762 | ||
|
|
3b18efdd25 | ||
|
|
6e044b5f2f | ||
|
|
310f916675 | ||
|
|
41d56c06b9 | ||
|
|
a90fe1b245 |
No files matched your search
@@ -29,6 +29,7 @@
|
|||||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||||
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
|
||||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
- 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)
|
- Type-check/build: `pnpm build` (tsc)
|
||||||
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
|
||||||
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
|
||||||
|
|||||||
+16
-1
@@ -2,32 +2,47 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
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
|
## 2026.1.21
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
- 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.
|
- CLI: exec approvals mutations render tables instead of raw JSON.
|
||||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
- 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.
|
- 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: 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.
|
- 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.
|
- 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
|
### 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)
|
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
||||||
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
- 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.
|
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
||||||
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
||||||
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
|
- 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.
|
- 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.
|
- 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: 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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)
|
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
|
||||||
|
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
|
||||||
|
|
||||||
## 2026.1.20
|
## 2026.1.20
|
||||||
|
|
||||||
|
|||||||
@@ -554,6 +554,30 @@ 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 {
|
enum ExecAllowlistMatcher {
|
||||||
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
|
||||||
guard let resolution, !entries.isEmpty else { return nil }
|
guard let resolution, !entries.isEmpty else { return nil }
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ private enum ExecHostExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var approvedByAsk = approvalDecision != nil
|
var approvedByAsk = approvalDecision != nil
|
||||||
if self.requiresAsk(
|
if ExecApprovalHelpers.requiresAsk(
|
||||||
ask: context.ask,
|
ask: context.ask,
|
||||||
security: context.security,
|
security: context.security,
|
||||||
allowlistMatch: context.allowlistMatch,
|
allowlistMatch: context.allowlistMatch,
|
||||||
@@ -417,36 +417,20 @@ private enum ExecHostExecutor {
|
|||||||
skillAllow: skillAllow)
|
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(
|
private static func persistAllowlistEntry(
|
||||||
decision: ExecApprovalDecision?,
|
decision: ExecApprovalDecision?,
|
||||||
context: ExecApprovalContext)
|
context: ExecApprovalContext)
|
||||||
{
|
{
|
||||||
guard decision == .allowAlways, context.security == .allowlist else { return }
|
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||||
guard let pattern = self.allowlistPattern(command: context.command, resolution: context.resolution) else {
|
guard let pattern = ExecApprovalHelpers.allowlistPattern(
|
||||||
|
command: context.command,
|
||||||
|
resolution: context.resolution)
|
||||||
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
|
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? {
|
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
|
||||||
guard needsScreenRecording == true else { return nil }
|
guard needsScreenRecording == true else { return nil }
|
||||||
let authorized = await PermissionManager
|
let authorized = await PermissionManager
|
||||||
|
|||||||
@@ -480,14 +480,16 @@ actor MacNodeRuntime {
|
|||||||
message: "SYSTEM_RUN_DISABLED: security=deny")
|
message: "SYSTEM_RUN_DISABLED: security=deny")
|
||||||
}
|
}
|
||||||
|
|
||||||
let requiresAsk: Bool = {
|
let requiresAsk = ExecApprovalHelpers.requiresAsk(
|
||||||
if ask == .always { return true }
|
ask: ask,
|
||||||
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
|
security: security,
|
||||||
return false
|
allowlistMatch: allowlistMatch,
|
||||||
}()
|
skillAllow: skillAllow)
|
||||||
|
|
||||||
let approvedByAsk = params.approved == true
|
let decisionFromParams = ExecApprovalHelpers.parseDecision(params.approvalDecision)
|
||||||
if requiresAsk, !approvedByAsk {
|
var approvedByAsk = params.approved == true || decisionFromParams != nil
|
||||||
|
var persistAllowlist = decisionFromParams == .allowAlways
|
||||||
|
if decisionFromParams == .deny {
|
||||||
await self.emitExecEvent(
|
await self.emitExecEvent(
|
||||||
"exec.denied",
|
"exec.denied",
|
||||||
payload: ExecEventPayload(
|
payload: ExecEventPayload(
|
||||||
@@ -495,11 +497,49 @@ actor MacNodeRuntime {
|
|||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: displayCommand,
|
command: displayCommand,
|
||||||
reason: "approval-required"))
|
reason: "user-denied"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
code: .unavailable,
|
code: .unavailable,
|
||||||
message: "SYSTEM_RUN_DENIED: approval required")
|
message: "SYSTEM_RUN_DENIED: user denied")
|
||||||
|
}
|
||||||
|
if requiresAsk, !approvedByAsk {
|
||||||
|
let decision = await MainActor.run {
|
||||||
|
ExecApprovalsPromptPresenter.prompt(
|
||||||
|
ExecApprovalPromptRequest(
|
||||||
|
command: displayCommand,
|
||||||
|
cwd: params.cwd,
|
||||||
|
host: "node",
|
||||||
|
security: security.rawValue,
|
||||||
|
ask: ask.rawValue,
|
||||||
|
agentId: agentId,
|
||||||
|
resolvedPath: resolution?.resolvedPath))
|
||||||
|
}
|
||||||
|
switch decision {
|
||||||
|
case .deny:
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: displayCommand,
|
||||||
|
reason: "user-denied"))
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .unavailable,
|
||||||
|
message: "SYSTEM_RUN_DENIED: user denied")
|
||||||
|
case .allowAlways:
|
||||||
|
approvedByAsk = true
|
||||||
|
persistAllowlist = true
|
||||||
|
case .allowOnce:
|
||||||
|
approvedByAsk = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if persistAllowlist, security == .allowlist,
|
||||||
|
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution)
|
||||||
|
{
|
||||||
|
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
if security == .allowlist, allowlistMatch == nil, !skillAllow, !approvedByAsk {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
public let commands: [String]?
|
public let commands: [String]?
|
||||||
public let permissions: [String: AnyCodable]?
|
public let permissions: [String: AnyCodable]?
|
||||||
|
public let pathenv: String?
|
||||||
public let role: String?
|
public let role: String?
|
||||||
public let scopes: [String]?
|
public let scopes: [String]?
|
||||||
public let device: [String: AnyCodable]?
|
public let device: [String: AnyCodable]?
|
||||||
@@ -32,6 +33,7 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
caps: [String]?,
|
caps: [String]?,
|
||||||
commands: [String]?,
|
commands: [String]?,
|
||||||
permissions: [String: AnyCodable]?,
|
permissions: [String: AnyCodable]?,
|
||||||
|
pathenv: String?,
|
||||||
role: String?,
|
role: String?,
|
||||||
scopes: [String]?,
|
scopes: [String]?,
|
||||||
device: [String: AnyCodable]?,
|
device: [String: AnyCodable]?,
|
||||||
@@ -45,6 +47,7 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
self.caps = caps
|
self.caps = caps
|
||||||
self.commands = commands
|
self.commands = commands
|
||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
|
self.pathenv = pathenv
|
||||||
self.role = role
|
self.role = role
|
||||||
self.scopes = scopes
|
self.scopes = scopes
|
||||||
self.device = device
|
self.device = device
|
||||||
@@ -59,6 +62,7 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
case caps
|
case caps
|
||||||
case commands
|
case commands
|
||||||
case permissions
|
case permissions
|
||||||
|
case pathenv = "pathEnv"
|
||||||
case role
|
case role
|
||||||
case scopes
|
case scopes
|
||||||
case device
|
case device
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -571,7 +571,14 @@ public actor GatewayChannelActor {
|
|||||||
id: id,
|
id: id,
|
||||||
method: method,
|
method: method,
|
||||||
params: paramsObject)
|
params: paramsObject)
|
||||||
let data = try self.encoder.encode(frame)
|
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 response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
|
||||||
self.pending[id] = cont
|
self.pending[id] = cont
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
|
|||||||
@@ -219,8 +219,8 @@ public actor GatewayNodeSession {
|
|||||||
}
|
}
|
||||||
if let error = response.error {
|
if let error = response.error {
|
||||||
params["error"] = AnyCodable([
|
params["error"] = AnyCodable([
|
||||||
"code": AnyCodable(error.code.rawValue),
|
"code": error.code.rawValue,
|
||||||
"message": AnyCodable(error.message),
|
"message": error.message,
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
public var agentId: String?
|
public var agentId: String?
|
||||||
public var sessionKey: String?
|
public var sessionKey: String?
|
||||||
public var approved: Bool?
|
public var approved: Bool?
|
||||||
|
public var approvalDecision: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
command: [String],
|
command: [String],
|
||||||
@@ -40,7 +41,8 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
needsScreenRecording: Bool? = nil,
|
needsScreenRecording: Bool? = nil,
|
||||||
agentId: String? = nil,
|
agentId: String? = nil,
|
||||||
sessionKey: String? = nil,
|
sessionKey: String? = nil,
|
||||||
approved: Bool? = nil)
|
approved: Bool? = nil,
|
||||||
|
approvalDecision: String? = nil)
|
||||||
{
|
{
|
||||||
self.command = command
|
self.command = command
|
||||||
self.rawCommand = rawCommand
|
self.rawCommand = rawCommand
|
||||||
@@ -51,6 +53,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
self.agentId = agentId
|
self.agentId = agentId
|
||||||
self.sessionKey = sessionKey
|
self.sessionKey = sessionKey
|
||||||
self.approved = approved
|
self.approved = approved
|
||||||
|
self.approvalDecision = approvalDecision
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
public let commands: [String]?
|
public let commands: [String]?
|
||||||
public let permissions: [String: AnyCodable]?
|
public let permissions: [String: AnyCodable]?
|
||||||
|
public let pathenv: String?
|
||||||
public let role: String?
|
public let role: String?
|
||||||
public let scopes: [String]?
|
public let scopes: [String]?
|
||||||
public let device: [String: AnyCodable]?
|
public let device: [String: AnyCodable]?
|
||||||
@@ -32,6 +33,7 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
caps: [String]?,
|
caps: [String]?,
|
||||||
commands: [String]?,
|
commands: [String]?,
|
||||||
permissions: [String: AnyCodable]?,
|
permissions: [String: AnyCodable]?,
|
||||||
|
pathenv: String?,
|
||||||
role: String?,
|
role: String?,
|
||||||
scopes: [String]?,
|
scopes: [String]?,
|
||||||
device: [String: AnyCodable]?,
|
device: [String: AnyCodable]?,
|
||||||
@@ -45,6 +47,7 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
self.caps = caps
|
self.caps = caps
|
||||||
self.commands = commands
|
self.commands = commands
|
||||||
self.permissions = permissions
|
self.permissions = permissions
|
||||||
|
self.pathenv = pathenv
|
||||||
self.role = role
|
self.role = role
|
||||||
self.scopes = scopes
|
self.scopes = scopes
|
||||||
self.device = device
|
self.device = device
|
||||||
@@ -59,6 +62,7 @@ public struct ConnectParams: Codable, Sendable {
|
|||||||
case caps
|
case caps
|
||||||
case commands
|
case commands
|
||||||
case permissions
|
case permissions
|
||||||
|
case pathenv = "pathEnv"
|
||||||
case role
|
case role
|
||||||
case scopes
|
case scopes
|
||||||
case device
|
case device
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
|
|||||||
- Provider: `anthropic`
|
- Provider: `anthropic`
|
||||||
- Auth: `ANTHROPIC_API_KEY` or `claude setup-token`
|
- Auth: `ANTHROPIC_API_KEY` or `claude setup-token`
|
||||||
- Example model: `anthropic/claude-opus-4-5`
|
- Example model: `anthropic/claude-opus-4-5`
|
||||||
- CLI: `clawdbot onboard --auth-choice setup-token`
|
- CLI: `clawdbot onboard --auth-choice token` (paste setup-token) or `clawdbot models auth paste-token --provider anthropic`
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2671,6 +2671,8 @@ Control UI base path:
|
|||||||
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
|
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
|
||||||
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
|
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
|
||||||
- Default: root (`/`) (unchanged).
|
- 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:
|
Related docs:
|
||||||
- [Control UI](/web/control-ui)
|
- [Control UI](/web/control-ui)
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ The Gateway treats these as **claims** and enforces server-side allowlists.
|
|||||||
- **Local** connects include loopback and the gateway host’s own tailnet address
|
- **Local** connects include loopback and the gateway host’s own tailnet address
|
||||||
(so same‑host tailnet binds can still auto‑approve).
|
(so same‑host tailnet binds can still auto‑approve).
|
||||||
- All WS clients must include `device` identity during `connect` (operator + node).
|
- 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.
|
- Non-local connections must sign the server-provided `connect.challenge` nonce.
|
||||||
|
|
||||||
## TLS + pinning
|
## TLS + pinning
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ When the audit prints findings, treat this as a priority order:
|
|||||||
5. **Plugins/extensions**: only load what you explicitly trust.
|
5. **Plugins/extensions**: only load what you explicitly trust.
|
||||||
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
|
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
|
## Local session logs live on disk
|
||||||
|
|
||||||
Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.
|
Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
|
|||||||
|
|
||||||
## Common Issues
|
## 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
|
### CI Secrets Scan Failed
|
||||||
|
|
||||||
This means `detect-secrets` found new candidates not yet in the baseline.
|
This means `detect-secrets` found new candidates not yet in the baseline.
|
||||||
@@ -69,6 +82,34 @@ Doctor/service will show runtime state (PID/last exit) and log hints.
|
|||||||
|
|
||||||
See [/logging](/logging) for a full overview of formats, config, and access.
|
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)
|
### Service Environment (PATH + runtime)
|
||||||
|
|
||||||
The gateway service runs with a **minimal PATH** to avoid shell/manager cruft:
|
The gateway service runs with a **minimal PATH** to avoid shell/manager cruft:
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ Almost always a Node/npm PATH issue. Start here:
|
|||||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||||
- [Gateway authentication](/gateway/authentication)
|
- [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
|
### Service says running, but RPC probe fails
|
||||||
|
|
||||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
|
|||||||
Notes:
|
Notes:
|
||||||
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
|
- `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`).
|
- 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
|
```bash
|
||||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||||
|
|||||||
@@ -70,11 +70,9 @@ Setup-tokens are created by the **Claude Code CLI**, not the Anthropic Console.
|
|||||||
claude setup-token
|
claude setup-token
|
||||||
```
|
```
|
||||||
|
|
||||||
Paste the token into Clawdbot (wizard: **Anthropic token (paste setup-token)**), or let Clawdbot run the command locally:
|
Paste the token into Clawdbot (wizard: **Anthropic token (paste setup-token)**), or run it on the gateway host:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot onboard --auth-choice setup-token
|
|
||||||
# or
|
|
||||||
clawdbot models auth setup-token --provider anthropic
|
clawdbot models auth setup-token --provider anthropic
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -87,9 +85,6 @@ clawdbot models auth paste-token --provider anthropic
|
|||||||
### CLI setup
|
### CLI setup
|
||||||
|
|
||||||
```bash
|
```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
|
# Reuse Claude Code CLI OAuth credentials if already logged in
|
||||||
clawdbot onboard --auth-choice claude-cli
|
clawdbot onboard --auth-choice claude-cli
|
||||||
```
|
```
|
||||||
@@ -104,7 +99,7 @@ clawdbot onboard --auth-choice claude-cli
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The wizard can run `claude setup-token` locally and store the token, or you can paste a token generated elsewhere.
|
- Generate the setup-token with `claude setup-token` and paste it, or run `clawdbot models auth setup-token` on the gateway host.
|
||||||
- Clawdbot writes `auth.profiles["anthropic:claude-cli"].mode` as `"oauth"` so the profile
|
- 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
|
accepts both OAuth and setup-token credentials. Older configs using `"token"` are
|
||||||
auto-migrated on load.
|
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?
|
### 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 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).
|
`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).
|
||||||
|
|
||||||
Clawdbot keeps `auth.profiles["anthropic:claude-cli"].mode` set to `"oauth"` so
|
Clawdbot keeps `auth.profiles["anthropic:claude-cli"].mode` set to `"oauth"` so
|
||||||
the profile accepts both OAuth and setup-token credentials; older `"token"` mode
|
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
|
claude setup-token
|
||||||
```
|
```
|
||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
### Do you support Claude subscription auth (Claude Code OAuth)?
|
### 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, 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).
|
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).
|
||||||
|
|
||||||
Note: Claude subscription access is governed by Anthropic’s terms. For production or multi‑user workloads, API keys are usually the safer choice.
|
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
|
## What the wizard does
|
||||||
|
|
||||||
**Local mode (default)** walks you through:
|
**Local mode (default)** walks you through:
|
||||||
- Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or `claude setup-token`, plus MiniMax/GLM/Moonshot/AI Gateway options)
|
- Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or setup-token (paste), plus MiniMax/GLM/Moonshot/AI Gateway options)
|
||||||
- Workspace location + bootstrap files
|
- Workspace location + bootstrap files
|
||||||
- Gateway settings (port/bind/auth/tailscale)
|
- Gateway settings (port/bind/auth/tailscale)
|
||||||
- Providers (Telegram, WhatsApp, Discord, Signal)
|
- Providers (Telegram, WhatsApp, Discord, Signal)
|
||||||
@@ -79,9 +79,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
|||||||
|
|
||||||
2) **Model/Auth**
|
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 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 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` in your terminal, then paste the token (you can name it; blank = default).
|
- **Anthropic token (paste setup-token)**: run `claude setup-token` on any machine, 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 (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
|
||||||
- **OpenAI Code (Codex) subscription (OAuth)**: browser flow; paste the `code#state`.
|
- **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/*`.
|
- Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ 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
|
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
|
||||||
editing in the macOS app. Patterns are **case-insensitive glob matches**.
|
editing in the macOS app. Patterns are **case-insensitive glob matches**.
|
||||||
|
Patterns should resolve to **binary paths** (basename-only entries are ignored).
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- `~/Projects/**/bin/bird`
|
- `~/Projects/**/bin/bird`
|
||||||
@@ -104,6 +105,15 @@ 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
|
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.
|
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
|
## Control UI editing
|
||||||
|
|
||||||
Use the **Control UI → Nodes → Exec approvals** card to edit defaults, per‑agent
|
Use the **Control UI → Nodes → Exec approvals** card to edit defaults, per‑agent
|
||||||
|
|||||||
+10
-1
@@ -43,6 +43,7 @@ Notes:
|
|||||||
- `tools.exec.ask` (default: `on-miss`)
|
- `tools.exec.ask` (default: `on-miss`)
|
||||||
- `tools.exec.node` (default: unset)
|
- `tools.exec.node` (default: unset)
|
||||||
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
|
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
|
||||||
|
- `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```json5
|
```json5
|
||||||
@@ -64,7 +65,8 @@ Example:
|
|||||||
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
|
- `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.
|
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
|
- `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`.
|
if the exec call already sets `env.PATH`. Node PATH overrides are accepted only when they prepend
|
||||||
|
the node host PATH (no replacement).
|
||||||
|
|
||||||
Per-agent node binding (use the agent list index in config):
|
Per-agent node binding (use the agent list index in config):
|
||||||
|
|
||||||
@@ -90,6 +92,13 @@ Example:
|
|||||||
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
|
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.
|
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
|
||||||
|
|
||||||
|
## 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
|
## Examples
|
||||||
|
|
||||||
Foreground:
|
Foreground:
|
||||||
|
|||||||
@@ -86,6 +86,33 @@ Then open:
|
|||||||
|
|
||||||
Paste the token into the UI settings (sent as `connect.params.auth.token`).
|
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
|
## Building the UI
|
||||||
|
|
||||||
The Gateway serves static files from `dist/control-ui`. Build them with:
|
The Gateway serves static files from `dist/control-ui`. Build them with:
|
||||||
|
|||||||
@@ -114,11 +114,36 @@ 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 {
|
export function parseJsonOutput<T>(stdout: string): T | null {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(stdout) as T;
|
return JSON.parse(stdout) as T;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
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"
|
"$ROOT_DIR/scripts/package-mac-app.sh"
|
||||||
|
|
||||||
APP="$ROOT_DIR/dist/Clawdbot.app"
|
APP="$ROOT_DIR/dist/Clawdbot.app"
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ import {
|
|||||||
type ExecAsk,
|
type ExecAsk,
|
||||||
type ExecHost,
|
type ExecHost,
|
||||||
type ExecSecurity,
|
type ExecSecurity,
|
||||||
|
type ExecAllowlistEntry,
|
||||||
addAllowlistEntry,
|
addAllowlistEntry,
|
||||||
|
analyzeShellCommand,
|
||||||
|
isSafeBinUsage,
|
||||||
matchAllowlist,
|
matchAllowlist,
|
||||||
maxAsk,
|
maxAsk,
|
||||||
minSecurity,
|
minSecurity,
|
||||||
|
resolveSafeBins,
|
||||||
recordAllowlistUse,
|
recordAllowlistUse,
|
||||||
resolveCommandResolution,
|
|
||||||
resolveExecApprovals,
|
resolveExecApprovals,
|
||||||
} from "../infra/exec-approvals.js";
|
} from "../infra/exec-approvals.js";
|
||||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
@@ -94,6 +97,7 @@ export type ExecToolDefaults = {
|
|||||||
ask?: ExecAsk;
|
ask?: ExecAsk;
|
||||||
node?: string;
|
node?: string;
|
||||||
pathPrepend?: string[];
|
pathPrepend?: string[];
|
||||||
|
safeBins?: string[];
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
backgroundMs?: number;
|
backgroundMs?: number;
|
||||||
timeoutSec?: number;
|
timeoutSec?: number;
|
||||||
@@ -298,6 +302,7 @@ export function createExecTool(
|
|||||||
? defaults.timeoutSec
|
? defaults.timeoutSec
|
||||||
: 1800;
|
: 1800;
|
||||||
const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend);
|
const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend);
|
||||||
|
const safeBins = resolveSafeBins(defaults?.safeBins);
|
||||||
const notifyOnExit = defaults?.notifyOnExit !== false;
|
const notifyOnExit = defaults?.notifyOnExit !== false;
|
||||||
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
|
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
|
||||||
|
|
||||||
@@ -593,13 +598,27 @@ export function createExecTool(
|
|||||||
if (hostSecurity === "deny") {
|
if (hostSecurity === "deny") {
|
||||||
throw new Error("exec denied: host=gateway security=deny");
|
throw new Error("exec denied: host=gateway security=deny");
|
||||||
}
|
}
|
||||||
|
const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
|
||||||
const resolution = resolveCommandResolution(params.command, workdir, env);
|
const allowlistMatches: ExecAllowlistEntry[] = [];
|
||||||
const allowlistMatch =
|
let allowlistSatisfied = false;
|
||||||
hostSecurity === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
|
if (hostSecurity === "allowlist" && analysis.ok && analysis.segments.length > 0) {
|
||||||
|
allowlistSatisfied = 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: workdir,
|
||||||
|
});
|
||||||
|
return Boolean(match || safe);
|
||||||
|
});
|
||||||
|
}
|
||||||
const requiresAsk =
|
const requiresAsk =
|
||||||
hostAsk === "always" ||
|
hostAsk === "always" ||
|
||||||
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
|
(hostAsk === "on-miss" &&
|
||||||
|
hostSecurity === "allowlist" &&
|
||||||
|
(!analysis.ok || !allowlistSatisfied));
|
||||||
|
|
||||||
let approvedByAsk = false;
|
let approvedByAsk = false;
|
||||||
if (requiresAsk) {
|
if (requiresAsk) {
|
||||||
@@ -613,7 +632,7 @@ export function createExecTool(
|
|||||||
security: hostSecurity,
|
security: hostSecurity,
|
||||||
ask: hostAsk,
|
ask: hostAsk,
|
||||||
agentId: defaults?.agentId,
|
agentId: defaults?.agentId,
|
||||||
resolvedPath: resolution?.resolvedPath ?? null,
|
resolvedPath: analysis.segments[0]?.resolution?.resolvedPath ?? null,
|
||||||
sessionKey: defaults?.sessionKey ?? null,
|
sessionKey: defaults?.sessionKey ?? null,
|
||||||
timeoutMs: 120_000,
|
timeoutMs: 120_000,
|
||||||
},
|
},
|
||||||
@@ -630,7 +649,7 @@ export function createExecTool(
|
|||||||
if (askFallback === "full") {
|
if (askFallback === "full") {
|
||||||
approvedByAsk = true;
|
approvedByAsk = true;
|
||||||
} else if (askFallback === "allowlist") {
|
} else if (askFallback === "allowlist") {
|
||||||
if (!allowlistMatch) {
|
if (!analysis.ok || !allowlistSatisfied) {
|
||||||
throw new Error("exec denied: approval required (approval UI not available)");
|
throw new Error("exec denied: approval required (approval UI not available)");
|
||||||
}
|
}
|
||||||
approvedByAsk = true;
|
approvedByAsk = true;
|
||||||
@@ -644,30 +663,37 @@ export function createExecTool(
|
|||||||
if (decision === "allow-always") {
|
if (decision === "allow-always") {
|
||||||
approvedByAsk = true;
|
approvedByAsk = true;
|
||||||
if (hostSecurity === "allowlist") {
|
if (hostSecurity === "allowlist") {
|
||||||
const pattern =
|
for (const segment of analysis.segments) {
|
||||||
resolution?.resolvedPath ??
|
const pattern = segment.resolution?.resolvedPath ?? "";
|
||||||
resolution?.rawExecutable ??
|
if (pattern) {
|
||||||
params.command.split(/\s+/).shift() ??
|
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
|
||||||
"";
|
}
|
||||||
if (pattern) {
|
|
||||||
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hostSecurity === "allowlist" && !allowlistMatch && !approvedByAsk) {
|
if (
|
||||||
|
hostSecurity === "allowlist" &&
|
||||||
|
(!analysis.ok || !allowlistSatisfied) &&
|
||||||
|
!approvedByAsk
|
||||||
|
) {
|
||||||
throw new Error("exec denied: allowlist miss");
|
throw new Error("exec denied: allowlist miss");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowlistMatch) {
|
if (allowlistMatches.length > 0) {
|
||||||
recordAllowlistUse(
|
const seen = new Set<string>();
|
||||||
approvals.file,
|
for (const match of allowlistMatches) {
|
||||||
defaults?.agentId,
|
if (seen.has(match.pattern)) continue;
|
||||||
allowlistMatch,
|
seen.add(match.pattern);
|
||||||
params.command,
|
recordAllowlistUse(
|
||||||
resolution?.resolvedPath,
|
approvals.file,
|
||||||
);
|
defaults?.agentId,
|
||||||
|
match,
|
||||||
|
params.command,
|
||||||
|
analysis.segments[0]?.resolution?.resolvedPath,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ describe("extractModelDirective", () => {
|
|||||||
expect(result.cleaned).toBe("");
|
expect(result.cleaned).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not extract /models with argument", () => {
|
it("extracts /models with argument", () => {
|
||||||
const result = extractModelDirective("/models gpt-5");
|
const result = extractModelDirective("/models gpt-5");
|
||||||
expect(result.hasDirective).toBe(false);
|
expect(result.hasDirective).toBe(true);
|
||||||
expect(result.cleaned).toBe("/models gpt-5");
|
expect(result.rawModel).toBe("gpt-5");
|
||||||
|
expect(result.cleaned).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("extracts /model with provider/model format", () => {
|
it("extracts /model with provider/model format", () => {
|
||||||
@@ -113,10 +114,9 @@ describe("extractModelDirective", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("edge cases", () => {
|
describe("edge cases", () => {
|
||||||
it("extracts models with multiple path segments", () => {
|
it("absorbs path-like segments when /model includes extra slashes", () => {
|
||||||
const result = extractModelDirective("thats not /model gpt-5/tmp/hello");
|
const result = extractModelDirective("thats not /model gpt-5/tmp/hello");
|
||||||
expect(result.hasDirective).toBe(true);
|
expect(result.hasDirective).toBe(true);
|
||||||
expect(result.rawModel).toBe("gpt-5/tmp/hello");
|
|
||||||
expect(result.cleaned).toBe("thats not");
|
expect(result.cleaned).toBe("thats not");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function extractModelDirective(
|
|||||||
if (!body) return { cleaned: "", hasDirective: false };
|
if (!body) return { cleaned: "", hasDirective: false };
|
||||||
|
|
||||||
const modelMatch = body.match(
|
const modelMatch = body.match(
|
||||||
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
|
/(?:^|\s)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
|
||||||
);
|
);
|
||||||
|
|
||||||
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
|
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
|
||||||
|
|||||||
+17
-76
@@ -60,13 +60,13 @@ describe("directive behavior", () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists allowlisted models on /models <provider>", async () => {
|
it("lists allowlisted models on /model list", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
@@ -84,29 +84,9 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Models (anthropic)");
|
expect(text).toContain("Model listing moved.");
|
||||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
|
||||||
|
expect(text).toContain("Switch: /model <provider/model>");
|
||||||
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();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -117,7 +97,7 @@ describe("directive behavior", () => {
|
|||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
@@ -135,29 +115,9 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Models (anthropic)");
|
expect(text).toContain("Current: anthropic/claude-opus-4-5");
|
||||||
expect(text).toContain("anthropic/claude-opus-4-5");
|
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
|
||||||
|
expect(text).toContain("More: /model status");
|
||||||
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();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -172,7 +132,7 @@ describe("directive behavior", () => {
|
|||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{ Body: "/models xai", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
@@ -190,29 +150,9 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Models (xai)");
|
expect(text).toContain("Model listing moved.");
|
||||||
expect(text).toContain("xai/grok-4");
|
expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
|
||||||
|
expect(text).toContain("Switch: /model <provider/model>");
|
||||||
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();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -232,7 +172,7 @@ describe("directive behavior", () => {
|
|||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
@@ -261,8 +201,9 @@ describe("directive behavior", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Models (minimax)");
|
expect(text).toContain("Model listing moved.");
|
||||||
expect(text).toContain("minimax/MiniMax-M2.1");
|
expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
|
||||||
|
expect(text).toContain("Switch: /model <provider/model>");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-2
@@ -62,13 +62,13 @@ describe("directive behavior", () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selects exact alias matches even when ambiguous", async () => {
|
it("prefers alias matches when fuzzy selection is ambiguous", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
await getReplyFromConfig(
|
await getReplyFromConfig(
|
||||||
{ Body: "/model Kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/model ki", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
+15
-15
@@ -65,7 +65,7 @@ describe("directive behavior", () => {
|
|||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
await getReplyFromConfig(
|
||||||
{ Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/model kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
@@ -94,10 +94,10 @@ describe("directive behavior", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
assertModelSelection(storePath, {
|
||||||
expect(text).toContain("Did you mean:");
|
model: "kimi-k2-0905-preview",
|
||||||
expect(text).toContain("moonshot/kimi-k2-0905-preview");
|
provider: "moonshot",
|
||||||
assertModelSelection(storePath);
|
});
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -106,7 +106,7 @@ describe("directive behavior", () => {
|
|||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
await getReplyFromConfig(
|
||||||
{
|
{
|
||||||
Body: "/model kimi-k2-0905-preview",
|
Body: "/model kimi-k2-0905-preview",
|
||||||
From: "+1222",
|
From: "+1222",
|
||||||
@@ -140,10 +140,10 @@ describe("directive behavior", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
assertModelSelection(storePath, {
|
||||||
expect(text).toContain("Did you mean:");
|
model: "kimi-k2-0905-preview",
|
||||||
expect(text).toContain("moonshot/kimi-k2-0905-preview");
|
provider: "moonshot",
|
||||||
assertModelSelection(storePath);
|
});
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -152,7 +152,7 @@ describe("directive behavior", () => {
|
|||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
|
|
||||||
const res = await getReplyFromConfig(
|
await getReplyFromConfig(
|
||||||
{ Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
{ Body: "/model moonshot/kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
|
||||||
{},
|
{},
|
||||||
{
|
{
|
||||||
@@ -181,10 +181,10 @@ describe("directive behavior", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
assertModelSelection(storePath, {
|
||||||
expect(text).toContain("Did you mean:");
|
model: "kimi-k2-0905-preview",
|
||||||
expect(text).toContain("moonshot/kimi-k2-0905-preview");
|
provider: "moonshot",
|
||||||
assertModelSelection(storePath);
|
});
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+3
-3
@@ -187,7 +187,7 @@ describe("directive behavior", () => {
|
|||||||
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("shows a model summary on /model", async () => {
|
it("lists allowlisted models on /model", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockReset();
|
vi.mocked(runEmbeddedPiAgent).mockReset();
|
||||||
const storePath = path.join(home, "sessions.json");
|
const storePath = path.join(home, "sessions.json");
|
||||||
@@ -212,8 +212,8 @@ describe("directive behavior", () => {
|
|||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(text).toContain("Current: anthropic/claude-opus-4-5");
|
expect(text).toContain("Current: anthropic/claude-opus-4-5");
|
||||||
expect(text).toContain("Browse: /models");
|
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
|
||||||
expect(text).toContain("Switch: /model <provider/model>");
|
expect(text).toContain("More: /model status");
|
||||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+15
-15
@@ -95,7 +95,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("trigger handling", () => {
|
describe("trigger handling", () => {
|
||||||
it("shows a /model summary with browse hints", async () => {
|
it("shows a /model summary and points to /models", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const cfg = makeCfg(home);
|
const cfg = makeCfg(home);
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
@@ -116,17 +116,19 @@ describe("trigger handling", () => {
|
|||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
const normalized = normalizeTestText(text ?? "");
|
const normalized = normalizeTestText(text ?? "");
|
||||||
expect(normalized).toContain("Current: anthropic/claude-opus-4-5");
|
expect(normalized).toContain("Current: anthropic/claude-opus-4-5");
|
||||||
expect(normalized).toContain("Browse: /models");
|
|
||||||
expect(normalized).toContain("Switch: /model <provider/model>");
|
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).toContain("More: /model status");
|
||||||
|
expect(normalized).not.toContain("reasoning");
|
||||||
|
expect(normalized).not.toContain("image");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("lists providers on /models", async () => {
|
it("moves /model list to /models", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const cfg = makeCfg(home);
|
const cfg = makeCfg(home);
|
||||||
const res = await getReplyFromConfig(
|
const res = await getReplyFromConfig(
|
||||||
{
|
{
|
||||||
Body: "/models",
|
Body: "/model list",
|
||||||
From: "telegram:111",
|
From: "telegram:111",
|
||||||
To: "telegram:111",
|
To: "telegram:111",
|
||||||
ChatType: "direct",
|
ChatType: "direct",
|
||||||
@@ -141,12 +143,9 @@ describe("trigger handling", () => {
|
|||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
const normalized = normalizeTestText(text ?? "");
|
const normalized = normalizeTestText(text ?? "");
|
||||||
expect(normalized).toContain("Providers:");
|
expect(normalized).toContain("Model listing moved.");
|
||||||
expect(normalized).toContain("anthropic");
|
expect(normalized).toContain("Use: /models (providers) or /models <provider> (models)");
|
||||||
expect(normalized).toContain("openrouter");
|
expect(normalized).toContain("Switch: /model <provider/model>");
|
||||||
expect(normalized).toContain("openai");
|
|
||||||
expect(normalized).toContain("openai-codex");
|
|
||||||
expect(normalized).toContain("minimax");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("selects the exact provider/model pair for openrouter", async () => {
|
it("selects the exact provider/model pair for openrouter", async () => {
|
||||||
@@ -200,16 +199,17 @@ describe("trigger handling", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(normalizeTestText(text ?? "")).toContain(
|
const normalized = normalizeTestText(text ?? "");
|
||||||
"Numeric model selection is not supported in chat.",
|
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>");
|
||||||
|
|
||||||
const store = loadSessionStore(cfg.session.store);
|
const store = loadSessionStore(cfg.session.store);
|
||||||
expect(store[sessionKey]?.providerOverride).toBeUndefined();
|
expect(store[sessionKey]?.providerOverride).toBeUndefined();
|
||||||
expect(store[sessionKey]?.modelOverride).toBeUndefined();
|
expect(store[sessionKey]?.modelOverride).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("selects exact provider/model combo via /model <provider/model>", async () => {
|
it("resets to the default model via /model <provider/model>", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const cfg = makeCfg(home);
|
const cfg = makeCfg(home);
|
||||||
const sessionKey = "telegram:slash:111";
|
const sessionKey = "telegram:slash:111";
|
||||||
@@ -231,7 +231,7 @@ describe("trigger handling", () => {
|
|||||||
|
|
||||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||||
expect(normalizeTestText(text ?? "")).toContain(
|
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);
|
const store = loadSessionStore(cfg.session.store);
|
||||||
|
|||||||
@@ -80,47 +80,14 @@ describe("/models command", () => {
|
|||||||
expect(result.reply?.text).toContain("All: /models anthropic all");
|
expect(result.reply?.text).toContain("All: /models anthropic all");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes configured providers and defaults", async () => {
|
it("ignores page argument when all flag is present", async () => {
|
||||||
const configuredCfg = {
|
const params = buildParams("/models anthropic 3 all", cfg);
|
||||||
commands: { text: true },
|
const result = await handleCommands(params);
|
||||||
agents: {
|
expect(result.shouldContinue).toBe(false);
|
||||||
defaults: {
|
expect(result.reply?.text).toContain("Models (anthropic)");
|
||||||
model: {
|
expect(result.reply?.text).toContain("page 1/1");
|
||||||
primary: "synthetic/synth-1",
|
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
|
||||||
fallbacks: ["synthetic/synth-2"],
|
expect(result.reply?.text).not.toContain("Page out of range");
|
||||||
},
|
|
||||||
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 () => {
|
it("errors on out-of-range pages", async () => {
|
||||||
@@ -138,4 +105,29 @@ describe("/models command", () => {
|
|||||||
expect(result.reply?.text).toContain("Unknown provider");
|
expect(result.reply?.text).toContain("Unknown provider");
|
||||||
expect(result.reply?.text).toContain("Available providers");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -17,15 +17,6 @@ function formatProviderLine(params: { provider: string; count: number }): string
|
|||||||
return `- ${params.provider} (${params.count})`;
|
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): {
|
function parseModelsArgs(raw: string): {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
page: number;
|
page: number;
|
||||||
@@ -100,57 +91,66 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
|
|||||||
defaultModel: resolvedDefault.model,
|
defaultModel: resolvedDefault.model,
|
||||||
});
|
});
|
||||||
|
|
||||||
const byProvider = new Map<string, Set<string>>();
|
|
||||||
const aliasIndex = buildModelAliasIndex({
|
const aliasIndex = buildModelAliasIndex({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
defaultProvider: resolvedDefault.provider,
|
defaultProvider: resolvedDefault.provider,
|
||||||
});
|
});
|
||||||
const addRaw = (raw?: string) => {
|
|
||||||
const value = String(raw ?? "").trim();
|
const byProvider = new Map<string, Set<string>>();
|
||||||
if (!value) return;
|
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 resolved = resolveModelRefFromString({
|
const resolved = resolveModelRefFromString({
|
||||||
raw: value,
|
raw: trimmed,
|
||||||
defaultProvider: resolvedDefault.provider,
|
defaultProvider: resolvedDefault.provider,
|
||||||
aliasIndex,
|
aliasIndex,
|
||||||
});
|
});
|
||||||
if (!resolved) return;
|
if (!resolved) return;
|
||||||
addModelRef(byProvider, resolved.ref.provider, resolved.ref.model);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const entry of allowed.allowedCatalog) {
|
for (const entry of allowed.allowedCatalog) {
|
||||||
addModelRef(byProvider, entry.provider, entry.id);
|
add(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 ?? {})) {
|
for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
|
||||||
addRaw(String(raw ?? ""));
|
addRawModelRef(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [provider, providerConfig] of Object.entries(params.cfg.models?.providers ?? {})) {
|
// Ensure configured defaults/fallbacks/image models show up even when the
|
||||||
for (const modelDef of providerConfig?.models ?? []) {
|
// curated catalog doesn't know about them (custom providers, dev builds, etc.).
|
||||||
addModelRef(byProvider, provider, modelDef?.id ?? "");
|
add(resolvedDefault.provider, resolvedDefault.model);
|
||||||
}
|
addModelConfigEntries();
|
||||||
}
|
|
||||||
|
|
||||||
const providers = [...byProvider.keys()].sort();
|
const providers = [...byProvider.keys()].sort();
|
||||||
|
|
||||||
|
|||||||
@@ -341,16 +341,41 @@ export function resolveModelSelectionFromDirective(params: {
|
|||||||
|
|
||||||
if (resolved.selection) {
|
if (resolved.selection) {
|
||||||
const suggestion = `${resolved.selection.provider}/${resolved.selection.model}`;
|
const suggestion = `${resolved.selection.provider}/${resolved.selection.model}`;
|
||||||
return {
|
const rawHasSlash = raw.includes("/");
|
||||||
errorText: [
|
const shouldAutoSelect = (() => {
|
||||||
`Unrecognized model: ${raw}`,
|
if (!rawHasSlash) return true;
|
||||||
"",
|
const slash = raw.indexOf("/");
|
||||||
`Did you mean: ${suggestion}`,
|
if (slash <= 0) return true;
|
||||||
`Try: /model ${suggestion}`,
|
const rawProvider = normalizeProviderId(raw.slice(0, slash));
|
||||||
"",
|
const rawFragment = raw
|
||||||
"Browse: /models or /models <provider>",
|
.slice(slash + 1)
|
||||||
].join("\n"),
|
.trim()
|
||||||
};
|
.toLowerCase();
|
||||||
|
if (!rawFragment) return false;
|
||||||
|
const resolvedProvider = normalizeProviderId(resolved.selection.provider);
|
||||||
|
if (rawProvider !== resolvedProvider) return false;
|
||||||
|
const resolvedModel = resolved.selection.model.toLowerCase();
|
||||||
|
return (
|
||||||
|
resolvedModel.startsWith(rawFragment) ||
|
||||||
|
resolvedModel.includes(rawFragment) ||
|
||||||
|
rawFragment.startsWith(resolvedModel)
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (shouldAutoSelect) {
|
||||||
|
modelSelection = resolved.selection;
|
||||||
|
} else {
|
||||||
|
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;
|
if (Math.abs(aLen - bLen) > maxDistance) return null;
|
||||||
|
|
||||||
// Standard DP with early exit. O(maxDistance * minLen) in common cases.
|
// Standard DP with early exit. O(maxDistance * minLen) in common cases.
|
||||||
const prev = Array.from({ length: bLen + 1 }, (_, index) => index);
|
const prev = Array.from({ length: bLen + 1 }, (_, idx) => idx);
|
||||||
const curr = Array.from({ length: bLen + 1 }, () => 0);
|
const curr = Array.from({ length: bLen + 1 }, () => 0);
|
||||||
|
|
||||||
for (let i = 1; i <= aLen; i++) {
|
for (let i = 1; i <= aLen; i++) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { startGatewayServer } from "../../gateway/server.js";
|
import type { startGatewayServer } from "../../gateway/server.js";
|
||||||
|
import { acquireGatewayLock } from "../../infra/gateway-lock.js";
|
||||||
import {
|
import {
|
||||||
consumeGatewaySigusr1RestartAuthorization,
|
consumeGatewaySigusr1RestartAuthorization,
|
||||||
isGatewaySigusr1RestartExternallyAllowed,
|
isGatewaySigusr1RestartExternallyAllowed,
|
||||||
@@ -14,6 +15,7 @@ export async function runGatewayLoop(params: {
|
|||||||
start: () => Promise<Awaited<ReturnType<typeof startGatewayServer>>>;
|
start: () => Promise<Awaited<ReturnType<typeof startGatewayServer>>>;
|
||||||
runtime: typeof defaultRuntime;
|
runtime: typeof defaultRuntime;
|
||||||
}) {
|
}) {
|
||||||
|
const lock = await acquireGatewayLock();
|
||||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
|
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
let restartResolver: (() => void) | null = null;
|
let restartResolver: (() => void) | null = null;
|
||||||
@@ -96,6 +98,7 @@ export async function runGatewayLoop(params: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
await lock?.release();
|
||||||
cleanupSignals();
|
cleanupSignals();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ const callGateway = vi.fn(async (opts: { method?: string }) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (opts.method === "exec.approvals.get") {
|
if (opts.method === "exec.approvals.node.get") {
|
||||||
return {
|
return {
|
||||||
path: "/tmp/exec-approvals.json",
|
path: "/tmp/exec-approvals.json",
|
||||||
exists: true,
|
exists: true,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type ExecDefaults = {
|
|||||||
ask?: ExecAsk;
|
ask?: ExecAsk;
|
||||||
node?: string;
|
node?: string;
|
||||||
pathPrepend?: string[];
|
pathPrepend?: string[];
|
||||||
|
safeBins?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
|
function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
|
||||||
@@ -95,6 +96,7 @@ function resolveExecDefaults(
|
|||||||
ask: globalExec.ask,
|
ask: globalExec.ask,
|
||||||
node: globalExec.node,
|
node: globalExec.node,
|
||||||
pathPrepend: globalExec.pathPrepend,
|
pathPrepend: globalExec.pathPrepend,
|
||||||
|
safeBins: globalExec.safeBins,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
@@ -104,6 +106,7 @@ function resolveExecDefaults(
|
|||||||
ask: agentExec?.ask ?? globalExec?.ask,
|
ask: agentExec?.ask ?? globalExec?.ask,
|
||||||
node: agentExec?.node ?? globalExec?.node,
|
node: agentExec?.node ?? globalExec?.node,
|
||||||
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
|
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
|
||||||
|
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +233,9 @@ export function registerNodesInvokeCommands(nodes: Command) {
|
|||||||
const security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
|
const security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
|
||||||
const ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
|
const ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
|
||||||
|
|
||||||
const approvalsSnapshot = (await callGatewayCli("exec.approvals.get", opts, {})) as {
|
const approvalsSnapshot = (await callGatewayCli("exec.approvals.node.get", opts, {
|
||||||
|
nodeId,
|
||||||
|
})) as {
|
||||||
file?: unknown;
|
file?: unknown;
|
||||||
} | null;
|
} | null;
|
||||||
const approvalsFile =
|
const approvalsFile =
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
|
|||||||
value: "anthropic",
|
value: "anthropic",
|
||||||
label: "Anthropic",
|
label: "Anthropic",
|
||||||
hint: "Claude Code CLI + API key",
|
hint: "Claude Code CLI + API key",
|
||||||
choices: ["claude-cli", "setup-token", "token", "apiKey"],
|
choices: ["claude-cli", "token", "apiKey"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "minimax",
|
value: "minimax",
|
||||||
@@ -170,12 +170,6 @@ 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({
|
options.push({
|
||||||
value: "token",
|
value: "token",
|
||||||
label: "Anthropic token (paste setup-token)",
|
label: "Anthropic token (paste setup-token)",
|
||||||
|
|||||||
@@ -87,6 +87,19 @@ export async function doctorCommand(
|
|||||||
});
|
});
|
||||||
let cfg: ClawdbotConfig = configResult.cfg;
|
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);
|
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
|
||||||
await noteAuthProfileHealth({
|
await noteAuthProfileHealth({
|
||||||
cfg,
|
cfg,
|
||||||
|
|||||||
@@ -220,12 +220,18 @@ describe("legacy config detection", () => {
|
|||||||
});
|
});
|
||||||
it("keeps gateway.bind tailnet", async () => {
|
it("keeps gateway.bind tailnet", async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const { migrateLegacyConfig } = await import("./config.js");
|
const { migrateLegacyConfig, validateConfigObject } = await import("./config.js");
|
||||||
const res = migrateLegacyConfig({
|
const res = migrateLegacyConfig({
|
||||||
gateway: { bind: "tailnet" as const },
|
gateway: { bind: "tailnet" as const },
|
||||||
});
|
});
|
||||||
expect(res.changes).not.toContain("Migrated gateway.bind from 'tailnet' to 'auto'.");
|
expect(res.changes).not.toContain("Migrated gateway.bind from 'tailnet' to 'auto'.");
|
||||||
expect(res.config?.gateway?.bind).toBe("tailnet");
|
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");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
|
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|||||||
@@ -202,10 +202,12 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
|
|||||||
|
|
||||||
let mutated = false;
|
let mutated = false;
|
||||||
const nextDefaults = { ...defaults };
|
const nextDefaults = { ...defaults };
|
||||||
|
const contextPruning = defaults.contextPruning ?? {};
|
||||||
|
const heartbeat = defaults.heartbeat ?? {};
|
||||||
|
|
||||||
if (defaults.contextPruning?.mode === undefined) {
|
if (defaults.contextPruning?.mode === undefined) {
|
||||||
nextDefaults.contextPruning = {
|
nextDefaults.contextPruning = {
|
||||||
...defaults.contextPruning,
|
...contextPruning,
|
||||||
mode: "cache-ttl",
|
mode: "cache-ttl",
|
||||||
ttl: defaults.contextPruning?.ttl ?? "1h",
|
ttl: defaults.contextPruning?.ttl ?? "1h",
|
||||||
};
|
};
|
||||||
@@ -214,7 +216,7 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
|
|||||||
|
|
||||||
if (defaults.heartbeat?.every === undefined) {
|
if (defaults.heartbeat?.every === undefined) {
|
||||||
nextDefaults.heartbeat = {
|
nextDefaults.heartbeat = {
|
||||||
...defaults.heartbeat,
|
...heartbeat,
|
||||||
every: authMode === "oauth" ? "1h" : "30m",
|
every: authMode === "oauth" ? "1h" : "30m",
|
||||||
};
|
};
|
||||||
mutated = true;
|
mutated = true;
|
||||||
|
|||||||
@@ -7,11 +7,7 @@ export function migrateLegacyConfig(raw: unknown): {
|
|||||||
changes: string[];
|
changes: string[];
|
||||||
} {
|
} {
|
||||||
const { next, changes } = applyLegacyMigrations(raw);
|
const { next, changes } = applyLegacyMigrations(raw);
|
||||||
if (!next) {
|
if (!next) return { config: null, changes: [] };
|
||||||
const validated = validateConfigObjectWithPlugins(raw);
|
|
||||||
if (!validated.ok) return { config: null, changes: [] };
|
|
||||||
return { config: validated.config, changes: [] };
|
|
||||||
}
|
|
||||||
const validated = validateConfigObjectWithPlugins(next);
|
const validated = validateConfigObjectWithPlugins(next);
|
||||||
if (!validated.ok) {
|
if (!validated.ok) {
|
||||||
changes.push("Migration applied, but config still invalid; fix remaining issues manually.");
|
changes.push("Migration applied, but config still invalid; fix remaining issues manually.");
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"tools.exec.ask": "Exec Ask",
|
"tools.exec.ask": "Exec Ask",
|
||||||
"tools.exec.node": "Exec Node Binding",
|
"tools.exec.node": "Exec Node Binding",
|
||||||
"tools.exec.pathPrepend": "Exec PATH Prepend",
|
"tools.exec.pathPrepend": "Exec PATH Prepend",
|
||||||
|
"tools.exec.safeBins": "Exec Safe Bins",
|
||||||
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
|
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
|
||||||
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
|
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
|
||||||
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
|
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
|
||||||
@@ -186,6 +187,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
|
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
|
||||||
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
|
||||||
"gateway.controlUi.basePath": "Control UI Base Path",
|
"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.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
||||||
"gateway.reload.mode": "Config Reload Mode",
|
"gateway.reload.mode": "Config Reload Mode",
|
||||||
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
||||||
@@ -344,6 +346,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"gateway.auth.password": "Required for Tailscale funnel.",
|
"gateway.auth.password": "Required for Tailscale funnel.",
|
||||||
"gateway.controlUi.basePath":
|
"gateway.controlUi.basePath":
|
||||||
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).",
|
"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":
|
"gateway.http.endpoints.chatCompletions.enabled":
|
||||||
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
||||||
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
|
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
|
||||||
@@ -367,6 +371,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"tools.exec.notifyOnExit":
|
"tools.exec.notifyOnExit":
|
||||||
"When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.",
|
"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.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":
|
"tools.message.allowCrossContextSend":
|
||||||
"Legacy override: allow cross-context sends across all providers.",
|
"Legacy override: allow cross-context sends across all providers.",
|
||||||
"tools.message.crossContext.allowWithinProvider":
|
"tools.message.crossContext.allowWithinProvider":
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export type GatewayControlUiConfig = {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Optional base path prefix for the Control UI (e.g. "/clawdbot"). */
|
/** Optional base path prefix for the Control UI (e.g. "/clawdbot"). */
|
||||||
basePath?: string;
|
basePath?: string;
|
||||||
|
/** Allow token-only auth over insecure HTTP (default: false). */
|
||||||
|
allowInsecureAuth?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayAuthMode = "token" | "password";
|
export type GatewayAuthMode = "token" | "password";
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ export type ExecToolConfig = {
|
|||||||
node?: string;
|
node?: string;
|
||||||
/** Directories to prepend to PATH when running exec (gateway/sandbox). */
|
/** Directories to prepend to PATH when running exec (gateway/sandbox). */
|
||||||
pathPrepend?: string[];
|
pathPrepend?: string[];
|
||||||
|
/** Safe stdin-only binaries that can run without allowlist entries. */
|
||||||
|
safeBins?: string[];
|
||||||
/** Default time (ms) before an exec command auto-backgrounds. */
|
/** Default time (ms) before an exec command auto-backgrounds. */
|
||||||
backgroundMs?: number;
|
backgroundMs?: number;
|
||||||
/** Default timeout (seconds) before auto-killing exec commands. */
|
/** Default timeout (seconds) before auto-killing exec commands. */
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ export const AgentToolsSchema = z
|
|||||||
ask: z.enum(["off", "on-miss", "always"]).optional(),
|
ask: z.enum(["off", "on-miss", "always"]).optional(),
|
||||||
node: z.string().optional(),
|
node: z.string().optional(),
|
||||||
pathPrepend: z.array(z.string()).optional(),
|
pathPrepend: z.array(z.string()).optional(),
|
||||||
|
safeBins: z.array(z.string()).optional(),
|
||||||
backgroundMs: z.number().int().positive().optional(),
|
backgroundMs: z.number().int().positive().optional(),
|
||||||
timeoutSec: z.number().int().positive().optional(),
|
timeoutSec: z.number().int().positive().optional(),
|
||||||
cleanupMs: z.number().int().positive().optional(),
|
cleanupMs: z.number().int().positive().optional(),
|
||||||
@@ -485,6 +486,7 @@ export const ToolsSchema = z
|
|||||||
ask: z.enum(["off", "on-miss", "always"]).optional(),
|
ask: z.enum(["off", "on-miss", "always"]).optional(),
|
||||||
node: z.string().optional(),
|
node: z.string().optional(),
|
||||||
pathPrepend: z.array(z.string()).optional(),
|
pathPrepend: z.array(z.string()).optional(),
|
||||||
|
safeBins: z.array(z.string()).optional(),
|
||||||
backgroundMs: z.number().int().positive().optional(),
|
backgroundMs: z.number().int().positive().optional(),
|
||||||
timeoutSec: z.number().int().positive().optional(),
|
timeoutSec: z.number().int().positive().optional(),
|
||||||
cleanupMs: z.number().int().positive().optional(),
|
cleanupMs: z.number().int().positive().optional(),
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ export const ClawdbotSchema = z
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
basePath: z.string().optional(),
|
basePath: z.string().optional(),
|
||||||
|
allowInsecureAuth: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ 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_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 MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits
|
||||||
export const HANDSHAKE_TIMEOUT_MS = 10_000;
|
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 TICK_INTERVAL_MS = 30_000;
|
export const TICK_INTERVAL_MS = 30_000;
|
||||||
export const HEALTH_REFRESH_INTERVAL_MS = 60_000;
|
export const HEALTH_REFRESH_INTERVAL_MS = 60_000;
|
||||||
export const DEDUPE_TTL_MS = 5 * 60_000;
|
export const DEDUPE_TTL_MS = 5 * 60_000;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||||
import { HANDSHAKE_TIMEOUT_MS } from "./server-constants.js";
|
import { getHandshakeTimeoutMs } from "./server-constants.js";
|
||||||
import {
|
import {
|
||||||
connectReq,
|
connectReq,
|
||||||
getFreePort,
|
getFreePort,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
startServerWithClient,
|
startServerWithClient,
|
||||||
testState,
|
testState,
|
||||||
} from "./test-helpers.js";
|
} from "./test-helpers.js";
|
||||||
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
|
|
||||||
installGatewayTestHooks();
|
installGatewayTestHooks();
|
||||||
|
|
||||||
@@ -28,10 +29,21 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean
|
|||||||
describe("gateway server auth/connect", () => {
|
describe("gateway server auth/connect", () => {
|
||||||
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
|
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
const { server, ws } = await startServerWithClient();
|
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||||
const closed = await waitForWsClose(ws, HANDSHAKE_TIMEOUT_MS + 2_000);
|
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "250";
|
||||||
expect(closed).toBe(true);
|
try {
|
||||||
await server.close();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("connect (req) handshake returns hello-ok payload", async () => {
|
test("connect (req) handshake returns hello-ok payload", async () => {
|
||||||
@@ -116,6 +128,52 @@ describe("gateway server auth/connect", () => {
|
|||||||
await server.close();
|
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 () => {
|
test("accepts device token auth for paired device", async () => {
|
||||||
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
||||||
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
|
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { isWebchatClient } from "../../utils/message-channel.js";
|
|||||||
|
|
||||||
import type { ResolvedGatewayAuth } from "../auth.js";
|
import type { ResolvedGatewayAuth } from "../auth.js";
|
||||||
import { isLoopbackAddress } from "../net.js";
|
import { isLoopbackAddress } from "../net.js";
|
||||||
import { HANDSHAKE_TIMEOUT_MS } from "../server-constants.js";
|
import { getHandshakeTimeoutMs } from "../server-constants.js";
|
||||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "../server-methods/types.js";
|
import type { GatewayRequestContext, GatewayRequestHandlers } from "../server-methods/types.js";
|
||||||
import { formatError } from "../server-utils.js";
|
import { formatError } from "../server-utils.js";
|
||||||
import { logWs } from "../ws-log.js";
|
import { logWs } from "../ws-log.js";
|
||||||
@@ -210,6 +210,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
|||||||
close();
|
close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handshakeTimeoutMs = getHandshakeTimeoutMs();
|
||||||
const handshakeTimer = setTimeout(() => {
|
const handshakeTimer = setTimeout(() => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
handshakeState = "failed";
|
handshakeState = "failed";
|
||||||
@@ -219,7 +220,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
|||||||
logWsControl.warn(`handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`);
|
logWsControl.warn(`handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`);
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
}, HANDSHAKE_TIMEOUT_MS);
|
}, handshakeTimeoutMs);
|
||||||
|
|
||||||
attachGatewayWsMessageHandler({
|
attachGatewayWsMessageHandler({
|
||||||
socket,
|
socket,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
validateConnectParams,
|
validateConnectParams,
|
||||||
validateRequestFrame,
|
validateRequestFrame,
|
||||||
} from "../../protocol/index.js";
|
} 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 { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
|
||||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js";
|
import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js";
|
||||||
import { handleGatewayRequest } from "../../server-methods.js";
|
import { handleGatewayRequest } from "../../server-methods.js";
|
||||||
@@ -293,24 +294,53 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
|
|
||||||
const device = connectParams.device;
|
const device = connectParams.device;
|
||||||
let devicePublicKey: string | null = null;
|
let devicePublicKey: string | null = null;
|
||||||
// Allow token-authenticated connections (e.g., control-ui) to skip device identity
|
const hasTokenAuth = Boolean(connectParams.auth?.token);
|
||||||
const hasTokenAuth = !!connectParams.auth?.token;
|
const hasPasswordAuth = Boolean(connectParams.auth?.password);
|
||||||
if (!device && !hasTokenAuth) {
|
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
|
||||||
setHandshakeState("failed");
|
|
||||||
setCloseCause("device-required", {
|
if (!device) {
|
||||||
client: connectParams.client.id,
|
const allowInsecureControlUi =
|
||||||
clientDisplayName: connectParams.client.displayName,
|
isControlUi && loadConfig().gateway?.controlUi?.allowInsecureAuth === true;
|
||||||
mode: connectParams.client.mode,
|
const canSkipDevice =
|
||||||
version: connectParams.client.version,
|
isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth;
|
||||||
});
|
|
||||||
send({
|
if (isControlUi && !allowInsecureControlUi) {
|
||||||
type: "res",
|
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
|
||||||
id: frame.id,
|
setHandshakeState("failed");
|
||||||
ok: false,
|
setCloseCause("control-ui-insecure-auth", {
|
||||||
error: errorShape(ErrorCodes.NOT_PAIRED, "device identity required"),
|
client: connectParams.client.id,
|
||||||
});
|
clientDisplayName: connectParams.client.displayName,
|
||||||
close(1008, "device identity required");
|
mode: connectParams.client.mode,
|
||||||
return;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (device) {
|
if (device) {
|
||||||
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
|
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ export const testState = {
|
|||||||
cronEnabled: false as boolean | undefined,
|
cronEnabled: false as boolean | undefined,
|
||||||
gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined,
|
gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined,
|
||||||
gatewayAuth: undefined as Record<string, unknown> | undefined,
|
gatewayAuth: undefined as Record<string, unknown> | undefined,
|
||||||
|
gatewayControlUi: undefined as Record<string, unknown> | undefined,
|
||||||
hooksConfig: undefined as HooksConfig | undefined,
|
hooksConfig: undefined as HooksConfig | undefined,
|
||||||
canvasHostPort: undefined as number | undefined,
|
canvasHostPort: undefined as number | undefined,
|
||||||
legacyIssues: [] as Array<{ path: string; message: string }>,
|
legacyIssues: [] as Array<{ path: string; message: string }>,
|
||||||
@@ -443,6 +444,7 @@ vi.mock("../config/config.js", async () => {
|
|||||||
: {};
|
: {};
|
||||||
if (testState.gatewayBind) fileGateway.bind = testState.gatewayBind;
|
if (testState.gatewayBind) fileGateway.bind = testState.gatewayBind;
|
||||||
if (testState.gatewayAuth) fileGateway.auth = testState.gatewayAuth;
|
if (testState.gatewayAuth) fileGateway.auth = testState.gatewayAuth;
|
||||||
|
if (testState.gatewayControlUi) fileGateway.controlUi = testState.gatewayControlUi;
|
||||||
const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined;
|
const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined;
|
||||||
|
|
||||||
const fileCanvasHost =
|
const fileCanvasHost =
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export function installGatewayTestHooks() {
|
|||||||
testTailnetIPv4.value = undefined;
|
testTailnetIPv4.value = undefined;
|
||||||
testState.gatewayBind = undefined;
|
testState.gatewayBind = undefined;
|
||||||
testState.gatewayAuth = undefined;
|
testState.gatewayAuth = undefined;
|
||||||
|
testState.gatewayControlUi = undefined;
|
||||||
testState.hooksConfig = undefined;
|
testState.hooksConfig = undefined;
|
||||||
testState.canvasHostPort = undefined;
|
testState.canvasHostPort = undefined;
|
||||||
testState.legacyIssues = [];
|
testState.legacyIssues = [];
|
||||||
@@ -280,7 +281,7 @@ export async function connectReq(
|
|||||||
signature: string;
|
signature: string;
|
||||||
signedAt: number;
|
signedAt: number;
|
||||||
nonce?: string;
|
nonce?: string;
|
||||||
};
|
} | null;
|
||||||
},
|
},
|
||||||
): Promise<ConnectResponse> {
|
): Promise<ConnectResponse> {
|
||||||
const { randomUUID } = await import("node:crypto");
|
const { randomUUID } = await import("node:crypto");
|
||||||
@@ -294,6 +295,7 @@ export async function connectReq(
|
|||||||
const role = opts?.role ?? "operator";
|
const role = opts?.role ?? "operator";
|
||||||
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
|
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
|
||||||
const device = (() => {
|
const device = (() => {
|
||||||
|
if (opts?.device === null) return undefined;
|
||||||
if (opts?.device) return opts.device;
|
if (opts?.device) return opts.device;
|
||||||
const identity = loadOrCreateDeviceIdentity();
|
const identity = loadOrCreateDeviceIdentity();
|
||||||
const signedAtMs = Date.now();
|
const signedAtMs = Date.now();
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ import path from "node:path";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
analyzeArgvCommand,
|
||||||
|
analyzeShellCommand,
|
||||||
|
isSafeBinUsage,
|
||||||
matchAllowlist,
|
matchAllowlist,
|
||||||
maxAsk,
|
maxAsk,
|
||||||
minSecurity,
|
minSecurity,
|
||||||
|
normalizeSafeBins,
|
||||||
resolveCommandResolution,
|
resolveCommandResolution,
|
||||||
resolveExecApprovals,
|
resolveExecApprovals,
|
||||||
type ExecAllowlistEntry,
|
type ExecAllowlistEntry,
|
||||||
@@ -18,7 +22,7 @@ function makeTempDir() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("exec approvals allowlist matching", () => {
|
describe("exec approvals allowlist matching", () => {
|
||||||
it("matches by executable name (case-insensitive)", () => {
|
it("ignores basename-only patterns", () => {
|
||||||
const resolution = {
|
const resolution = {
|
||||||
rawExecutable: "rg",
|
rawExecutable: "rg",
|
||||||
resolvedPath: "/opt/homebrew/bin/rg",
|
resolvedPath: "/opt/homebrew/bin/rg",
|
||||||
@@ -26,7 +30,7 @@ describe("exec approvals allowlist matching", () => {
|
|||||||
};
|
};
|
||||||
const entries: ExecAllowlistEntry[] = [{ pattern: "RG" }];
|
const entries: ExecAllowlistEntry[] = [{ pattern: "RG" }];
|
||||||
const match = matchAllowlist(entries, resolution);
|
const match = matchAllowlist(entries, resolution);
|
||||||
expect(match?.pattern).toBe("RG");
|
expect(match).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("matches by resolved path with **", () => {
|
it("matches by resolved path with **", () => {
|
||||||
@@ -51,7 +55,7 @@ describe("exec approvals allowlist matching", () => {
|
|||||||
expect(match).toBeNull();
|
expect(match).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to raw executable when no resolved path", () => {
|
it("requires a resolved path", () => {
|
||||||
const resolution = {
|
const resolution = {
|
||||||
rawExecutable: "bin/rg",
|
rawExecutable: "bin/rg",
|
||||||
resolvedPath: undefined,
|
resolvedPath: undefined,
|
||||||
@@ -59,7 +63,7 @@ describe("exec approvals allowlist matching", () => {
|
|||||||
};
|
};
|
||||||
const entries: ExecAllowlistEntry[] = [{ pattern: "bin/rg" }];
|
const entries: ExecAllowlistEntry[] = [{ pattern: "bin/rg" }];
|
||||||
const match = matchAllowlist(entries, resolution);
|
const match = matchAllowlist(entries, resolution);
|
||||||
expect(match?.pattern).toBe("bin/rg");
|
expect(match).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,6 +74,7 @@ describe("exec approvals command resolution", () => {
|
|||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
const exe = path.join(binDir, "rg");
|
const exe = path.join(binDir, "rg");
|
||||||
fs.writeFileSync(exe, "");
|
fs.writeFileSync(exe, "");
|
||||||
|
fs.chmodSync(exe, 0o755);
|
||||||
const res = resolveCommandResolution("rg -n foo", undefined, { PATH: binDir });
|
const res = resolveCommandResolution("rg -n foo", undefined, { PATH: binDir });
|
||||||
expect(res?.resolvedPath).toBe(exe);
|
expect(res?.resolvedPath).toBe(exe);
|
||||||
expect(res?.executableName).toBe("rg");
|
expect(res?.executableName).toBe("rg");
|
||||||
@@ -81,6 +86,7 @@ describe("exec approvals command resolution", () => {
|
|||||||
const script = path.join(cwd, "scripts", "run.sh");
|
const script = path.join(cwd, "scripts", "run.sh");
|
||||||
fs.mkdirSync(path.dirname(script), { recursive: true });
|
fs.mkdirSync(path.dirname(script), { recursive: true });
|
||||||
fs.writeFileSync(script, "");
|
fs.writeFileSync(script, "");
|
||||||
|
fs.chmodSync(script, 0o755);
|
||||||
const res = resolveCommandResolution("./scripts/run.sh --flag", cwd, undefined);
|
const res = resolveCommandResolution("./scripts/run.sh --flag", cwd, undefined);
|
||||||
expect(res?.resolvedPath).toBe(script);
|
expect(res?.resolvedPath).toBe(script);
|
||||||
});
|
});
|
||||||
@@ -91,11 +97,81 @@ describe("exec approvals command resolution", () => {
|
|||||||
const script = path.join(cwd, "bin", "tool");
|
const script = path.join(cwd, "bin", "tool");
|
||||||
fs.mkdirSync(path.dirname(script), { recursive: true });
|
fs.mkdirSync(path.dirname(script), { recursive: true });
|
||||||
fs.writeFileSync(script, "");
|
fs.writeFileSync(script, "");
|
||||||
|
fs.chmodSync(script, 0o755);
|
||||||
const res = resolveCommandResolution('"./bin/tool" --version', cwd, undefined);
|
const res = resolveCommandResolution('"./bin/tool" --version', cwd, undefined);
|
||||||
expect(res?.resolvedPath).toBe(script);
|
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 exe = path.join(binDir, "jq");
|
||||||
|
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 exe = path.join(binDir, "jq");
|
||||||
|
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", () => {
|
describe("exec approvals policy helpers", () => {
|
||||||
it("minSecurity returns the more restrictive value", () => {
|
it("minSecurity returns the more restrictive value", () => {
|
||||||
expect(minSecurity("deny", "full")).toBe("deny");
|
expect(minSecurity("deny", "full")).toBe("deny");
|
||||||
|
|||||||
+310
-13
@@ -60,6 +60,7 @@ const DEFAULT_ASK_FALLBACK: ExecSecurity = "deny";
|
|||||||
const DEFAULT_AUTO_ALLOW_SKILLS = false;
|
const DEFAULT_AUTO_ALLOW_SKILLS = false;
|
||||||
const DEFAULT_SOCKET = "~/.clawdbot/exec-approvals.sock";
|
const DEFAULT_SOCKET = "~/.clawdbot/exec-approvals.sock";
|
||||||
const DEFAULT_FILE = "~/.clawdbot/exec-approvals.json";
|
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 {
|
function hashExecApprovalsRaw(raw: string | null): string {
|
||||||
return crypto
|
return crypto
|
||||||
@@ -283,6 +284,19 @@ type CommandResolution = {
|
|||||||
executableName: string;
|
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 {
|
function parseFirstToken(command: string): string | null {
|
||||||
const trimmed = command.trim();
|
const trimmed = command.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
@@ -299,15 +313,26 @@ function parseFirstToken(command: string): string | null {
|
|||||||
function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS.ProcessEnv) {
|
function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS.ProcessEnv) {
|
||||||
const expanded = rawExecutable.startsWith("~") ? expandHome(rawExecutable) : rawExecutable;
|
const expanded = rawExecutable.startsWith("~") ? expandHome(rawExecutable) : rawExecutable;
|
||||||
if (expanded.includes("/") || expanded.includes("\\")) {
|
if (expanded.includes("/") || expanded.includes("\\")) {
|
||||||
if (path.isAbsolute(expanded)) return expanded;
|
if (path.isAbsolute(expanded)) {
|
||||||
|
return isExecutableFile(expanded) ? expanded : undefined;
|
||||||
|
}
|
||||||
const base = cwd && cwd.trim() ? cwd.trim() : process.cwd();
|
const base = cwd && cwd.trim() ? cwd.trim() : process.cwd();
|
||||||
return path.resolve(base, expanded);
|
const candidate = path.resolve(base, expanded);
|
||||||
|
return isExecutableFile(candidate) ? candidate : undefined;
|
||||||
}
|
}
|
||||||
const envPath = env?.PATH ?? process.env.PATH ?? "";
|
const envPath = env?.PATH ?? process.env.PATH ?? "";
|
||||||
const entries = envPath.split(path.delimiter).filter(Boolean);
|
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 entry of entries) {
|
||||||
const candidate = path.join(entry, expanded);
|
for (const ext of extensions) {
|
||||||
if (fs.existsSync(candidate)) return candidate;
|
const candidate = path.join(entry, expanded + ext);
|
||||||
|
if (isExecutableFile(candidate)) return candidate;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -324,6 +349,18 @@ export function resolveCommandResolution(
|
|||||||
return { rawExecutable, resolvedPath, executableName };
|
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 {
|
function normalizeMatchTarget(value: string): string {
|
||||||
return value.replace(/\\\\/g, "/").toLowerCase();
|
return value.replace(/\\\\/g, "/").toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -370,24 +407,284 @@ export function matchAllowlist(
|
|||||||
entries: ExecAllowlistEntry[],
|
entries: ExecAllowlistEntry[],
|
||||||
resolution: CommandResolution | null,
|
resolution: CommandResolution | null,
|
||||||
): ExecAllowlistEntry | null {
|
): ExecAllowlistEntry | null {
|
||||||
if (!entries.length || !resolution) return null;
|
if (!entries.length || !resolution?.resolvedPath) return null;
|
||||||
const rawExecutable = resolution.rawExecutable;
|
|
||||||
const resolvedPath = resolution.resolvedPath;
|
const resolvedPath = resolution.resolvedPath;
|
||||||
const executableName = resolution.executableName;
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const pattern = entry.pattern?.trim();
|
const pattern = entry.pattern?.trim();
|
||||||
if (!pattern) continue;
|
if (!pattern) continue;
|
||||||
const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~");
|
const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~");
|
||||||
if (hasPath) {
|
if (!hasPath) continue;
|
||||||
const target = resolvedPath ?? rawExecutable;
|
if (matchesPattern(pattern, resolvedPath)) return entry;
|
||||||
if (target && matchesPattern(pattern, target)) return entry;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (executableName && matchesPattern(pattern, executableName)) return entry;
|
|
||||||
}
|
}
|
||||||
return null;
|
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 || !params.safeBins.has(execName)) 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(
|
export function recordAllowlistUse(
|
||||||
approvals: ExecApprovalsFile,
|
approvals: ExecApprovalsFile,
|
||||||
agentId: string | undefined,
|
agentId: string | undefined,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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,3 +1,33 @@
|
|||||||
|
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 {
|
export class GatewayLockError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
@@ -7,3 +37,117 @@ export class GatewayLockError extends Error {
|
|||||||
this.name = "GatewayLockError";
|
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`);
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseActiveHoursTime(raw: string | undefined, opts: { allow24: boolean }): number | null {
|
function parseActiveHoursTime(opts: { allow24: boolean }, raw?: string): number | null {
|
||||||
if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) return null;
|
if (!raw || !ACTIVE_HOURS_TIME_PATTERN.test(raw)) return null;
|
||||||
const [hourStr, minuteStr] = raw.split(":");
|
const [hourStr, minuteStr] = raw.split(":");
|
||||||
const hour = Number(hourStr);
|
const hour = Number(hourStr);
|
||||||
@@ -131,8 +131,8 @@ function isWithinActiveHours(
|
|||||||
const active = heartbeat?.activeHours;
|
const active = heartbeat?.activeHours;
|
||||||
if (!active) return true;
|
if (!active) return true;
|
||||||
|
|
||||||
const startMin = parseActiveHoursTime(active.start, { allow24: false });
|
const startMin = parseActiveHoursTime({ allow24: false }, active.start);
|
||||||
const endMin = parseActiveHoursTime(active.end, { allow24: true });
|
const endMin = parseActiveHoursTime({ allow24: true }, active.end);
|
||||||
if (startMin === null || endMin === null) return true;
|
if (startMin === null || endMin === null) return true;
|
||||||
if (startMin === endMin) return true;
|
if (startMin === endMin) return true;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
|
import type { GatewayLockHandle } from "../infra/gateway-lock.js";
|
||||||
|
|
||||||
declare const __CLAWDBOT_VERSION__: string;
|
declare const __CLAWDBOT_VERSION__: string;
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ async function main() {
|
|||||||
{ startGatewayServer },
|
{ startGatewayServer },
|
||||||
{ setGatewayWsLogStyle },
|
{ setGatewayWsLogStyle },
|
||||||
{ setVerbose },
|
{ setVerbose },
|
||||||
|
{ acquireGatewayLock, GatewayLockError },
|
||||||
{ consumeGatewaySigusr1RestartAuthorization, isGatewaySigusr1RestartExternallyAllowed },
|
{ consumeGatewaySigusr1RestartAuthorization, isGatewaySigusr1RestartExternallyAllowed },
|
||||||
{ defaultRuntime },
|
{ defaultRuntime },
|
||||||
{ enableConsoleCapture, setConsoleTimestampPrefix },
|
{ enableConsoleCapture, setConsoleTimestampPrefix },
|
||||||
@@ -53,10 +55,11 @@ async function main() {
|
|||||||
import("../gateway/server.js"),
|
import("../gateway/server.js"),
|
||||||
import("../gateway/ws-logging.js"),
|
import("../gateway/ws-logging.js"),
|
||||||
import("../globals.js"),
|
import("../globals.js"),
|
||||||
|
import("../infra/gateway-lock.js"),
|
||||||
import("../infra/restart.js"),
|
import("../infra/restart.js"),
|
||||||
import("../runtime.js"),
|
import("../runtime.js"),
|
||||||
import("../logging.js"),
|
import("../logging.js"),
|
||||||
]);
|
] as const);
|
||||||
|
|
||||||
enableConsoleCapture();
|
enableConsoleCapture();
|
||||||
setConsoleTimestampPrefix(true);
|
setConsoleTimestampPrefix(true);
|
||||||
@@ -103,6 +106,7 @@ async function main() {
|
|||||||
if (token) process.env.CLAWDBOT_GATEWAY_TOKEN = token;
|
if (token) process.env.CLAWDBOT_GATEWAY_TOKEN = token;
|
||||||
|
|
||||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
|
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
|
||||||
|
let lock: GatewayLockHandle | null = null;
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
let forceExitTimer: ReturnType<typeof setTimeout> | null = null;
|
let forceExitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let restartResolver: (() => void) | null = null;
|
let restartResolver: (() => void) | null = null;
|
||||||
@@ -177,6 +181,15 @@ async function main() {
|
|||||||
process.on("SIGUSR1", onSigusr1);
|
process.on("SIGUSR1", onSigusr1);
|
||||||
|
|
||||||
try {
|
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
|
// eslint-disable-next-line no-constant-condition
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
@@ -191,6 +204,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
await (lock as GatewayLockHandle | null)?.release();
|
||||||
cleanupSignals();
|
cleanupSignals();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-13
@@ -4,12 +4,16 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type ExecAllowlistEntry,
|
||||||
addAllowlistEntry,
|
addAllowlistEntry,
|
||||||
|
analyzeArgvCommand,
|
||||||
|
analyzeShellCommand,
|
||||||
|
isSafeBinUsage,
|
||||||
matchAllowlist,
|
matchAllowlist,
|
||||||
normalizeExecApprovals,
|
normalizeExecApprovals,
|
||||||
recordAllowlistUse,
|
recordAllowlistUse,
|
||||||
resolveCommandResolution,
|
|
||||||
resolveExecApprovals,
|
resolveExecApprovals,
|
||||||
|
resolveSafeBins,
|
||||||
ensureExecApprovals,
|
ensureExecApprovals,
|
||||||
readExecApprovalsSnapshot,
|
readExecApprovalsSnapshot,
|
||||||
resolveExecApprovalsSocketPath,
|
resolveExecApprovalsSocketPath,
|
||||||
@@ -25,6 +29,7 @@ import {
|
|||||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { resolveAgentConfig } from "../agents/agent-scope.js";
|
||||||
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
@@ -110,7 +115,6 @@ const execHostFallbackAllowed =
|
|||||||
process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0";
|
process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0";
|
||||||
|
|
||||||
const blockedEnvKeys = new Set([
|
const blockedEnvKeys = new Set([
|
||||||
"PATH",
|
|
||||||
"NODE_OPTIONS",
|
"NODE_OPTIONS",
|
||||||
"PYTHONHOME",
|
"PYTHONHOME",
|
||||||
"PYTHONPATH",
|
"PYTHONPATH",
|
||||||
@@ -156,10 +160,24 @@ function sanitizeEnv(
|
|||||||
): Record<string, string> | undefined {
|
): Record<string, string> | undefined {
|
||||||
if (!overrides) return undefined;
|
if (!overrides) return undefined;
|
||||||
const merged = { ...process.env } as Record<string, string>;
|
const merged = { ...process.env } as Record<string, string>;
|
||||||
|
const basePath = process.env.PATH ?? DEFAULT_NODE_PATH;
|
||||||
for (const [rawKey, value] of Object.entries(overrides)) {
|
for (const [rawKey, value] of Object.entries(overrides)) {
|
||||||
const key = rawKey.trim();
|
const key = rawKey.trim();
|
||||||
if (!key) continue;
|
if (!key) continue;
|
||||||
const upper = key.toUpperCase();
|
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 (blockedEnvKeys.has(upper)) continue;
|
||||||
if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) continue;
|
if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) continue;
|
||||||
merged[key] = value;
|
merged[key] = value;
|
||||||
@@ -567,12 +585,32 @@ async function handleInvoke(
|
|||||||
const sessionKey = params.sessionKey?.trim() || "node";
|
const sessionKey = params.sessionKey?.trim() || "node";
|
||||||
const runId = crypto.randomUUID();
|
const runId = crypto.randomUUID();
|
||||||
const env = sanitizeEnv(params.env ?? undefined);
|
const env = sanitizeEnv(params.env ?? undefined);
|
||||||
const resolution = resolveCommandResolution(cmdText, params.cwd ?? undefined, env);
|
const analysis = rawCommand
|
||||||
const allowlistMatch =
|
? analyzeShellCommand({ command: rawCommand, cwd: params.cwd ?? undefined, env })
|
||||||
security === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
|
: 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 bins = autoAllowSkills ? await skillBins.current() : new Set<string>();
|
const bins = autoAllowSkills ? await skillBins.current() : new Set<string>();
|
||||||
const skillAllow =
|
const allowlistSatisfied =
|
||||||
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
|
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 useMacAppExec = process.platform === "darwin";
|
const useMacAppExec = process.platform === "darwin";
|
||||||
if (useMacAppExec) {
|
if (useMacAppExec) {
|
||||||
@@ -678,7 +716,7 @@ async function handleInvoke(
|
|||||||
|
|
||||||
const requiresAsk =
|
const requiresAsk =
|
||||||
ask === "always" ||
|
ask === "always" ||
|
||||||
(ask === "on-miss" && security === "allowlist" && !allowlistMatch && !skillAllow);
|
(ask === "on-miss" && security === "allowlist" && (!analysis.ok || !allowlistSatisfied));
|
||||||
|
|
||||||
const approvalDecision =
|
const approvalDecision =
|
||||||
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
|
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
|
||||||
@@ -704,11 +742,15 @@ async function handleInvoke(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (approvalDecision === "allow-always" && security === "allowlist") {
|
if (approvalDecision === "allow-always" && security === "allowlist") {
|
||||||
const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? "";
|
if (analysis.ok) {
|
||||||
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
|
for (const segment of analysis.segments) {
|
||||||
|
const pattern = segment.resolution?.resolvedPath ?? "";
|
||||||
|
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (security === "allowlist" && !allowlistMatch && !skillAllow && !approvedByAsk) {
|
if (security === "allowlist" && (!analysis.ok || !allowlistSatisfied) && !approvedByAsk) {
|
||||||
await sendNodeEvent(
|
await sendNodeEvent(
|
||||||
client,
|
client,
|
||||||
"exec.denied",
|
"exec.denied",
|
||||||
@@ -727,8 +769,19 @@ async function handleInvoke(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowlistMatch) {
|
if (allowlistMatches.length > 0) {
|
||||||
recordAllowlistUse(approvals.file, agentId, allowlistMatch, cmdText, resolution?.resolvedPath);
|
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 (params.needsScreenRecording === true) {
|
if (params.needsScreenRecording === true) {
|
||||||
|
|||||||
@@ -227,6 +227,29 @@ 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 () => {
|
it("warns when multiple DM senders share the main session", async () => {
|
||||||
const cfg: ClawdbotConfig = { session: { dmScope: "main" } };
|
const cfg: ClawdbotConfig = { session: { dmScope: "main" } };
|
||||||
const plugins: ChannelPlugin[] = [
|
const plugins: ChannelPlugin[] = [
|
||||||
|
|||||||
@@ -235,6 +235,17 @@ 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 =
|
const token =
|
||||||
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
|
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
|
||||||
if (auth.mode === "token" && token && token.length < 24) {
|
if (auth.mode === "token" && token && token.length < 24) {
|
||||||
|
|||||||
+55
-29
@@ -126,15 +126,27 @@ export class GatewayBrowserClient {
|
|||||||
window.clearTimeout(this.connectTimer);
|
window.clearTimeout(this.connectTimer);
|
||||||
this.connectTimer = null;
|
this.connectTimer = null;
|
||||||
}
|
}
|
||||||
const deviceIdentity = await loadOrCreateDeviceIdentity();
|
|
||||||
|
// 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 scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
|
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
|
||||||
const role = "operator";
|
const role = "operator";
|
||||||
const storedToken = loadDeviceAuthToken({
|
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
|
||||||
deviceId: deviceIdentity.deviceId,
|
let canFallbackToShared = false;
|
||||||
role,
|
let authToken = this.opts.token;
|
||||||
})?.token;
|
|
||||||
const authToken = storedToken ?? this.opts.token;
|
if (isSecureContext) {
|
||||||
const canFallbackToShared = Boolean(storedToken && this.opts.token);
|
deviceIdentity = await loadOrCreateDeviceIdentity();
|
||||||
|
const storedToken = loadDeviceAuthToken({
|
||||||
|
deviceId: deviceIdentity.deviceId,
|
||||||
|
role,
|
||||||
|
})?.token;
|
||||||
|
authToken = storedToken ?? this.opts.token;
|
||||||
|
canFallbackToShared = Boolean(storedToken && this.opts.token);
|
||||||
|
}
|
||||||
const auth =
|
const auth =
|
||||||
authToken || this.opts.password
|
authToken || this.opts.password
|
||||||
? {
|
? {
|
||||||
@@ -142,19 +154,39 @@ export class GatewayBrowserClient {
|
|||||||
password: this.opts.password,
|
password: this.opts.password,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const signedAtMs = Date.now();
|
|
||||||
const nonce = this.connectNonce ?? undefined;
|
let device:
|
||||||
const payload = buildDeviceAuthPayload({
|
| {
|
||||||
deviceId: deviceIdentity.deviceId,
|
id: string;
|
||||||
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
publicKey: string;
|
||||||
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
|
signature: string;
|
||||||
role,
|
signedAt: number;
|
||||||
scopes,
|
nonce: string | undefined;
|
||||||
signedAtMs,
|
}
|
||||||
token: authToken ?? null,
|
| undefined;
|
||||||
nonce,
|
|
||||||
});
|
if (isSecureContext && deviceIdentity) {
|
||||||
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
|
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 params = {
|
const params = {
|
||||||
minProtocol: 3,
|
minProtocol: 3,
|
||||||
maxProtocol: 3,
|
maxProtocol: 3,
|
||||||
@@ -167,13 +199,7 @@ export class GatewayBrowserClient {
|
|||||||
},
|
},
|
||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
device: {
|
device,
|
||||||
id: deviceIdentity.deviceId,
|
|
||||||
publicKey: deviceIdentity.publicKey,
|
|
||||||
signature,
|
|
||||||
signedAt: signedAtMs,
|
|
||||||
nonce,
|
|
||||||
},
|
|
||||||
caps: [],
|
caps: [],
|
||||||
auth,
|
auth,
|
||||||
userAgent: navigator.userAgent,
|
userAgent: navigator.userAgent,
|
||||||
@@ -182,7 +208,7 @@ export class GatewayBrowserClient {
|
|||||||
|
|
||||||
void this.request<GatewayHelloOk>("connect", params)
|
void this.request<GatewayHelloOk>("connect", params)
|
||||||
.then((hello) => {
|
.then((hello) => {
|
||||||
if (hello?.auth?.deviceToken) {
|
if (hello?.auth?.deviceToken && deviceIdentity) {
|
||||||
storeDeviceAuthToken({
|
storeDeviceAuthToken({
|
||||||
deviceId: deviceIdentity.deviceId,
|
deviceId: deviceIdentity.deviceId,
|
||||||
role: hello.auth.role ?? role,
|
role: hello.auth.role ?? role,
|
||||||
@@ -194,7 +220,7 @@ export class GatewayBrowserClient {
|
|||||||
this.opts.onHello?.(hello);
|
this.opts.onHello?.(hello);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (canFallbackToShared) {
|
if (canFallbackToShared && deviceIdentity) {
|
||||||
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
|
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
|
||||||
}
|
}
|
||||||
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
|
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
|
||||||
|
|||||||
@@ -77,6 +77,44 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
</div>
|
</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`
|
return html`
|
||||||
<section class="grid grid-cols-2">
|
<section class="grid grid-cols-2">
|
||||||
@@ -167,6 +205,7 @@ export function renderOverview(props: OverviewProps) {
|
|||||||
? html`<div class="callout danger" style="margin-top: 14px;">
|
? html`<div class="callout danger" style="margin-top: 14px;">
|
||||||
<div>${props.lastError}</div>
|
<div>${props.lastError}</div>
|
||||||
${authHint ?? ""}
|
${authHint ?? ""}
|
||||||
|
${insecureContextHint ?? ""}
|
||||||
</div>`
|
</div>`
|
||||||
: html`<div class="callout" style="margin-top: 14px;">
|
: html`<div class="callout" style="margin-top: 14px;">
|
||||||
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
|
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.
|
||||||
|
|||||||
Reference in new issue
Block a user