Compare commits

..
Author SHA1 Message Date
Peter Steinberger 4997a5b93f fix: improve macOS exec approvals 2026-01-22 00:46:31 +00:00
Nimrod Gutman 1092b30531 fix(node): handle invoke approvals and errors 2026-01-22 00:46:31 +00:00
Peter Steinberger 7d93de710e fix: remove setup-token run option in onboarding 2026-01-22 00:42:04 +00:00
Peter Steinberger 39b375e32b Merge pull request #1396 from JustYannicc/fix/macos-x86-universal-build
fix(mac): default to universal binary for distribution builds
2026-01-22 00:32:06 +00:00
Peter Steinberger 3b6ec501aa Merge origin/main into fix/macos-x86-universal-build 2026-01-22 00:31:54 +00:00
Peter Steinberger 2b254a9b39 fix: refine model directive handling 2026-01-22 00:29:27 +00:00
Clawd 429a2d7849 fix(mac): default to universal binary for distribution builds
Closes #1393

The distribution script (package-mac-dist.sh) now defaults BUILD_ARCHS to 'all',
producing universal binaries that run natively on both Apple Silicon and Intel Macs.

Previously, the script inherited the host architecture default from package-mac-app.sh,
which meant release builds done on ARM Macs only included ARM binaries.
2026-01-22 00:29:27 +00:00
Peter Steinberger 1cce83b21e fix: refine model directive handling 2026-01-22 00:28:49 +00:00
Clawd 8255e4649c fix(mac): default to universal binary for distribution builds
Closes #1393

The distribution script (package-mac-dist.sh) now defaults BUILD_ARCHS to 'all',
producing universal binaries that run natively on both Apple Silicon and Intel Macs.

Previously, the script inherited the host architecture default from package-mac-app.sh,
which meant release builds done on ARM Macs only included ARM binaries.
2026-01-22 00:28:49 +00:00
Peter Steinberger 7eef176afc fix: warn on unset gateway.mode 2026-01-22 00:21:08 +00:00
Peter Steinberger 06e496540f Merge pull request #1379 from ptn1411/feature/1378-zalouser-extension
Refs #1378: scaffold zalouser extension
2026-01-22 00:00:29 +00:00
Peter Steinberger f76e3c1419 fix: enforce secure control ui auth 2026-01-21 23:58:42 +00:00
Peter Steinberger b4776af38c docs: clarify mac packaging guidance 2026-01-21 23:27:40 +00:00
Peter Steinberger cd65e8e755 fix: type gateway lock handle 2026-01-21 23:05:11 +00:00
Peter Steinberger 28e547f120 fix: stabilize ci 2026-01-21 22:59:11 +00:00
Peter Steinberger 05a254746e fix(gateway): enforce singleton lock 2026-01-21 22:47:18 +00:00
Peter Steinberger 529372f762 Merge pull request #1398 from vignesh07/feat/models-command
fix(chat): add /models and stop /model from dumping full model list
2026-01-21 21:54:16 +00:00
Peter Steinberger 3b18efdd25 feat: tighten exec allowlist gating 2026-01-21 21:45:50 +00:00
Vignesh Natarajan 6e044b5f2f fix(models): include configured providers/models + ignore page with all 2026-01-21 13:14:18 -08:00
Vignesh Natarajan 310f916675 fix(models): handle out-of-range pages 2026-01-21 12:54:02 -08:00
Vignesh Natarajan 41d56c06b9 feat(commands): add /models and fix /model listing UX 2026-01-21 11:53:29 -08:00
Pham Nam a90fe1b245 Refs #1378: scaffold zalouser extension 2026-01-21 19:48:21 +07:00
68 changed files with 1518 additions and 395 deletions

No files matched your search

+1
View File
@@ -29,6 +29,7 @@
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
- Node remains supported for running built output (`dist/*`) and production installs.
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
- Type-check/build: `pnpm build` (tsc)
- Lint/format: `pnpm lint` (oxlint), `pnpm format` (oxfmt)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
+16 -1
View File
@@ -2,32 +2,47 @@
Docs: https://docs.clawd.bot
## 2026.1.22
### Changes
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
### Fixes
- Doctor: warn when gateway.mode is unset with configure/config guidance.
## 2026.1.21
### Changes
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
- CLI: exec approvals mutations render tables instead of raw JSON.
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
### Breaking
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
### Fixes
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock).
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
- Model picker: list the full catalog when no model allowlist is configured.
- Chat: include configured defaults/providers in `/models` output and normalize all-mode paging. (#1398) Thanks @vignesh07.
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
## 2026.1.20
@@ -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 {
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
guard let resolution, !entries.isEmpty else { return nil }
@@ -314,7 +314,7 @@ private enum ExecHostExecutor {
}
var approvedByAsk = approvalDecision != nil
if self.requiresAsk(
if ExecApprovalHelpers.requiresAsk(
ask: context.ask,
security: context.security,
allowlistMatch: context.allowlistMatch,
@@ -417,36 +417,20 @@ private enum ExecHostExecutor {
skillAllow: skillAllow)
}
private static func requiresAsk(
ask: ExecAsk,
security: ExecSecurity,
allowlistMatch: ExecAllowlistEntry?,
skillAllow: Bool) -> Bool
{
if ask == .always { return true }
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
return false
}
private static func persistAllowlistEntry(
decision: ExecApprovalDecision?,
context: ExecApprovalContext)
{
guard decision == .allowAlways, context.security == .allowlist else { return }
guard let pattern = self.allowlistPattern(command: context.command, resolution: context.resolution) else {
guard let pattern = ExecApprovalHelpers.allowlistPattern(
command: context.command,
resolution: context.resolution)
else {
return
}
ExecApprovalsStore.addAllowlistEntry(agentId: context.trimmedAgent, pattern: pattern)
}
private static func allowlistPattern(
command: [String],
resolution: ExecCommandResolution?) -> String?
{
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
return pattern.isEmpty ? nil : pattern
}
private static func ensureScreenRecordingAccess(_ needsScreenRecording: Bool?) async -> ExecHostResponse? {
guard needsScreenRecording == true else { return nil }
let authorized = await PermissionManager
@@ -480,14 +480,16 @@ actor MacNodeRuntime {
message: "SYSTEM_RUN_DISABLED: security=deny")
}
let requiresAsk: Bool = {
if ask == .always { return true }
if ask == .onMiss, security == .allowlist, allowlistMatch == nil, !skillAllow { return true }
return false
}()
let requiresAsk = ExecApprovalHelpers.requiresAsk(
ask: ask,
security: security,
allowlistMatch: allowlistMatch,
skillAllow: skillAllow)
let approvedByAsk = params.approved == true
if requiresAsk, !approvedByAsk {
let decisionFromParams = ExecApprovalHelpers.parseDecision(params.approvalDecision)
var approvedByAsk = params.approved == true || decisionFromParams != nil
var persistAllowlist = decisionFromParams == .allowAlways
if decisionFromParams == .deny {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
@@ -495,11 +497,49 @@ actor MacNodeRuntime {
runId: runId,
host: "node",
command: displayCommand,
reason: "approval-required"))
reason: "user-denied"))
return Self.errorResponse(
req,
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 {
@@ -18,6 +18,7 @@ public struct ConnectParams: Codable, Sendable {
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: AnyCodable]?
public let pathenv: String?
public let role: String?
public let scopes: [String]?
public let device: [String: AnyCodable]?
@@ -32,6 +33,7 @@ public struct ConnectParams: Codable, Sendable {
caps: [String]?,
commands: [String]?,
permissions: [String: AnyCodable]?,
pathenv: String?,
role: String?,
scopes: [String]?,
device: [String: AnyCodable]?,
@@ -45,6 +47,7 @@ public struct ConnectParams: Codable, Sendable {
self.caps = caps
self.commands = commands
self.permissions = permissions
self.pathenv = pathenv
self.role = role
self.scopes = scopes
self.device = device
@@ -59,6 +62,7 @@ public struct ConnectParams: Codable, Sendable {
case caps
case commands
case permissions
case pathenv = "pathEnv"
case role
case scopes
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,
method: method,
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
self.pending[id] = cont
Task { [weak self] in
@@ -219,8 +219,8 @@ public actor GatewayNodeSession {
}
if let error = response.error {
params["error"] = AnyCodable([
"code": AnyCodable(error.code.rawValue),
"message": AnyCodable(error.message),
"code": error.code.rawValue,
"message": error.message,
])
}
do {
@@ -30,6 +30,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
public var agentId: String?
public var sessionKey: String?
public var approved: Bool?
public var approvalDecision: String?
public init(
command: [String],
@@ -40,7 +41,8 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
needsScreenRecording: Bool? = nil,
agentId: String? = nil,
sessionKey: String? = nil,
approved: Bool? = nil)
approved: Bool? = nil,
approvalDecision: String? = nil)
{
self.command = command
self.rawCommand = rawCommand
@@ -51,6 +53,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
self.agentId = agentId
self.sessionKey = sessionKey
self.approved = approved
self.approvalDecision = approvalDecision
}
}
@@ -18,6 +18,7 @@ public struct ConnectParams: Codable, Sendable {
public let caps: [String]?
public let commands: [String]?
public let permissions: [String: AnyCodable]?
public let pathenv: String?
public let role: String?
public let scopes: [String]?
public let device: [String: AnyCodable]?
@@ -32,6 +33,7 @@ public struct ConnectParams: Codable, Sendable {
caps: [String]?,
commands: [String]?,
permissions: [String: AnyCodable]?,
pathenv: String?,
role: String?,
scopes: [String]?,
device: [String: AnyCodable]?,
@@ -45,6 +47,7 @@ public struct ConnectParams: Codable, Sendable {
self.caps = caps
self.commands = commands
self.permissions = permissions
self.pathenv = pathenv
self.role = role
self.scopes = scopes
self.device = device
@@ -59,6 +62,7 @@ public struct ConnectParams: Codable, Sendable {
case caps
case commands
case permissions
case pathenv = "pathEnv"
case role
case scopes
case device
+1 -1
View File
@@ -38,7 +38,7 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
- Provider: `anthropic`
- Auth: `ANTHROPIC_API_KEY` or `claude setup-token`
- Example model: `anthropic/claude-opus-4-5`
- CLI: `clawdbot onboard --auth-choice setup-token`
- CLI: `clawdbot onboard --auth-choice token` (paste setup-token) or `clawdbot models auth paste-token --provider anthropic`
```json5
{
+2
View File
@@ -2671,6 +2671,8 @@ Control UI base path:
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
- Default: root (`/`) (unchanged).
- `gateway.controlUi.allowInsecureAuth` allows token-only auth over **HTTP** (no device identity).
Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`.
Related docs:
- [Control UI](/web/control-ui)
+1
View File
@@ -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
(so same‑host tailnet binds can still auto‑approve).
- All WS clients must include `device` identity during `connect` (operator + node).
Control UI can omit it **only** when `gateway.controlUi.allowInsecureAuth` is enabled.
- Non-local connections must sign the server-provided `connect.challenge` nonce.
## TLS + pinning
+9
View File
@@ -52,6 +52,15 @@ When the audit prints findings, treat this as a priority order:
5. **Plugins/extensions**: only load what you explicitly trust.
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
## Control UI over HTTP
The Control UI needs a **secure context** (HTTPS or localhost) to generate device
identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back
to **token-only auth** on plain HTTP and skips device pairing. This is a security
downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`.
`clawdbot security audit` warns when this setting is enabled.
## Local session logs live on disk
Clawdbot stores session transcripts on disk under `~/.clawdbot/agents/<agentId>/sessions/*.jsonl`.
+41
View File
@@ -31,6 +31,19 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
## Common Issues
### Control UI fails on HTTP ("device identity required" / "connect failed")
If you open the dashboard over plain HTTP (e.g. `http://<lan-ip>:18789/` or
`http://<tailscale-ip>:18789/`), the browser runs in a **non-secure context** and
blocks WebCrypto, so device identity can’t be generated.
**Fix:**
- Prefer HTTPS via [Tailscale Serve](/gateway/tailscale).
- Or open locally on the gateway host: `http://127.0.0.1:18789/`.
- If you must stay on HTTP, enable `gateway.controlUi.allowInsecureAuth: true` and
use a gateway token (token-only; no device identity/pairing). See
[Control UI](/web/control-ui#insecure-http).
### CI Secrets Scan Failed
This means `detect-secrets` found new candidates not yet in the baseline.
@@ -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.
### "Gateway start blocked: set gateway.mode=local"
This means the config exists but `gateway.mode` is unset (or not `local`), so the
Gateway refuses to start.
**Fix (recommended):**
- Run the wizard and set the Gateway run mode to **Local**:
```bash
clawdbot configure
```
- Or set it directly:
```bash
clawdbot config set gateway.mode local
```
**If you meant to run a remote Gateway instead:**
- Set a remote URL and keep `gateway.mode=remote`:
```bash
clawdbot config set gateway.mode remote
clawdbot config set gateway.remote.url "wss://gateway.example.com"
```
**Ad-hoc/dev only:** pass `--allow-unconfigured` to start the gateway without
`gateway.mode=local`.
**No config file yet?** Run `clawdbot setup` to create a starter config, then rerun
the gateway.
### Service Environment (PATH + runtime)
The gateway service runs with a **minimal PATH** to avoid shell/manager cruft:
+5
View File
@@ -38,6 +38,11 @@ Almost always a Node/npm PATH issue. Start here:
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Gateway authentication](/gateway/authentication)
### Control UI fails on HTTP (device identity required)
- [Gateway troubleshooting](/gateway/troubleshooting)
- [Control UI](/web/control-ui#insecure-http)
### Service says running, but RPC probe fails
- [Gateway troubleshooting](/gateway/troubleshooting)
+1
View File
@@ -24,6 +24,7 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
Notes:
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
- Defaults to the current architecture (`$(uname -m)`). For release/universal builds, set `BUILD_ARCHS="arm64 x86_64"` (or `BUILD_ARCHS=all`).
- Use `scripts/package-mac-dist.sh` for release artifacts (zip + DMG + notarization). Use `scripts/package-mac-app.sh` for local/dev packaging.
```bash
# From repo root; set release IDs so Sparkle feed is enabled.
+2 -7
View File
@@ -70,11 +70,9 @@ Setup-tokens are created by the **Claude Code CLI**, not the Anthropic Console.
claude setup-token
```
Paste the token into Clawdbot (wizard: **Anthropic token (paste setup-token)**), or 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
clawdbot onboard --auth-choice setup-token
# or
clawdbot models auth setup-token --provider anthropic
```
@@ -87,9 +85,6 @@ clawdbot models auth paste-token --provider anthropic
### CLI setup
```bash
# Run setup-token locally (wizard can run it for you)
clawdbot onboard --auth-choice setup-token
# Reuse Claude Code CLI OAuth credentials if already logged in
clawdbot onboard --auth-choice claude-cli
```
@@ -104,7 +99,7 @@ clawdbot onboard --auth-choice claude-cli
## 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
accepts both OAuth and setup-token credentials. Older configs using `"token"` are
auto-migrated on load.
+3 -3
View File
@@ -241,7 +241,7 @@ It also warns if your configured model is unknown or missing auth.
### How does Anthropic "setup-token" auth work?
`claude setup-token` generates a **token string** via the Claude Code CLI (it is not available in the web console). You can run it on **any machine**. If 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
the profile accepts both OAuth and setup-token credentials; older `"token"` mode
@@ -255,11 +255,11 @@ It is **not** in the Anthropic Console. The setup-token is generated by the **Cl
claude setup-token
```
Copy the token it prints, then choose **Anthropic token (paste setup-token)** in the wizard. If you want 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)?
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.
+2 -3
View File
@@ -45,7 +45,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
## What the wizard does
**Local mode (default)** walks you through:
- Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or `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
- Gateway settings (port/bind/auth/tailscale)
- Providers (Telegram, WhatsApp, Discord, Signal)
@@ -79,9 +79,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
2) **Model/Auth**
- **Anthropic API key (recommended)**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
- **Anthropic token (setup-token)**: run `claude setup-token` locally (the wizard can run it for you and reuse the token) or run it elsewhere and paste the token.
- **Anthropic OAuth (Claude Code CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
- **Anthropic token (paste setup-token)**: run `claude setup-token` 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 (OAuth)**: browser flow; paste the `code#state`.
- Sets `agents.defaults.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`.
+10
View File
@@ -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
editing in the macOS app. Patterns are **case-insensitive glob matches**.
Patterns should resolve to **binary paths** (basename-only entries are ignored).
Examples:
- `~/Projects/**/bin/bird`
@@ -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
gateway for the skill bin list. Disable this if you want strict manual allowlists.
## Safe bins (stdin-only)
`tools.exec.safeBins` defines a small list of **stdin-only** binaries (for example `jq`)
that can run in allowlist mode **without** explicit allowlist entries. Safe bins reject
positional file args and path-like tokens, so they can only operate on the incoming stream.
Shell chaining and redirections are not auto-allowed in allowlist mode.
Default safe bins: `jq`, `grep`, `cut`, `sort`, `uniq`, `head`, `tail`, `tr`, `wc`.
## Control UI editing
Use the **Control UI → Nodes → Exec approvals** card to edit defaults, per‑agent
+10 -1
View File
@@ -43,6 +43,7 @@ Notes:
- `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset)
- `tools.exec.pathPrepend`: list of directories to prepend to `PATH` for exec runs.
- `tools.exec.safeBins`: stdin-only safe binaries that can run without explicit allowlist entries.
Example:
```json5
@@ -64,7 +65,8 @@ Example:
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
if the exec call already sets `env.PATH`.
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):
@@ -90,6 +92,13 @@ Example:
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
## Allowlist + safe bins
Allowlist enforcement matches **resolved binary paths only** (no basename matches). When
`security=allowlist`, shell commands are auto-allowed only if every pipeline segment is
allowlisted or a safe bin. Chaining (`;`, `&&`, `||`) and redirections are rejected in
allowlist mode.
## Examples
Foreground:
+27
View File
@@ -86,6 +86,33 @@ Then open:
Paste the token into the UI settings (sent as `connect.params.auth.token`).
## Insecure HTTP
If you open the dashboard over plain HTTP (`http://<lan-ip>` or `http://<tailscale-ip>`),
the browser runs in a **non-secure context** and blocks WebCrypto. By default,
Clawdbot **blocks** Control UI connections without device identity.
**Recommended fix:** use HTTPS (Tailscale Serve) or open the UI locally:
- `https://<magicdns>/` (Serve)
- `http://127.0.0.1:18789/` (on the gateway host)
**Downgrade example (token-only over HTTP):**
```json5
{
gateway: {
controlUi: { allowInsecureAuth: true },
bind: "tailnet",
auth: { mode: "token", token: "replace-me" }
}
}
```
This disables device identity + pairing for the Control UI. Use only if you
trust the network.
See [Tailscale](/gateway/tailscale) for HTTPS setup guidance.
## Building the UI
The Gateway serves static files from `dist/control-ui`. Build them with:
+26 -1
View File
@@ -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 {
try {
return JSON.parse(stdout) as T;
} 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;
}
}
}
+3
View File
@@ -10,6 +10,9 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
# Default to universal binary for distribution builds (supports both Apple Silicon and Intel Macs)
export BUILD_ARCHS="${BUILD_ARCHS:-all}"
"$ROOT_DIR/scripts/package-mac-app.sh"
APP="$ROOT_DIR/dist/Clawdbot.app"
+50 -24
View File
@@ -8,12 +8,15 @@ import {
type ExecAsk,
type ExecHost,
type ExecSecurity,
type ExecAllowlistEntry,
addAllowlistEntry,
analyzeShellCommand,
isSafeBinUsage,
matchAllowlist,
maxAsk,
minSecurity,
resolveSafeBins,
recordAllowlistUse,
resolveCommandResolution,
resolveExecApprovals,
} from "../infra/exec-approvals.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
@@ -94,6 +97,7 @@ export type ExecToolDefaults = {
ask?: ExecAsk;
node?: string;
pathPrepend?: string[];
safeBins?: string[];
agentId?: string;
backgroundMs?: number;
timeoutSec?: number;
@@ -298,6 +302,7 @@ export function createExecTool(
? defaults.timeoutSec
: 1800;
const defaultPathPrepend = normalizePathPrepend(defaults?.pathPrepend);
const safeBins = resolveSafeBins(defaults?.safeBins);
const notifyOnExit = defaults?.notifyOnExit !== false;
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
@@ -593,13 +598,27 @@ export function createExecTool(
if (hostSecurity === "deny") {
throw new Error("exec denied: host=gateway security=deny");
}
const resolution = resolveCommandResolution(params.command, workdir, env);
const allowlistMatch =
hostSecurity === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
const analysis = analyzeShellCommand({ command: params.command, cwd: workdir, env });
const allowlistMatches: ExecAllowlistEntry[] = [];
let allowlistSatisfied = false;
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 =
hostAsk === "always" ||
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
(hostAsk === "on-miss" &&
hostSecurity === "allowlist" &&
(!analysis.ok || !allowlistSatisfied));
let approvedByAsk = false;
if (requiresAsk) {
@@ -613,7 +632,7 @@ export function createExecTool(
security: hostSecurity,
ask: hostAsk,
agentId: defaults?.agentId,
resolvedPath: resolution?.resolvedPath ?? null,
resolvedPath: analysis.segments[0]?.resolution?.resolvedPath ?? null,
sessionKey: defaults?.sessionKey ?? null,
timeoutMs: 120_000,
},
@@ -630,7 +649,7 @@ export function createExecTool(
if (askFallback === "full") {
approvedByAsk = true;
} else if (askFallback === "allowlist") {
if (!allowlistMatch) {
if (!analysis.ok || !allowlistSatisfied) {
throw new Error("exec denied: approval required (approval UI not available)");
}
approvedByAsk = true;
@@ -644,30 +663,37 @@ export function createExecTool(
if (decision === "allow-always") {
approvedByAsk = true;
if (hostSecurity === "allowlist") {
const pattern =
resolution?.resolvedPath ??
resolution?.rawExecutable ??
params.command.split(/\s+/).shift() ??
"";
if (pattern) {
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
for (const segment of analysis.segments) {
const pattern = segment.resolution?.resolvedPath ?? "";
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");
}
if (allowlistMatch) {
recordAllowlistUse(
approvals.file,
defaults?.agentId,
allowlistMatch,
params.command,
resolution?.resolvedPath,
);
if (allowlistMatches.length > 0) {
const seen = new Set<string>();
for (const match of allowlistMatches) {
if (seen.has(match.pattern)) continue;
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
defaults?.agentId,
match,
params.command,
analysis.segments[0]?.resolution?.resolvedPath,
);
}
}
}
+5 -5
View File
@@ -10,10 +10,11 @@ describe("extractModelDirective", () => {
expect(result.cleaned).toBe("");
});
it("does not extract /models with argument", () => {
it("extracts /models with argument", () => {
const result = extractModelDirective("/models gpt-5");
expect(result.hasDirective).toBe(false);
expect(result.cleaned).toBe("/models gpt-5");
expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBe("gpt-5");
expect(result.cleaned).toBe("");
});
it("extracts /model with provider/model format", () => {
@@ -113,10 +114,9 @@ describe("extractModelDirective", () => {
});
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");
expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBe("gpt-5/tmp/hello");
expect(result.cleaned).toBe("thats not");
});
+1 -1
View File
@@ -14,7 +14,7 @@ export function extractModelDirective(
if (!body) return { cleaned: "", hasDirective: false };
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);
@@ -60,13 +60,13 @@ describe("directive behavior", () => {
vi.restoreAllMocks();
});
it("lists allowlisted models on /models <provider>", async () => {
it("lists allowlisted models on /model list", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -84,29 +84,9 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Models (anthropic)");
expect(text).toContain("anthropic/claude-opus-4-5");
const openaiRes = await getReplyFromConfig(
{ Body: "/models openai", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
},
);
const openaiText = Array.isArray(openaiRes) ? openaiRes[0]?.text : openaiRes?.text;
expect(openaiText).toContain("Models (openai)");
expect(openaiText).toContain("openai/gpt-4.1-mini");
expect(text).toContain("Model listing moved.");
expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
expect(text).toContain("Switch: /model <provider/model>");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -117,7 +97,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/models anthropic", From: "+1222", To: "+1222", CommandAuthorized: true },
{ Body: "/model", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -135,29 +115,9 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Models (anthropic)");
expect(text).toContain("anthropic/claude-opus-4-5");
const openaiRes = await getReplyFromConfig(
{ Body: "/models openai", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: { primary: "anthropic/claude-opus-4-5" },
workspace: path.join(home, "clawd"),
models: {
"anthropic/claude-opus-4-5": {},
"openai/gpt-4.1-mini": {},
},
},
},
session: { store: storePath },
},
);
const openaiText = Array.isArray(openaiRes) ? openaiRes[0]?.text : openaiRes?.text;
expect(openaiText).toContain("Models (openai)");
expect(openaiText).toContain("openai/gpt-4.1-mini");
expect(text).toContain("Current: anthropic/claude-opus-4-5");
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
expect(text).toContain("More: /model status");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -172,7 +132,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/models xai", From: "+1222", To: "+1222", CommandAuthorized: true },
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -190,29 +150,9 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Models (xai)");
expect(text).toContain("xai/grok-4");
const minimaxRes = await getReplyFromConfig(
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
defaults: {
model: {
primary: "anthropic/claude-opus-4-5",
fallbacks: ["openai/gpt-4.1-mini"],
},
imageModel: { primary: "minimax/MiniMax-M2.1" },
workspace: path.join(home, "clawd"),
},
},
session: { store: storePath },
},
);
const minimaxText = Array.isArray(minimaxRes) ? minimaxRes[0]?.text : minimaxRes?.text;
expect(minimaxText).toContain("Models (minimax)");
expect(minimaxText).toContain("minimax/MiniMax-M2.1");
expect(text).toContain("Model listing moved.");
expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
expect(text).toContain("Switch: /model <provider/model>");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -232,7 +172,7 @@ describe("directive behavior", () => {
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
{ Body: "/models minimax", From: "+1222", To: "+1222", CommandAuthorized: true },
{ Body: "/model list", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -261,8 +201,9 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Models (minimax)");
expect(text).toContain("minimax/MiniMax-M2.1");
expect(text).toContain("Model listing moved.");
expect(text).toContain("Use: /models (providers) or /models <provider> (models)");
expect(text).toContain("Switch: /model <provider/model>");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -62,13 +62,13 @@ describe("directive behavior", () => {
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) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
await getReplyFromConfig(
{ Body: "/model Kimi", From: "+1222", To: "+1222", CommandAuthorized: true },
{ Body: "/model ki", From: "+1222", To: "+1222", CommandAuthorized: true },
{},
{
agents: {
@@ -65,7 +65,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
await getReplyFromConfig(
{ 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;
expect(text).toContain("Did you mean:");
expect(text).toContain("moonshot/kimi-k2-0905-preview");
assertModelSelection(storePath);
assertModelSelection(storePath, {
model: "kimi-k2-0905-preview",
provider: "moonshot",
});
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -106,7 +106,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
await getReplyFromConfig(
{
Body: "/model kimi-k2-0905-preview",
From: "+1222",
@@ -140,10 +140,10 @@ describe("directive behavior", () => {
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Did you mean:");
expect(text).toContain("moonshot/kimi-k2-0905-preview");
assertModelSelection(storePath);
assertModelSelection(storePath, {
model: "kimi-k2-0905-preview",
provider: "moonshot",
});
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -152,7 +152,7 @@ describe("directive behavior", () => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
const res = await getReplyFromConfig(
await getReplyFromConfig(
{ 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;
expect(text).toContain("Did you mean:");
expect(text).toContain("moonshot/kimi-k2-0905-preview");
assertModelSelection(storePath);
assertModelSelection(storePath, {
model: "kimi-k2-0905-preview",
provider: "moonshot",
});
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -187,7 +187,7 @@ describe("directive behavior", () => {
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
});
});
it("shows a model summary on /model", async () => {
it("lists allowlisted models on /model", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const storePath = path.join(home, "sessions.json");
@@ -212,8 +212,8 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current: anthropic/claude-opus-4-5");
expect(text).toContain("Browse: /models");
expect(text).toContain("Switch: /model <provider/model>");
expect(text).toContain("Browse: /models (providers) or /models <provider> (models)");
expect(text).toContain("More: /model status");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
@@ -95,7 +95,7 @@ afterEach(() => {
});
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) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
@@ -116,17 +116,19 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Current: anthropic/claude-opus-4-5");
expect(normalized).toContain("Browse: /models");
expect(normalized).toContain("Switch: /model <provider/model>");
expect(normalized).toContain("Browse: /models (providers) or /models <provider> (models)");
expect(normalized).toContain("More: /model status");
expect(normalized).not.toContain("reasoning");
expect(normalized).not.toContain("image");
});
});
it("lists providers on /models", async () => {
it("moves /model list to /models", async () => {
await withTempHome(async (home) => {
const cfg = makeCfg(home);
const res = await getReplyFromConfig(
{
Body: "/models",
Body: "/model list",
From: "telegram:111",
To: "telegram:111",
ChatType: "direct",
@@ -141,12 +143,9 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Providers:");
expect(normalized).toContain("anthropic");
expect(normalized).toContain("openrouter");
expect(normalized).toContain("openai");
expect(normalized).toContain("openai-codex");
expect(normalized).toContain("minimax");
expect(normalized).toContain("Model listing moved.");
expect(normalized).toContain("Use: /models (providers) or /models <provider> (models)");
expect(normalized).toContain("Switch: /model <provider/model>");
});
});
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;
expect(normalizeTestText(text ?? "")).toContain(
"Numeric model selection is not supported in chat.",
);
const normalized = normalizeTestText(text ?? "");
expect(normalized).toContain("Numeric model selection is not supported in chat.");
expect(normalized).toContain("Browse: /models or /models <provider>");
expect(normalized).toContain("Switch: /model <provider/model>");
const store = loadSessionStore(cfg.session.store);
expect(store[sessionKey]?.providerOverride).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) => {
const cfg = makeCfg(home);
const sessionKey = "telegram:slash:111";
@@ -231,7 +231,7 @@ describe("trigger handling", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(normalizeTestText(text ?? "")).toContain(
"Model reset to default (anthropic/claude-opus-4-5).",
"Model reset to default (anthropic/claude-opus-4-5)",
);
const store = loadSessionStore(cfg.session.store);
+33 -41
View File
@@ -80,47 +80,14 @@ describe("/models command", () => {
expect(result.reply?.text).toContain("All: /models anthropic all");
});
it("includes configured providers and defaults", async () => {
const configuredCfg = {
commands: { text: true },
agents: {
defaults: {
model: {
primary: "synthetic/synth-1",
fallbacks: ["synthetic/synth-2"],
},
imageModel: {
primary: "synthetic/synth-image",
fallbacks: ["synthetic/synth-image-2"],
},
},
},
models: {
providers: {
synthetic: {
baseUrl: "https://example.com",
models: [
{
id: "synth-3",
name: "Synth 3",
},
],
},
},
},
} as unknown as ClawdbotConfig;
const providersResult = await handleCommands(buildParams("/models", configuredCfg));
expect(providersResult.shouldContinue).toBe(false);
expect(providersResult.reply?.text).toContain("synthetic");
const modelsResult = await handleCommands(buildParams("/models synthetic", configuredCfg));
expect(modelsResult.shouldContinue).toBe(false);
expect(modelsResult.reply?.text).toContain("synthetic/synth-1");
expect(modelsResult.reply?.text).toContain("synthetic/synth-2");
expect(modelsResult.reply?.text).toContain("synthetic/synth-3");
expect(modelsResult.reply?.text).toContain("synthetic/synth-image");
expect(modelsResult.reply?.text).toContain("synthetic/synth-image-2");
it("ignores page argument when all flag is present", async () => {
const params = buildParams("/models anthropic 3 all", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Models (anthropic)");
expect(result.reply?.text).toContain("page 1/1");
expect(result.reply?.text).toContain("anthropic/claude-opus-4-5");
expect(result.reply?.text).not.toContain("Page out of range");
});
it("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("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");
});
});
+43 -43
View File
@@ -17,15 +17,6 @@ function formatProviderLine(params: { provider: string; count: number }): string
return `- ${params.provider} (${params.count})`;
}
function addModelRef(map: Map<string, Set<string>>, provider: string, model: string): void {
const normalizedProvider = normalizeProviderId(provider);
const normalizedModel = String(model ?? "").trim();
if (!normalizedProvider || !normalizedModel) return;
const set = map.get(normalizedProvider) ?? new Set<string>();
set.add(normalizedModel);
map.set(normalizedProvider, set);
}
function parseModelsArgs(raw: string): {
provider?: string;
page: number;
@@ -100,57 +91,66 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
defaultModel: resolvedDefault.model,
});
const byProvider = new Map<string, Set<string>>();
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider: resolvedDefault.provider,
});
const addRaw = (raw?: string) => {
const value = String(raw ?? "").trim();
if (!value) return;
const byProvider = new Map<string, Set<string>>();
const add = (p: string, m: string) => {
const key = normalizeProviderId(p);
const set = byProvider.get(key) ?? new Set<string>();
set.add(m);
byProvider.set(key, set);
};
const addRawModelRef = (raw?: string) => {
const trimmed = raw?.trim();
if (!trimmed) return;
const resolved = resolveModelRefFromString({
raw: value,
raw: trimmed,
defaultProvider: resolvedDefault.provider,
aliasIndex,
});
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) {
addModelRef(byProvider, entry.provider, entry.id);
}
addModelRef(byProvider, resolvedDefault.provider, resolvedDefault.model);
const modelConfig = params.cfg.agents?.defaults?.model;
const modelFallbacks =
modelConfig && typeof modelConfig === "object" ? (modelConfig.fallbacks ?? []) : [];
for (const fallback of modelFallbacks) {
addRaw(String(fallback ?? ""));
}
const imageConfig = params.cfg.agents?.defaults?.imageModel;
if (imageConfig) {
if (typeof imageConfig === "string") {
addRaw(imageConfig);
} else {
addRaw(imageConfig.primary);
for (const fallback of imageConfig.fallbacks ?? []) {
addRaw(String(fallback ?? ""));
}
}
add(entry.provider, entry.id);
}
// Include config-only allowlist keys that aren't in the curated catalog.
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 ?? {})) {
for (const modelDef of providerConfig?.models ?? []) {
addModelRef(byProvider, provider, modelDef?.id ?? "");
}
}
// Ensure configured defaults/fallbacks/image models show up even when the
// curated catalog doesn't know about them (custom providers, dev builds, etc.).
add(resolvedDefault.provider, resolvedDefault.model);
addModelConfigEntries();
const providers = [...byProvider.keys()].sort();
@@ -341,16 +341,41 @@ export function resolveModelSelectionFromDirective(params: {
if (resolved.selection) {
const suggestion = `${resolved.selection.provider}/${resolved.selection.model}`;
return {
errorText: [
`Unrecognized model: ${raw}`,
"",
`Did you mean: ${suggestion}`,
`Try: /model ${suggestion}`,
"",
"Browse: /models or /models <provider>",
].join("\n"),
};
const rawHasSlash = raw.includes("/");
const shouldAutoSelect = (() => {
if (!rawHasSlash) return true;
const slash = raw.indexOf("/");
if (slash <= 0) return true;
const rawProvider = normalizeProviderId(raw.slice(0, slash));
const rawFragment = raw
.slice(slash + 1)
.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"),
};
}
}
}
+1 -1
View File
@@ -54,7 +54,7 @@ function boundedLevenshteinDistance(a: string, b: string, maxDistance: number):
if (Math.abs(aLen - bLen) > maxDistance) return null;
// Standard DP with early exit. O(maxDistance * minLen) in common cases.
const prev = Array.from({ length: bLen + 1 }, (_, index) => index);
const prev = Array.from({ length: bLen + 1 }, (_, idx) => idx);
const curr = Array.from({ length: bLen + 1 }, () => 0);
for (let i = 1; i <= aLen; i++) {
+3
View File
@@ -1,4 +1,5 @@
import type { startGatewayServer } from "../../gateway/server.js";
import { acquireGatewayLock } from "../../infra/gateway-lock.js";
import {
consumeGatewaySigusr1RestartAuthorization,
isGatewaySigusr1RestartExternallyAllowed,
@@ -14,6 +15,7 @@ export async function runGatewayLoop(params: {
start: () => Promise<Awaited<ReturnType<typeof startGatewayServer>>>;
runtime: typeof defaultRuntime;
}) {
const lock = await acquireGatewayLock();
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
let shuttingDown = false;
let restartResolver: (() => void) | null = null;
@@ -96,6 +98,7 @@ export async function runGatewayLoop(params: {
});
}
} finally {
await lock?.release();
cleanupSignals();
}
}
+1 -1
View File
@@ -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 {
path: "/tmp/exec-approvals.json",
exists: true,
+6 -1
View File
@@ -38,6 +38,7 @@ type ExecDefaults = {
ask?: ExecAsk;
node?: string;
pathPrepend?: string[];
safeBins?: string[];
};
function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
@@ -95,6 +96,7 @@ function resolveExecDefaults(
ask: globalExec.ask,
node: globalExec.node,
pathPrepend: globalExec.pathPrepend,
safeBins: globalExec.safeBins,
}
: undefined;
}
@@ -104,6 +106,7 @@ function resolveExecDefaults(
ask: agentExec?.ask ?? globalExec?.ask,
node: agentExec?.node ?? globalExec?.node,
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 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;
} | null;
const approvalsFile =
+1 -7
View File
@@ -46,7 +46,7 @@ const AUTH_CHOICE_GROUP_DEFS: {
value: "anthropic",
label: "Anthropic",
hint: "Claude Code CLI + API key",
choices: ["claude-cli", "setup-token", "token", "apiKey"],
choices: ["claude-cli", "token", "apiKey"],
},
{
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({
value: "token",
label: "Anthropic token (paste setup-token)",
+13
View File
@@ -87,6 +87,19 @@ export async function doctorCommand(
});
let cfg: ClawdbotConfig = configResult.cfg;
const configPath = configResult.path ?? CONFIG_PATH_CLAWDBOT;
if (!cfg.gateway?.mode) {
const lines = [
"gateway.mode is unset; gateway start will be blocked.",
`Fix: run ${formatCliCommand("clawdbot configure")} and set Gateway mode (local/remote).`,
`Or set directly: ${formatCliCommand("clawdbot config set gateway.mode local")}`,
];
if (!fs.existsSync(configPath)) {
lines.push(`Missing config: run ${formatCliCommand("clawdbot setup")} first.`);
}
note(lines.join("\n"), "Gateway");
}
cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter);
await noteAuthProfileHealth({
cfg,
@@ -220,12 +220,18 @@ describe("legacy config detection", () => {
});
it("keeps gateway.bind tailnet", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const { migrateLegacyConfig, validateConfigObject } = await import("./config.js");
const res = migrateLegacyConfig({
gateway: { bind: "tailnet" as const },
});
expect(res.changes).not.toContain("Migrated gateway.bind from 'tailnet' to 'auto'.");
expect(res.config?.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 () => {
vi.resetModules();
+4 -2
View File
@@ -202,10 +202,12 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
let mutated = false;
const nextDefaults = { ...defaults };
const contextPruning = defaults.contextPruning ?? {};
const heartbeat = defaults.heartbeat ?? {};
if (defaults.contextPruning?.mode === undefined) {
nextDefaults.contextPruning = {
...defaults.contextPruning,
...contextPruning,
mode: "cache-ttl",
ttl: defaults.contextPruning?.ttl ?? "1h",
};
@@ -214,7 +216,7 @@ export function applyContextPruningDefaults(cfg: ClawdbotConfig): ClawdbotConfig
if (defaults.heartbeat?.every === undefined) {
nextDefaults.heartbeat = {
...defaults.heartbeat,
...heartbeat,
every: authMode === "oauth" ? "1h" : "30m",
};
mutated = true;
+1 -5
View File
@@ -7,11 +7,7 @@ export function migrateLegacyConfig(raw: unknown): {
changes: string[];
} {
const { next, changes } = applyLegacyMigrations(raw);
if (!next) {
const validated = validateConfigObjectWithPlugins(raw);
if (!validated.ok) return { config: null, changes: [] };
return { config: validated.config, changes: [] };
}
if (!next) return { config: null, changes: [] };
const validated = validateConfigObjectWithPlugins(next);
if (!validated.ok) {
changes.push("Migration applied, but config still invalid; fix remaining issues manually.");
+6
View File
@@ -166,6 +166,7 @@ const FIELD_LABELS: Record<string, string> = {
"tools.exec.ask": "Exec Ask",
"tools.exec.node": "Exec Node Binding",
"tools.exec.pathPrepend": "Exec PATH Prepend",
"tools.exec.safeBins": "Exec Safe Bins",
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
@@ -186,6 +187,7 @@ const FIELD_LABELS: Record<string, string> = {
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
"gateway.controlUi.basePath": "Control UI Base Path",
"gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth",
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
"gateway.reload.mode": "Config Reload Mode",
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
@@ -344,6 +346,8 @@ const FIELD_HELP: Record<string, string> = {
"gateway.auth.password": "Required for Tailscale funnel.",
"gateway.controlUi.basePath":
"Optional URL prefix where the Control UI is served (e.g. /clawdbot).",
"gateway.controlUi.allowInsecureAuth":
"Allow Control UI auth over insecure HTTP (token-only; not recommended).",
"gateway.http.endpoints.chatCompletions.enabled":
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
@@ -367,6 +371,8 @@ const FIELD_HELP: Record<string, string> = {
"tools.exec.notifyOnExit":
"When true (default), backgrounded exec sessions enqueue a system event and request a heartbeat on exit.",
"tools.exec.pathPrepend": "Directories to prepend to PATH for exec runs (gateway/sandbox).",
"tools.exec.safeBins":
"Allow stdin-only safe binaries to run without explicit allowlist entries.",
"tools.message.allowCrossContextSend":
"Legacy override: allow cross-context sends across all providers.",
"tools.message.crossContext.allowWithinProvider":
+2
View File
@@ -51,6 +51,8 @@ export type GatewayControlUiConfig = {
enabled?: boolean;
/** Optional base path prefix for the Control UI (e.g. "/clawdbot"). */
basePath?: string;
/** Allow token-only auth over insecure HTTP (default: false). */
allowInsecureAuth?: boolean;
};
export type GatewayAuthMode = "token" | "password";
+2
View File
@@ -131,6 +131,8 @@ export type ExecToolConfig = {
node?: string;
/** Directories to prepend to PATH when running exec (gateway/sandbox). */
pathPrepend?: string[];
/** Safe stdin-only binaries that can run without allowlist entries. */
safeBins?: string[];
/** Default time (ms) before an exec command auto-backgrounds. */
backgroundMs?: number;
/** Default timeout (seconds) before auto-killing exec commands. */
+2
View File
@@ -257,6 +257,7 @@ export const AgentToolsSchema = z
ask: z.enum(["off", "on-miss", "always"]).optional(),
node: z.string().optional(),
pathPrepend: z.array(z.string()).optional(),
safeBins: z.array(z.string()).optional(),
backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(),
@@ -485,6 +486,7 @@ export const ToolsSchema = z
ask: z.enum(["off", "on-miss", "always"]).optional(),
node: z.string().optional(),
pathPrepend: z.array(z.string()).optional(),
safeBins: z.array(z.string()).optional(),
backgroundMs: z.number().int().positive().optional(),
timeoutSec: z.number().int().positive().optional(),
cleanupMs: z.number().int().positive().optional(),
+1
View File
@@ -282,6 +282,7 @@ export const ClawdbotSchema = z
.object({
enabled: z.boolean().optional(),
basePath: z.string().optional(),
allowInsecureAuth: z.boolean().optional(),
})
.strict()
.optional(),
+8 -1
View File
@@ -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_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 HEALTH_REFRESH_INTERVAL_MS = 60_000;
export const DEDUPE_TTL_MS = 5 * 60_000;
+63 -5
View File
@@ -1,7 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import { HANDSHAKE_TIMEOUT_MS } from "./server-constants.js";
import { getHandshakeTimeoutMs } from "./server-constants.js";
import {
connectReq,
getFreePort,
@@ -11,6 +11,7 @@ import {
startServerWithClient,
testState,
} from "./test-helpers.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
installGatewayTestHooks();
@@ -28,10 +29,21 @@ async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean
describe("gateway server auth/connect", () => {
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
vi.useRealTimers();
const { server, ws } = await startServerWithClient();
const closed = await waitForWsClose(ws, HANDSHAKE_TIMEOUT_MS + 2_000);
expect(closed).toBe(true);
await server.close();
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "250";
try {
const { server, ws } = await startServerWithClient();
const handshakeTimeoutMs = getHandshakeTimeoutMs();
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 2_000);
expect(closed).toBe(true);
await server.close();
} finally {
if (prevHandshakeTimeout === undefined) {
delete process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
} else {
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout;
}
}
});
test("connect (req) handshake returns hello-ok payload", async () => {
@@ -116,6 +128,52 @@ describe("gateway server auth/connect", () => {
await server.close();
});
test("rejects control ui without device identity by default", async () => {
const { server, ws, prevToken } = await startServerWithClient("secret");
const res = await connectReq(ws, {
token: "secret",
device: null,
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "1.0.0",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("secure context");
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("allows control ui without device identity when insecure auth is enabled", async () => {
testState.gatewayControlUi = { allowInsecureAuth: true };
const { server, ws, prevToken } = await startServerWithClient("secret");
const res = await connectReq(ws, {
token: "secret",
device: null,
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "1.0.0",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
expect(res.ok).toBe(true);
ws.close();
await server.close();
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
});
test("accepts device token auth for paired device", async () => {
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
const { approveDevicePairing, getPairedDevice, listDevicePairing } =
+3 -2
View File
@@ -8,7 +8,7 @@ import { isWebchatClient } from "../../utils/message-channel.js";
import type { ResolvedGatewayAuth } from "../auth.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 { formatError } from "../server-utils.js";
import { logWs } from "../ws-log.js";
@@ -210,6 +210,7 @@ export function attachGatewayWsConnectionHandler(params: {
close();
});
const handshakeTimeoutMs = getHandshakeTimeoutMs();
const handshakeTimer = setTimeout(() => {
if (!client) {
handshakeState = "failed";
@@ -219,7 +220,7 @@ export function attachGatewayWsConnectionHandler(params: {
logWsControl.warn(`handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`);
close();
}
}, HANDSHAKE_TIMEOUT_MS);
}, handshakeTimeoutMs);
attachGatewayWsMessageHandler({
socket,
@@ -39,6 +39,7 @@ import {
validateConnectParams,
validateRequestFrame,
} from "../../protocol/index.js";
import { GATEWAY_CLIENT_IDS } from "../../protocol/client-info.js";
import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js";
import { handleGatewayRequest } from "../../server-methods.js";
@@ -293,24 +294,53 @@ export function attachGatewayWsMessageHandler(params: {
const device = connectParams.device;
let devicePublicKey: string | null = null;
// Allow token-authenticated connections (e.g., control-ui) to skip device identity
const hasTokenAuth = !!connectParams.auth?.token;
if (!device && !hasTokenAuth) {
setHandshakeState("failed");
setCloseCause("device-required", {
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.NOT_PAIRED, "device identity required"),
});
close(1008, "device identity required");
return;
const hasTokenAuth = Boolean(connectParams.auth?.token);
const hasPasswordAuth = Boolean(connectParams.auth?.password);
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
if (!device) {
const allowInsecureControlUi =
isControlUi && loadConfig().gateway?.controlUi?.allowInsecureAuth === true;
const canSkipDevice =
isControlUi && allowInsecureControlUi ? hasTokenAuth || hasPasswordAuth : hasTokenAuth;
if (isControlUi && !allowInsecureControlUi) {
const errorMessage = "control ui requires HTTPS or localhost (secure context)";
setHandshakeState("failed");
setCloseCause("control-ui-insecure-auth", {
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, errorMessage),
});
close(1008, errorMessage);
return;
}
// Allow token-authenticated connections (e.g., control-ui) to skip device identity
if (!canSkipDevice) {
setHandshakeState("failed");
setCloseCause("device-required", {
client: connectParams.client.id,
clientDisplayName: connectParams.client.displayName,
mode: connectParams.client.mode,
version: connectParams.client.version,
});
send({
type: "res",
id: frame.id,
ok: false,
error: errorShape(ErrorCodes.NOT_PAIRED, "device identity required"),
});
close(1008, "device identity required");
return;
}
}
if (device) {
const derivedId = deriveDeviceIdFromPublicKey(device.publicKey);
+2
View File
@@ -210,6 +210,7 @@ export const testState = {
cronEnabled: false as boolean | undefined,
gatewayBind: undefined as "auto" | "lan" | "tailnet" | "loopback" | undefined,
gatewayAuth: undefined as Record<string, unknown> | undefined,
gatewayControlUi: undefined as Record<string, unknown> | undefined,
hooksConfig: undefined as HooksConfig | undefined,
canvasHostPort: undefined as number | undefined,
legacyIssues: [] as Array<{ path: string; message: string }>,
@@ -443,6 +444,7 @@ vi.mock("../config/config.js", async () => {
: {};
if (testState.gatewayBind) fileGateway.bind = testState.gatewayBind;
if (testState.gatewayAuth) fileGateway.auth = testState.gatewayAuth;
if (testState.gatewayControlUi) fileGateway.controlUi = testState.gatewayControlUi;
const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined;
const fileCanvasHost =
+3 -1
View File
@@ -101,6 +101,7 @@ export function installGatewayTestHooks() {
testTailnetIPv4.value = undefined;
testState.gatewayBind = undefined;
testState.gatewayAuth = undefined;
testState.gatewayControlUi = undefined;
testState.hooksConfig = undefined;
testState.canvasHostPort = undefined;
testState.legacyIssues = [];
@@ -280,7 +281,7 @@ export async function connectReq(
signature: string;
signedAt: number;
nonce?: string;
};
} | null;
},
): Promise<ConnectResponse> {
const { randomUUID } = await import("node:crypto");
@@ -294,6 +295,7 @@ export async function connectReq(
const role = opts?.role ?? "operator";
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
const device = (() => {
if (opts?.device === null) return undefined;
if (opts?.device) return opts.device;
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
+80 -4
View File
@@ -5,9 +5,13 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
analyzeArgvCommand,
analyzeShellCommand,
isSafeBinUsage,
matchAllowlist,
maxAsk,
minSecurity,
normalizeSafeBins,
resolveCommandResolution,
resolveExecApprovals,
type ExecAllowlistEntry,
@@ -18,7 +22,7 @@ function makeTempDir() {
}
describe("exec approvals allowlist matching", () => {
it("matches by executable name (case-insensitive)", () => {
it("ignores basename-only patterns", () => {
const resolution = {
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
@@ -26,7 +30,7 @@ describe("exec approvals allowlist matching", () => {
};
const entries: ExecAllowlistEntry[] = [{ pattern: "RG" }];
const match = matchAllowlist(entries, resolution);
expect(match?.pattern).toBe("RG");
expect(match).toBeNull();
});
it("matches by resolved path with **", () => {
@@ -51,7 +55,7 @@ describe("exec approvals allowlist matching", () => {
expect(match).toBeNull();
});
it("falls back to raw executable when no resolved path", () => {
it("requires a resolved path", () => {
const resolution = {
rawExecutable: "bin/rg",
resolvedPath: undefined,
@@ -59,7 +63,7 @@ describe("exec approvals allowlist matching", () => {
};
const entries: ExecAllowlistEntry[] = [{ pattern: "bin/rg" }];
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 });
const exe = path.join(binDir, "rg");
fs.writeFileSync(exe, "");
fs.chmodSync(exe, 0o755);
const res = resolveCommandResolution("rg -n foo", undefined, { PATH: binDir });
expect(res?.resolvedPath).toBe(exe);
expect(res?.executableName).toBe("rg");
@@ -81,6 +86,7 @@ describe("exec approvals command resolution", () => {
const script = path.join(cwd, "scripts", "run.sh");
fs.mkdirSync(path.dirname(script), { recursive: true });
fs.writeFileSync(script, "");
fs.chmodSync(script, 0o755);
const res = resolveCommandResolution("./scripts/run.sh --flag", cwd, undefined);
expect(res?.resolvedPath).toBe(script);
});
@@ -91,11 +97,81 @@ describe("exec approvals command resolution", () => {
const script = path.join(cwd, "bin", "tool");
fs.mkdirSync(path.dirname(script), { recursive: true });
fs.writeFileSync(script, "");
fs.chmodSync(script, 0o755);
const res = resolveCommandResolution('"./bin/tool" --version', cwd, undefined);
expect(res?.resolvedPath).toBe(script);
});
});
describe("exec approvals shell parsing", () => {
it("parses simple pipelines", () => {
const res = analyzeShellCommand({ command: "echo ok | jq .foo" });
expect(res.ok).toBe(true);
expect(res.segments.map((seg) => seg.argv[0])).toEqual(["echo", "jq"]);
});
it("rejects chained commands", () => {
const res = analyzeShellCommand({ command: "ls && rm -rf /" });
expect(res.ok).toBe(false);
});
it("parses argv commands", () => {
const res = analyzeArgvCommand({ argv: ["/bin/echo", "ok"] });
expect(res.ok).toBe(true);
expect(res.segments[0]?.argv).toEqual(["/bin/echo", "ok"]);
});
});
describe("exec approvals safe bins", () => {
it("allows safe bins with non-path args", () => {
const dir = makeTempDir();
const binDir = path.join(dir, "bin");
fs.mkdirSync(binDir, { recursive: true });
const 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", () => {
it("minSecurity returns the more restrictive value", () => {
expect(minSecurity("deny", "full")).toBe("deny");
+310 -13
View File
@@ -60,6 +60,7 @@ const DEFAULT_ASK_FALLBACK: ExecSecurity = "deny";
const DEFAULT_AUTO_ALLOW_SKILLS = false;
const DEFAULT_SOCKET = "~/.clawdbot/exec-approvals.sock";
const DEFAULT_FILE = "~/.clawdbot/exec-approvals.json";
export const DEFAULT_SAFE_BINS = ["jq", "grep", "cut", "sort", "uniq", "head", "tail", "tr", "wc"];
function hashExecApprovalsRaw(raw: string | null): string {
return crypto
@@ -283,6 +284,19 @@ type CommandResolution = {
executableName: string;
};
function isExecutableFile(filePath: string): boolean {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) return false;
if (process.platform !== "win32") {
fs.accessSync(filePath, fs.constants.X_OK);
}
return true;
} catch {
return false;
}
}
function parseFirstToken(command: string): string | null {
const trimmed = command.trim();
if (!trimmed) return null;
@@ -299,15 +313,26 @@ function parseFirstToken(command: string): string | null {
function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS.ProcessEnv) {
const expanded = rawExecutable.startsWith("~") ? expandHome(rawExecutable) : rawExecutable;
if (expanded.includes("/") || expanded.includes("\\")) {
if (path.isAbsolute(expanded)) return expanded;
if (path.isAbsolute(expanded)) {
return isExecutableFile(expanded) ? expanded : undefined;
}
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 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) {
const candidate = path.join(entry, expanded);
if (fs.existsSync(candidate)) return candidate;
for (const ext of extensions) {
const candidate = path.join(entry, expanded + ext);
if (isExecutableFile(candidate)) return candidate;
}
}
return undefined;
}
@@ -324,6 +349,18 @@ export function resolveCommandResolution(
return { rawExecutable, resolvedPath, executableName };
}
export function resolveCommandResolutionFromArgv(
argv: string[],
cwd?: string,
env?: NodeJS.ProcessEnv,
): CommandResolution | null {
const rawExecutable = argv[0]?.trim();
if (!rawExecutable) return null;
const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env);
const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable;
return { rawExecutable, resolvedPath, executableName };
}
function normalizeMatchTarget(value: string): string {
return value.replace(/\\\\/g, "/").toLowerCase();
}
@@ -370,24 +407,284 @@ export function matchAllowlist(
entries: ExecAllowlistEntry[],
resolution: CommandResolution | null,
): ExecAllowlistEntry | null {
if (!entries.length || !resolution) return null;
const rawExecutable = resolution.rawExecutable;
if (!entries.length || !resolution?.resolvedPath) return null;
const resolvedPath = resolution.resolvedPath;
const executableName = resolution.executableName;
for (const entry of entries) {
const pattern = entry.pattern?.trim();
if (!pattern) continue;
const hasPath = pattern.includes("/") || pattern.includes("\\") || pattern.includes("~");
if (hasPath) {
const target = resolvedPath ?? rawExecutable;
if (target && matchesPattern(pattern, target)) return entry;
continue;
}
if (executableName && matchesPattern(pattern, executableName)) return entry;
if (!hasPath) continue;
if (matchesPattern(pattern, resolvedPath)) return entry;
}
return null;
}
export type ExecCommandSegment = {
raw: string;
argv: string[];
resolution: CommandResolution | null;
};
export type ExecCommandAnalysis = {
ok: boolean;
reason?: string;
segments: ExecCommandSegment[];
};
const DISALLOWED_TOKENS = new Set([";", "&", ">", "<", "`", "\n", "\r", "(", ")"]);
function splitShellPipeline(command: string): { ok: boolean; reason?: string; segments: string[] } {
const segments: string[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
const pushSegment = () => {
const trimmed = buf.trim();
if (!trimmed) {
return false;
}
segments.push(trimmed);
buf = "";
return true;
};
for (let i = 0; i < command.length; i += 1) {
const ch = command[i];
if (escaped) {
buf += ch;
escaped = false;
continue;
}
if (!inSingle && ch === "\\") {
escaped = true;
buf += ch;
continue;
}
if (inSingle) {
if (ch === "'") inSingle = false;
buf += ch;
continue;
}
if (inDouble) {
if (ch === '"') inDouble = false;
buf += ch;
continue;
}
if (ch === "'") {
inSingle = true;
buf += ch;
continue;
}
if (ch === '"') {
inDouble = true;
buf += ch;
continue;
}
if (ch === "|" && command[i + 1] === "|") {
return { ok: false, reason: "unsupported shell token: ||", segments: [] };
}
if (ch === "|" && command[i + 1] === "&") {
return { ok: false, reason: "unsupported shell token: |&", segments: [] };
}
if (ch === "|") {
if (!pushSegment()) {
return { ok: false, reason: "empty pipeline segment", segments: [] };
}
continue;
}
if (DISALLOWED_TOKENS.has(ch)) {
return { ok: false, reason: `unsupported shell token: ${ch}`, segments: [] };
}
if (ch === "$" && command[i + 1] === "(") {
return { ok: false, reason: "unsupported shell token: $()", segments: [] };
}
buf += ch;
}
if (escaped || inSingle || inDouble) {
return { ok: false, reason: "unterminated shell quote/escape", segments: [] };
}
if (!pushSegment()) {
return { ok: false, reason: "empty command", segments: [] };
}
return { ok: true, segments };
}
function tokenizeShellSegment(segment: string): string[] | null {
const tokens: string[] = [];
let buf = "";
let inSingle = false;
let inDouble = false;
let escaped = false;
const pushToken = () => {
if (buf.length > 0) {
tokens.push(buf);
buf = "";
}
};
for (let i = 0; i < segment.length; i += 1) {
const ch = segment[i];
if (escaped) {
buf += ch;
escaped = false;
continue;
}
if (!inSingle && ch === "\\") {
escaped = true;
continue;
}
if (inSingle) {
if (ch === "'") {
inSingle = false;
} else {
buf += ch;
}
continue;
}
if (inDouble) {
if (ch === '"') {
inDouble = false;
} else {
buf += ch;
}
continue;
}
if (ch === "'") {
inSingle = true;
continue;
}
if (ch === '"') {
inDouble = true;
continue;
}
if (/\s/.test(ch)) {
pushToken();
continue;
}
buf += ch;
}
if (escaped || inSingle || inDouble) {
return null;
}
pushToken();
return tokens;
}
export function analyzeShellCommand(params: {
command: string;
cwd?: string;
env?: NodeJS.ProcessEnv;
}): ExecCommandAnalysis {
const split = splitShellPipeline(params.command);
if (!split.ok) {
return { ok: false, reason: split.reason, segments: [] };
}
const segments: ExecCommandSegment[] = [];
for (const raw of split.segments) {
const argv = tokenizeShellSegment(raw);
if (!argv || argv.length === 0) {
return { ok: false, reason: "unable to parse shell segment", segments: [] };
}
segments.push({
raw,
argv,
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
});
}
return { ok: true, segments };
}
export function analyzeArgvCommand(params: {
argv: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
}): ExecCommandAnalysis {
const argv = params.argv.filter((entry) => entry.trim().length > 0);
if (argv.length === 0) {
return { ok: false, reason: "empty argv", segments: [] };
}
return {
ok: true,
segments: [
{
raw: argv.join(" "),
argv,
resolution: resolveCommandResolutionFromArgv(argv, params.cwd, params.env),
},
],
};
}
function isPathLikeToken(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
if (trimmed === "-") return false;
if (trimmed.startsWith("./") || trimmed.startsWith("../") || trimmed.startsWith("~")) return true;
if (trimmed.startsWith("/")) return true;
return /^[A-Za-z]:[\\/]/.test(trimmed);
}
function defaultFileExists(filePath: string): boolean {
try {
return fs.existsSync(filePath);
} catch {
return false;
}
}
export function normalizeSafeBins(entries?: string[]): Set<string> {
if (!Array.isArray(entries)) return new Set();
const normalized = entries
.map((entry) => entry.trim().toLowerCase())
.filter((entry) => entry.length > 0);
return new Set(normalized);
}
export function resolveSafeBins(entries?: string[] | null): Set<string> {
if (entries === undefined) return normalizeSafeBins(DEFAULT_SAFE_BINS);
return normalizeSafeBins(entries ?? []);
}
export function isSafeBinUsage(params: {
argv: string[];
resolution: CommandResolution | null;
safeBins: Set<string>;
cwd?: string;
fileExists?: (filePath: string) => boolean;
}): boolean {
if (params.safeBins.size === 0) return false;
const resolution = params.resolution;
const execName = resolution?.executableName?.toLowerCase();
if (!execName || !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(
approvals: ExecApprovalsFile,
agentId: string | undefined,
+55
View File
@@ -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();
});
});
+144
View File
@@ -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 {
constructor(
message: string,
@@ -7,3 +37,117 @@ export class GatewayLockError extends Error {
this.name = "GatewayLockError";
}
}
function isAlive(pid: number): boolean {
if (!Number.isFinite(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function readLockPayload(lockPath: string): Promise<LockPayload | null> {
try {
const raw = await fs.readFile(lockPath, "utf8");
const parsed = JSON.parse(raw) as Partial<LockPayload>;
if (typeof parsed.pid !== "number") return null;
if (typeof parsed.createdAt !== "string") return null;
if (typeof parsed.configPath !== "string") return null;
return {
pid: parsed.pid,
createdAt: parsed.createdAt,
configPath: parsed.configPath,
};
} catch {
return null;
}
}
function resolveGatewayLockPath(env: NodeJS.ProcessEnv) {
const stateDir = resolveStateDir(env);
const configPath = resolveConfigPath(env, stateDir);
const hash = createHash("sha1").update(configPath).digest("hex").slice(0, 8);
const lockPath = path.join(stateDir, `gateway.${hash}.lock`);
return { lockPath, configPath };
}
export async function acquireGatewayLock(
opts: GatewayLockOptions = {},
): Promise<GatewayLockHandle | null> {
const env = opts.env ?? process.env;
const allowInTests = opts.allowInTests === true;
if (
env.CLAWDBOT_ALLOW_MULTI_GATEWAY === "1" ||
(!allowInTests && (env.VITEST || env.NODE_ENV === "test"))
) {
return null;
}
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
const staleMs = opts.staleMs ?? DEFAULT_STALE_MS;
const { lockPath, configPath } = resolveGatewayLockPath(env);
await fs.mkdir(path.dirname(lockPath), { recursive: true });
const startedAt = Date.now();
let lastPayload: LockPayload | null = null;
while (Date.now() - startedAt < timeoutMs) {
try {
const handle = await fs.open(lockPath, "wx");
const payload: LockPayload = {
pid: process.pid,
createdAt: new Date().toISOString(),
configPath,
};
await handle.writeFile(JSON.stringify(payload), "utf8");
return {
lockPath,
configPath,
release: async () => {
await handle.close().catch(() => undefined);
await fs.rm(lockPath, { force: true });
},
};
} catch (err) {
const code = (err as { code?: unknown }).code;
if (code !== "EEXIST") {
throw new GatewayLockError(`failed to acquire gateway lock at ${lockPath}`, err);
}
lastPayload = await readLockPayload(lockPath);
const ownerPid = lastPayload?.pid;
const ownerAlive = ownerPid ? isAlive(ownerPid) : false;
if (!ownerAlive && ownerPid) {
await fs.rm(lockPath, { force: true });
continue;
}
if (!ownerAlive) {
let stale = false;
if (lastPayload?.createdAt) {
const createdAt = Date.parse(lastPayload.createdAt);
stale = Number.isFinite(createdAt) ? Date.now() - createdAt > staleMs : false;
}
if (!stale) {
try {
const st = await fs.stat(lockPath);
stale = Date.now() - st.mtimeMs > staleMs;
} catch {
stale = true;
}
}
if (stale) {
await fs.rm(lockPath, { force: true });
continue;
}
}
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
}
const owner = lastPayload?.pid ? ` (pid ${lastPayload.pid})` : "";
throw new GatewayLockError(`gateway already running${owner}; lock timeout after ${timeoutMs}ms`);
}
+3 -3
View File
@@ -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;
const [hourStr, minuteStr] = raw.split(":");
const hour = Number(hourStr);
@@ -131,8 +131,8 @@ function isWithinActiveHours(
const active = heartbeat?.activeHours;
if (!active) return true;
const startMin = parseActiveHoursTime(active.start, { allow24: false });
const endMin = parseActiveHoursTime(active.end, { allow24: true });
const startMin = parseActiveHoursTime({ allow24: false }, active.start);
const endMin = parseActiveHoursTime({ allow24: true }, active.end);
if (startMin === null || endMin === null) return true;
if (startMin === endMin) return true;
+15 -1
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import process from "node:process";
import type { GatewayLockHandle } from "../infra/gateway-lock.js";
declare const __CLAWDBOT_VERSION__: string;
@@ -45,6 +46,7 @@ async function main() {
{ startGatewayServer },
{ setGatewayWsLogStyle },
{ setVerbose },
{ acquireGatewayLock, GatewayLockError },
{ consumeGatewaySigusr1RestartAuthorization, isGatewaySigusr1RestartExternallyAllowed },
{ defaultRuntime },
{ enableConsoleCapture, setConsoleTimestampPrefix },
@@ -53,10 +55,11 @@ async function main() {
import("../gateway/server.js"),
import("../gateway/ws-logging.js"),
import("../globals.js"),
import("../infra/gateway-lock.js"),
import("../infra/restart.js"),
import("../runtime.js"),
import("../logging.js"),
]);
] as const);
enableConsoleCapture();
setConsoleTimestampPrefix(true);
@@ -103,6 +106,7 @@ async function main() {
if (token) process.env.CLAWDBOT_GATEWAY_TOKEN = token;
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
let lock: GatewayLockHandle | null = null;
let shuttingDown = false;
let forceExitTimer: ReturnType<typeof setTimeout> | null = null;
let restartResolver: (() => void) | null = null;
@@ -177,6 +181,15 @@ async function main() {
process.on("SIGUSR1", onSigusr1);
try {
try {
lock = await acquireGatewayLock();
} catch (err) {
if (err instanceof GatewayLockError) {
defaultRuntime.error(`Gateway start blocked: ${err.message}`);
process.exit(1);
}
throw err;
}
// eslint-disable-next-line no-constant-condition
while (true) {
try {
@@ -191,6 +204,7 @@ async function main() {
});
}
} finally {
await (lock as GatewayLockHandle | null)?.release();
cleanupSignals();
}
}
+66 -13
View File
@@ -4,12 +4,16 @@ import fs from "node:fs";
import path from "node:path";
import {
type ExecAllowlistEntry,
addAllowlistEntry,
analyzeArgvCommand,
analyzeShellCommand,
isSafeBinUsage,
matchAllowlist,
normalizeExecApprovals,
recordAllowlistUse,
resolveCommandResolution,
resolveExecApprovals,
resolveSafeBins,
ensureExecApprovals,
readExecApprovalsSnapshot,
resolveExecApprovalsSocketPath,
@@ -25,6 +29,7 @@ import {
import { getMachineDisplayName } from "../infra/machine-name.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { loadConfig } from "../config/config.js";
import { resolveAgentConfig } from "../agents/agent-scope.js";
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
import { VERSION } from "../version.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
@@ -110,7 +115,6 @@ const execHostFallbackAllowed =
process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0";
const blockedEnvKeys = new Set([
"PATH",
"NODE_OPTIONS",
"PYTHONHOME",
"PYTHONPATH",
@@ -156,10 +160,24 @@ function sanitizeEnv(
): Record<string, string> | undefined {
if (!overrides) return undefined;
const merged = { ...process.env } as Record<string, string>;
const basePath = process.env.PATH ?? DEFAULT_NODE_PATH;
for (const [rawKey, value] of Object.entries(overrides)) {
const key = rawKey.trim();
if (!key) continue;
const upper = key.toUpperCase();
if (upper === "PATH") {
const trimmed = value.trim();
if (!trimmed) continue;
if (!basePath || trimmed === basePath) {
merged[key] = trimmed;
continue;
}
const suffix = `${path.delimiter}${basePath}`;
if (trimmed.endsWith(suffix)) {
merged[key] = trimmed;
}
continue;
}
if (blockedEnvKeys.has(upper)) continue;
if (blockedEnvPrefixes.some((prefix) => upper.startsWith(prefix))) continue;
merged[key] = value;
@@ -567,12 +585,32 @@ async function handleInvoke(
const sessionKey = params.sessionKey?.trim() || "node";
const runId = crypto.randomUUID();
const env = sanitizeEnv(params.env ?? undefined);
const resolution = resolveCommandResolution(cmdText, params.cwd ?? undefined, env);
const allowlistMatch =
security === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
const analysis = rawCommand
? analyzeShellCommand({ command: rawCommand, cwd: params.cwd ?? undefined, env })
: analyzeArgvCommand({ argv, cwd: params.cwd ?? undefined, env });
const cfg = loadConfig();
const agentExec = agentId ? resolveAgentConfig(cfg, agentId)?.tools?.exec : undefined;
const safeBins = resolveSafeBins(agentExec?.safeBins ?? cfg.tools?.exec?.safeBins);
const allowlistMatches: ExecAllowlistEntry[] = [];
const bins = autoAllowSkills ? await skillBins.current() : new Set<string>();
const skillAllow =
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
const allowlistSatisfied =
security === "allowlist" && analysis.ok && analysis.segments.length > 0
? analysis.segments.every((segment) => {
const match = matchAllowlist(approvals.allowlist, segment.resolution);
if (match) allowlistMatches.push(match);
const safe = isSafeBinUsage({
argv: segment.argv,
resolution: segment.resolution,
safeBins,
cwd: params.cwd ?? undefined,
});
const skillAllow =
autoAllowSkills && segment.resolution?.executableName
? bins.has(segment.resolution.executableName)
: false;
return Boolean(match || safe || skillAllow);
})
: false;
const useMacAppExec = process.platform === "darwin";
if (useMacAppExec) {
@@ -678,7 +716,7 @@ async function handleInvoke(
const requiresAsk =
ask === "always" ||
(ask === "on-miss" && security === "allowlist" && !allowlistMatch && !skillAllow);
(ask === "on-miss" && security === "allowlist" && (!analysis.ok || !allowlistSatisfied));
const approvalDecision =
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
@@ -704,11 +742,15 @@ async function handleInvoke(
return;
}
if (approvalDecision === "allow-always" && security === "allowlist") {
const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? "";
if (pattern) addAllowlistEntry(approvals.file, agentId, pattern);
if (analysis.ok) {
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(
client,
"exec.denied",
@@ -727,8 +769,19 @@ async function handleInvoke(
return;
}
if (allowlistMatch) {
recordAllowlistUse(approvals.file, agentId, allowlistMatch, cmdText, resolution?.resolvedPath);
if (allowlistMatches.length > 0) {
const seen = new Set<string>();
for (const match of allowlistMatches) {
if (!match?.pattern || seen.has(match.pattern)) continue;
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
agentId,
match,
cmdText,
analysis.segments[0]?.resolution?.resolvedPath,
);
}
}
if (params.needsScreenRecording === true) {
+23
View File
@@ -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 () => {
const cfg: ClawdbotConfig = { session: { dmScope: "main" } };
const plugins: ChannelPlugin[] = [
+11
View File
@@ -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 =
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
if (auth.mode === "token" && token && token.length < 24) {
+55 -29
View File
@@ -126,15 +126,27 @@ export class GatewayBrowserClient {
window.clearTimeout(this.connectTimer);
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 role = "operator";
const storedToken = loadDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role,
})?.token;
const authToken = storedToken ?? this.opts.token;
const canFallbackToShared = Boolean(storedToken && this.opts.token);
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
let canFallbackToShared = false;
let authToken = this.opts.token;
if (isSecureContext) {
deviceIdentity = await loadOrCreateDeviceIdentity();
const storedToken = loadDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role,
})?.token;
authToken = storedToken ?? this.opts.token;
canFallbackToShared = Boolean(storedToken && this.opts.token);
}
const auth =
authToken || this.opts.password
? {
@@ -142,19 +154,39 @@ export class GatewayBrowserClient {
password: this.opts.password,
}
: undefined;
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);
let device:
| {
id: string;
publicKey: string;
signature: string;
signedAt: number;
nonce: string | undefined;
}
| undefined;
if (isSecureContext && deviceIdentity) {
const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined;
const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
role,
scopes,
signedAtMs,
token: authToken ?? null,
nonce,
});
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
device = {
id: deviceIdentity.deviceId,
publicKey: deviceIdentity.publicKey,
signature,
signedAt: signedAtMs,
nonce,
};
}
const params = {
minProtocol: 3,
maxProtocol: 3,
@@ -167,13 +199,7 @@ export class GatewayBrowserClient {
},
role,
scopes,
device: {
id: deviceIdentity.deviceId,
publicKey: deviceIdentity.publicKey,
signature,
signedAt: signedAtMs,
nonce,
},
device,
caps: [],
auth,
userAgent: navigator.userAgent,
@@ -182,7 +208,7 @@ export class GatewayBrowserClient {
void this.request<GatewayHelloOk>("connect", params)
.then((hello) => {
if (hello?.auth?.deviceToken) {
if (hello?.auth?.deviceToken && deviceIdentity) {
storeDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role: hello.auth.role ?? role,
@@ -194,7 +220,7 @@ export class GatewayBrowserClient {
this.opts.onHello?.(hello);
})
.catch(() => {
if (canFallbackToShared) {
if (canFallbackToShared && deviceIdentity) {
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
}
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
+39
View File
@@ -77,6 +77,44 @@ export function renderOverview(props: OverviewProps) {
</div>
`;
})();
const insecureContextHint = (() => {
if (props.connected || !props.lastError) return null;
const isSecureContext = typeof window !== "undefined" ? window.isSecureContext : true;
if (isSecureContext !== false) return null;
const lower = props.lastError.toLowerCase();
if (!lower.includes("secure context") && !lower.includes("device identity required")) {
return null;
}
return html`
<div class="muted" style="margin-top: 8px;">
This page is HTTP, so the browser blocks device identity. Use HTTPS (Tailscale Serve) or
open <span class="mono">http://127.0.0.1:18789</span> on the gateway host.
<div style="margin-top: 6px;">
If you must stay on HTTP, set
<span class="mono">gateway.controlUi.allowInsecureAuth: true</span> (token-only).
</div>
<div style="margin-top: 6px;">
<a
class="session-link"
href="https://docs.clawd.bot/gateway/tailscale"
target="_blank"
rel="noreferrer"
title="Tailscale Serve docs (opens in new tab)"
>Docs: Tailscale Serve</a
>
<span class="muted"> · </span>
<a
class="session-link"
href="https://docs.clawd.bot/web/control-ui#insecure-http"
target="_blank"
rel="noreferrer"
title="Insecure HTTP docs (opens in new tab)"
>Docs: Insecure HTTP</a
>
</div>
</div>
`;
})();
return html`
<section class="grid grid-cols-2">
@@ -167,6 +205,7 @@ export function renderOverview(props: OverviewProps) {
? html`<div class="callout danger" style="margin-top: 14px;">
<div>${props.lastError}</div>
${authHint ?? ""}
${insecureContextHint ?? ""}
</div>`
: html`<div class="callout" style="margin-top: 14px;">
Use Channels to link WhatsApp, Telegram, Discord, Signal, or iMessage.