Merge remote-tracking branch 'origin/main' into feature/agent-avatar-support

This commit is contained in:
Peter Steinberger 2026-01-22 06:03:56 +00:00
commit 6539c09a93
84 changed files with 1323 additions and 381 deletions

View File

@ -9,16 +9,24 @@ Docs: https://docs.clawd.bot
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. - Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early. - Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting - Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Docs: add /model allowlist troubleshooting note. (#1405)
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky. - Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead). - Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Signal: add typing indicators and DM read receipts via signal-cli. - Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. - MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
### Breaking
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents dont have to constantly convert.
### Fixes ### Fixes
- Config: avoid stack traces for invalid configs and log the config path. - Config: avoid stack traces for invalid configs and log the config path.
- Doctor: avoid recreating WhatsApp config when only legacy routing keys remain. (#900)
- Doctor: warn when gateway.mode is unset with configure/config guidance. - Doctor: warn when gateway.mode is unset with configure/config guidance.
- OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416)
- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362) - macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr. - Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
- Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai. - UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
## 2026.1.21 ## 2026.1.21

View File

@ -149,6 +149,7 @@ struct ExecApprovalsResolvedDefaults {
enum ExecApprovalsStore { enum ExecApprovalsStore {
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals") private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
private static let defaultAgentId = "main"
private static let defaultSecurity: ExecSecurity = .deny private static let defaultSecurity: ExecSecurity = .deny
private static let defaultAsk: ExecAsk = .onMiss private static let defaultAsk: ExecAsk = .onMiss
private static let defaultAskFallback: ExecSecurity = .deny private static let defaultAskFallback: ExecSecurity = .deny
@ -165,13 +166,22 @@ enum ExecApprovalsStore {
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile { static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
var agents = file.agents ?? [:]
if let legacyDefault = agents["default"] {
if let main = agents[self.defaultAgentId] {
agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault)
} else {
agents[self.defaultAgentId] = legacyDefault
}
agents.removeValue(forKey: "default")
}
return ExecApprovalsFile( return ExecApprovalsFile(
version: 1, version: 1,
socket: ExecApprovalsSocketConfig( socket: ExecApprovalsSocketConfig(
path: socketPath.isEmpty ? nil : socketPath, path: socketPath.isEmpty ? nil : socketPath,
token: token.isEmpty ? nil : token), token: token.isEmpty ? nil : token),
defaults: file.defaults, defaults: file.defaults,
agents: file.agents) agents: agents)
} }
static func readSnapshot() -> ExecApprovalsSnapshot { static func readSnapshot() -> ExecApprovalsSnapshot {
@ -272,9 +282,7 @@ enum ExecApprovalsStore {
ask: defaults.ask ?? self.defaultAsk, ask: defaults.ask ?? self.defaultAsk,
askFallback: defaults.askFallback ?? self.defaultAskFallback, askFallback: defaults.askFallback ?? self.defaultAskFallback,
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) let key = self.agentKey(agentId)
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
: "default"
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent() let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent() let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
let resolvedAgent = ExecApprovalsResolvedDefaults( let resolvedAgent = ExecApprovalsResolvedDefaults(
@ -457,7 +465,36 @@ enum ExecApprovalsStore {
private static func agentKey(_ agentId: String?) -> String { private static func agentKey(_ agentId: String?) -> String {
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "default" : trimmed return trimmed.isEmpty ? self.defaultAgentId : trimmed
}
private static func normalizedPattern(_ pattern: String?) -> String? {
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed.lowercased()
}
private static func mergeAgents(
current: ExecApprovalsAgent,
legacy: ExecApprovalsAgent
) -> ExecApprovalsAgent {
var seen = Set<String>()
var allowlist: [ExecAllowlistEntry] = []
func append(_ entry: ExecAllowlistEntry) {
guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else {
return
}
seen.insert(key)
allowlist.append(entry)
}
for entry in current.allowlist ?? [] { append(entry) }
for entry in legacy.allowlist ?? [] { append(entry) }
return ExecApprovalsAgent(
security: current.security ?? legacy.security,
ask: current.ask ?? legacy.ask,
askFallback: current.askFallback ?? legacy.askFallback,
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
allowlist: allowlist.isEmpty ? nil : allowlist)
} }
} }

View File

@ -115,7 +115,7 @@ extension GatewayLaunchAgentManager {
quiet: Bool) async -> CommandResult quiet: Bool) async -> CommandResult
{ {
let command = CommandResolver.clawdbotCommand( let command = CommandResolver.clawdbotCommand(
subcommand: "daemon", subcommand: "gateway",
extraArgs: self.withJsonFlag(args), extraArgs: self.withJsonFlag(args),
// Launchd management must always run locally, even if remote mode is configured. // Launchd management must always run locally, even if remote mode is configured.
configRoot: ["gateway": ["mode": "local"]]) configRoot: ["gateway": ["mode": "local"]])

View File

@ -2,15 +2,12 @@ import AppKit
import ClawdbotDiscovery import ClawdbotDiscovery
import ClawdbotIPC import ClawdbotIPC
import ClawdbotKit import ClawdbotKit
import CoreLocation
import Observation import Observation
import SwiftUI import SwiftUI
struct GeneralSettings: View { struct GeneralSettings: View {
@Bindable var state: AppState @Bindable var state: AppState
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false @AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
private let healthStore = HealthStore.shared private let healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared private let gatewayManager = GatewayProcessManager.shared
@State private var gatewayDiscovery = GatewayDiscoveryModel( @State private var gatewayDiscovery = GatewayDiscoveryModel(
@ -20,7 +17,6 @@ struct GeneralSettings: View {
@State private var showRemoteAdvanced = false @State private var showRemoteAdvanced = false
private let isPreview = ProcessInfo.processInfo.isPreview private let isPreview = ProcessInfo.processInfo.isPreview
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode } private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
var body: some View { var body: some View {
ScrollView(.vertical) { ScrollView(.vertical) {
@ -60,27 +56,6 @@ struct GeneralSettings: View {
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.", subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
binding: self.$cameraEnabled) binding: self.$cameraEnabled)
VStack(alignment: .leading, spacing: 6) {
Text("Location Access")
.font(.body)
Picker("", selection: self.$locationModeRaw) {
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
}
.labelsHidden()
.pickerStyle(.menu)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
Text("Always may require System Settings to approve background location.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
SettingsToggleRow( SettingsToggleRow(
title: "Enable Peekaboo Bridge", title: "Enable Peekaboo Bridge",
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.", subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
@ -106,27 +81,12 @@ struct GeneralSettings: View {
.onAppear { .onAppear {
guard !self.isPreview else { return } guard !self.isPreview else { return }
self.refreshGatewayStatus() self.refreshGatewayStatus()
self.lastLocationModeRaw = self.locationModeRaw
} }
.onChange(of: self.state.canvasEnabled) { _, enabled in .onChange(of: self.state.canvasEnabled) { _, enabled in
if !enabled { if !enabled {
CanvasManager.shared.hideAll() CanvasManager.shared.hideAll()
} }
} }
.onChange(of: self.locationModeRaw) { _, newValue in
let previous = self.lastLocationModeRaw
self.lastLocationModeRaw = newValue
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
Task {
let granted = await self.requestLocationAuthorization(mode: mode)
if !granted {
await MainActor.run {
self.locationModeRaw = previous
self.lastLocationModeRaw = previous
}
}
}
}
} }
private var activeBinding: Binding<Bool> { private var activeBinding: Binding<Bool> {
@ -135,26 +95,6 @@ struct GeneralSettings: View {
set: { self.state.isPaused = !$0 }) set: { self.state.isPaused = !$0 })
} }
private var locationMode: ClawdbotLocationMode {
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
}
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
guard mode != .off else { return true }
guard CLLocationManager.locationServicesEnabled() else {
await MainActor.run { LocationPermissionHelper.openSettings() }
return false
}
let status = CLLocationManager().authorizationStatus
let requireAlways = mode == .always
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
return true
}
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
}
private var connectionSection: some View { private var connectionSection: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("Clawdbot runs") Text("Clawdbot runs")

View File

@ -1,4 +1,6 @@
import ClawdbotIPC import ClawdbotIPC
import ClawdbotKit
import CoreLocation
import SwiftUI import SwiftUI
struct PermissionsSettings: View { struct PermissionsSettings: View {
@ -17,6 +19,8 @@ struct PermissionsSettings: View {
.padding(.horizontal, 2) .padding(.horizontal, 2)
.padding(.vertical, 6) .padding(.vertical, 6)
LocationAccessSettings()
Button("Restart onboarding") { self.showOnboarding() } Button("Restart onboarding") { self.showOnboarding() }
.buttonStyle(.bordered) .buttonStyle(.bordered)
Spacer() Spacer()
@ -26,6 +30,72 @@ struct PermissionsSettings: View {
} }
} }
private struct LocationAccessSettings: View {
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Location Access")
.font(.body)
Picker("", selection: self.$locationModeRaw) {
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
}
.labelsHidden()
.pickerStyle(.menu)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
Text("Always may require System Settings to approve background location.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
.onAppear {
self.lastLocationModeRaw = self.locationModeRaw
}
.onChange(of: self.locationModeRaw) { _, newValue in
let previous = self.lastLocationModeRaw
self.lastLocationModeRaw = newValue
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
Task {
let granted = await self.requestLocationAuthorization(mode: mode)
if !granted {
await MainActor.run {
self.locationModeRaw = previous
self.lastLocationModeRaw = previous
}
}
}
}
}
private var locationMode: ClawdbotLocationMode {
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
}
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
guard mode != .off else { return true }
guard CLLocationManager.locationServicesEnabled() else {
await MainActor.run { LocationPermissionHelper.openSettings() }
return false
}
let status = CLLocationManager().authorizationStatus
let requireAlways = mode == .always
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
return true
}
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
}
}
struct PermissionStatusList: View { struct PermissionStatusList: View {
let status: [Capability: Bool] let status: [Capability: Bool]
let refresh: () async -> Void let refresh: () async -> Void

View File

@ -69,11 +69,13 @@ High-level:
1. Requires a clean worktree (no uncommitted changes). 1. Requires a clean worktree (no uncommitted changes).
2. Switches to the selected channel (tag or branch). 2. Switches to the selected channel (tag or branch).
3. Fetches and rebases against `@{upstream}` (dev only). 3. Fetches upstream (dev only).
4. Installs deps (pnpm preferred; npm fallback). 4. Dev only: preflight lint + TypeScript build in a temp worktree; if the tip fails, walks back up to 10 commits to find the newest clean build.
5. Builds + builds the Control UI. 5. Rebases onto the selected commit (dev only).
6. Runs `clawdbot doctor` as the final “safe update” check. 6. Installs deps (pnpm preferred; npm fallback).
7. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins. 7. Builds + builds the Control UI.
8. Runs `clawdbot doctor` as the final “safe update” check.
9. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
## `--update` shorthand ## `--update` shorthand

View File

@ -24,7 +24,7 @@ The prompt is intentionally compact and uses fixed sections:
- **Current Date & Time**: user-local time, timezone, and time format. - **Current Date & Time**: user-local time, timezone, and time format.
- **Reply Tags**: optional reply tag syntax for supported providers. - **Reply Tags**: optional reply tag syntax for supported providers.
- **Heartbeats**: heartbeat prompt and ack behavior. - **Heartbeats**: heartbeat prompt and ack behavior.
- **Runtime**: host, OS, node, model, thinking level (one line). - **Runtime**: host, OS, node, model, repo root (when detected), thinking level (one line).
- **Reasoning**: current visibility level + /reasoning toggle hint. - **Reasoning**: current visibility level + /reasoning toggle hint.
## Prompt modes ## Prompt modes

View File

@ -9,15 +9,15 @@ read_when:
Clawdbot standardizes timestamps so the model sees a **single reference time**. Clawdbot standardizes timestamps so the model sees a **single reference time**.
## Message envelopes (UTC by default) ## Message envelopes (local by default)
Inbound messages are wrapped in an envelope like: Inbound messages are wrapped in an envelope like:
``` ```
[Provider ... 2026-01-05T21:26Z] message text [Provider ... 2026-01-05 16:26 PST] message text
``` ```
The timestamp in the envelope is **UTC by default**, with minutes precision. The timestamp in the envelope is **host-local by default**, with minutes precision.
You can override this with: You can override this with:
@ -25,7 +25,7 @@ You can override this with:
{ {
agents: { agents: {
defaults: { defaults: {
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
envelopeTimestamp: "on", // "on" | "off" envelopeTimestamp: "on", // "on" | "off"
envelopeElapsed: "on" // "on" | "off" envelopeElapsed: "on" // "on" | "off"
} }
@ -33,6 +33,7 @@ You can override this with:
} }
``` ```
- `envelopeTimezone: "utc"` uses UTC.
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone). - `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset. - Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers. - `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
@ -40,10 +41,10 @@ You can override this with:
### Examples ### Examples
**UTC (default):** **Local (default):**
``` ```
[Signal Alice +1555 2026-01-18T05:19Z] hello [Signal Alice +1555 2026-01-18 00:19 PST] hello
``` ```
**Fixed timezone:** **Fixed timezone:**

View File

@ -7,18 +7,18 @@ read_when:
# Date & Time # Date & Time
Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**. Clawdbot defaults to **host-local time for transport timestamps** and **user-local time only in the system prompt**.
Provider timestamps are preserved so tools keep their native semantics. Provider timestamps are preserved so tools keep their native semantics.
## Message envelopes (UTC by default) ## Message envelopes (local by default)
Inbound messages are wrapped with a UTC timestamp (minute precision): Inbound messages are wrapped with a timestamp (minute precision):
``` ```
[Provider ... 2026-01-05T21:26Z] message text [Provider ... 2026-01-05 16:26 PST] message text
``` ```
This envelope timestamp is **UTC by default**, regardless of the host timezone. This envelope timestamp is **host-local by default**, regardless of the provider timezone.
You can override this behavior: You can override this behavior:
@ -26,7 +26,7 @@ You can override this behavior:
{ {
agents: { agents: {
defaults: { defaults: {
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
envelopeTimestamp: "on", // "on" | "off" envelopeTimestamp: "on", // "on" | "off"
envelopeElapsed: "on" // "on" | "off" envelopeElapsed: "on" // "on" | "off"
} }
@ -34,6 +34,7 @@ You can override this behavior:
} }
``` ```
- `envelopeTimezone: "utc"` uses UTC.
- `envelopeTimezone: "local"` uses the host timezone. - `envelopeTimezone: "local"` uses the host timezone.
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone). - `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone. - Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
@ -42,10 +43,10 @@ You can override this behavior:
### Examples ### Examples
**UTC (default):** **Local (default):**
``` ```
[WhatsApp +1555 2026-01-18T05:19Z] hello [WhatsApp +1555 2026-01-18 00:19 PST] hello
``` ```
**User timezone:** **User timezone:**
@ -73,12 +74,13 @@ Time format: 12-hour
If only the timezone is known, we still include the section and instruct the model If only the timezone is known, we still include the section and instruct the model
to assume UTC for unknown time references. to assume UTC for unknown time references.
## System event lines (UTC) ## System event lines (local by default)
Queued system events inserted into agent context are prefixed with a UTC timestamp: Queued system events inserted into agent context are prefixed with a timestamp using the
same timezone selection as message envelopes (default: host-local).
``` ```
System: [2026-01-12T20:19:17Z] Model switched. System: [2026-01-12 12:19:17 PST] Model switched.
``` ```
### Configure user timezone + format ### Configure user timezone + format

View File

@ -1280,6 +1280,18 @@ Default: `~/clawd`.
If `agents.defaults.sandbox` is enabled, non-main sessions can override this with their If `agents.defaults.sandbox` is enabled, non-main sessions can override this with their
own per-scope workspaces under `agents.defaults.sandbox.workspaceRoot`. own per-scope workspaces under `agents.defaults.sandbox.workspaceRoot`.
### `agents.defaults.repoRoot`
Optional repository root to show in the system prompts Runtime line. If unset, Clawdbot
tries to detect a `.git` directory by walking upward from the workspace (and current
working directory). The path must exist to be used.
```json5
{
agents: { defaults: { repoRoot: "~/Projects/clawdbot" } }
}
```
### `agents.defaults.skipBootstrap` ### `agents.defaults.skipBootstrap`
Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`). Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`).
@ -1983,7 +1995,7 @@ Per-agent override (further restrict):
Notes: Notes:
- `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can only further restrict (both must allow). - `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can only further restrict (both must allow).
- `/elevated on|off` stores state per session key; inline directives apply to a single message. - `/elevated on|off|ask|full` stores state per session key; inline directives apply to a single message.
- Elevated `exec` runs on the host and bypasses sandboxing. - Elevated `exec` runs on the host and bypasses sandboxing.
- Tool policy still applies; if `exec` is denied, elevated cannot be used. - Tool policy still applies; if `exec` is denied, elevated cannot be used.

View File

@ -91,7 +91,8 @@ Available groups:
## Elevated: exec-only “run on host” ## Elevated: exec-only “run on host”
Elevated does **not** grant extra tools; it only affects `exec`. Elevated does **not** grant extra tools; it only affects `exec`.
- If youre sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs on the host. - If youre sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs on the host (approvals may still apply).
- Use `/elevated full` to skip exec approvals for the session.
- If youre already running direct, elevated is effectively a no-op (still gated). - If youre already running direct, elevated is effectively a no-op (still gated).
- Elevated is **not** skill-scoped and does **not** override tool allow/deny. - Elevated is **not** skill-scoped and does **not** override tool allow/deny.

View File

@ -178,6 +178,20 @@ Even with strong system prompts, **prompt injection is not solved**. What helps
- Run sensitive tool execution in a sandbox; keep secrets out of the agents reachable filesystem. - Run sensitive tool execution in a sandbox; keep secrets out of the agents reachable filesystem.
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because its quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)). - **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because its quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
### Prompt injection does not require public DMs
Even if **only you** can message the bot, prompt injection can still happen via
any **untrusted content** the bot reads (web search/fetch results, browser pages,
emails, docs, attachments, pasted logs/code). In other words: the sender is not
the only threat surface; the **content itself** can carry adversarial instructions.
When tools are enabled, the typical risk is exfiltrating context or triggering
tool calls. Reduce the blast radius by:
- Using a read-only or tool-disabled **reader agent** to summarize untrusted content,
then pass the summary to your main agent.
- Keeping `web_search` / `web_fetch` / `browser` off for tool-enabled agents unless needed.
- Enabling sandboxing and strict tool allowlists for any agent that touches untrusted input.
### Model strength (security note) ### Model strength (security note)
Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts. Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts.
@ -187,6 +201,7 @@ Recommendations:
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes. - **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists). - If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
- When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled. - When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled.
- For chat-only personal assistants with trusted input and no tools, smaller models are usually fine.
## Reasoning & verbose output in groups ## Reasoning & verbose output in groups

View File

@ -53,6 +53,15 @@ Almost always a Node/npm PATH issue. Start here:
- [Models](/cli/models) - [Models](/cli/models)
- [OAuth / auth concepts](/concepts/oauth) - [OAuth / auth concepts](/concepts/oauth)
### `/model` says `model not allowed`
This usually means `agents.defaults.models` is configured as an allowlist. When its non-empty,
only those provider/model keys can be selected.
- Check the allowlist: `clawdbot config get agents.defaults.models`
- Add the model you want (or clear the allowlist) and retry `/model`
- Use `/models` to browse the allowed providers/models
### When filing an issue ### When filing an issue
Paste a safe report: Paste a safe report:

View File

@ -155,18 +155,21 @@ Quick diagnosis:
```bash ```bash
node -v node -v
npm -v npm -v
npm bin -g npm prefix -g
echo "$PATH" echo "$PATH"
``` ```
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell cant find global npm binaries (including `clawdbot`). If `$(npm prefix -g)/bin` (macOS/Linux) or `$(npm prefix -g)` (Windows) is **not** present inside `echo "$PATH"`, your shell cant find global npm binaries (including `clawdbot`).
Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`): Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`):
```bash ```bash
export PATH="/path/from/npm/bin/-g:$PATH" # macOS / Linux
export PATH="$(npm prefix -g)/bin:$PATH"
``` ```
On Windows, add the output of `npm prefix -g` to your PATH.
Then open a new terminal (or `rehash` in zsh / `hash -r` in bash). Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
## Update / uninstall ## Update / uninstall

View File

@ -19,33 +19,36 @@ Run:
```bash ```bash
node -v node -v
npm -v npm -v
npm bin -g npm prefix -g
echo "$PATH" echo "$PATH"
``` ```
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell cant find global npm binaries (including `clawdbot`). If `$(npm prefix -g)/bin` (macOS/Linux) or `$(npm prefix -g)` (Windows) is **not** present inside `echo "$PATH"`, your shell cant find global npm binaries (including `clawdbot`).
## Fix: put npms global bin dir on PATH ## Fix: put npms global bin dir on PATH
1) Find your global bin directory: 1) Find your global npm prefix:
```bash ```bash
npm bin -g npm prefix -g
``` ```
2) Add it to your shell startup file: 2) Add the global npm bin directory to your shell startup file:
- zsh: `~/.zshrc` - zsh: `~/.zshrc`
- bash: `~/.bashrc` - bash: `~/.bashrc`
Example (replace the path with your `npm bin -g` output): Example (replace the path with your `npm prefix -g` output):
```bash ```bash
export PATH="/path/from/npm/bin/-g:$PATH" # macOS / Linux
export PATH="/path/from/npm/prefix/bin:$PATH"
``` ```
Then open a **new terminal** (or run `rehash` in zsh / `hash -r` in bash). Then open a **new terminal** (or run `rehash` in zsh / `hash -r` in bash).
On Windows, add the output of `npm prefix -g` to your PATH.
## Fix: avoid `sudo npm install -g` / permission errors (Linux) ## Fix: avoid `sudo npm install -g` / permission errors (Linux)
If `npm install -g ...` fails with `EACCES`, switch npms global prefix to a user-writable directory: If `npm install -g ...` fails with `EACCES`, switch npms global prefix to a user-writable directory:
@ -63,7 +66,7 @@ Persist the `export PATH=...` line in your shell startup file.
Youll have the fewest surprises if Node/npm are installed in a way that: Youll have the fewest surprises if Node/npm are installed in a way that:
- keeps Node updated (22+) - keeps Node updated (22+)
- makes `npm bin -g` stable and on PATH in new shells - makes the global npm bin dir stable and on PATH in new shells
Common choices: Common choices:

View File

@ -216,7 +216,7 @@ Option B:
## Slash commands ## Slash commands
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` - `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>`
- Per-agent, per-session overrides; non-persistent unless saved via config. - Per-agent, per-session overrides; non-persistent unless saved via config.
- `/elevated on|off` remains a shortcut for `host=gateway security=full`. - `/elevated on|off|ask|full` remains a shortcut for `host=gateway security=full` (with `full` skipping approvals).
## Cross-platform story ## Cross-platform story
- The runner service is the portable execution target. - The runner service is the portable execution target.

View File

@ -117,6 +117,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent) - [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
- [Security and access control](#security-and-access-control) - [Security and access control](#security-and-access-control)
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms) - [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
- [Is prompt injection only a concern for public bots?](#is-prompt-injection-only-a-concern-for-public-bots)
- [Can I use cheaper models for personal assistant tasks?](#can-i-use-cheaper-models-for-personal-assistant-tasks)
- [I ran `/start` in Telegram but didnt get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code) - [I ran `/start` in Telegram but didnt get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code)
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work) - [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
- [Chat commands, aborting tasks, and “it wont stop”](#chat-commands-aborting-tasks-and-it-wont-stop) - [Chat commands, aborting tasks, and “it wont stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
@ -1539,6 +1541,28 @@ Treat inbound DMs as untrusted input. Defaults are designed to reduce risk:
Run `clawdbot doctor` to surface risky DM policies. Run `clawdbot doctor` to surface risky DM policies.
### Is prompt injection only a concern for public bots?
No. Prompt injection is about **untrusted content**, not just who can DM the bot.
If your assistant reads external content (web search/fetch, browser pages, emails,
docs, attachments, pasted logs), that content can include instructions that try
to hijack the model. This can happen even if **you are the only sender**.
The biggest risk is when tools are enabled: the model can be tricked into
exfiltrating context or calling tools on your behalf. Reduce the blast radius by:
- using a read-only or tool-disabled "reader" agent to summarize untrusted content
- keeping `web_search` / `web_fetch` / `browser` off for tool-enabled agents
- sandboxing and strict tool allowlists
Details: [Security](/gateway/security).
### Can I use cheaper models for personal assistant tasks?
Yes, **if** the agent is chat-only and the input is trusted. Smaller tiers are
more susceptible to instruction hijacking, so avoid them for tool-enabled agents
or when reading untrusted content. If you must use a smaller model, lock down
tools and run inside a sandbox. See [Security](/gateway/security).
### I ran `/start` in Telegram but didnt get a pairing code ### I ran `/start` in Telegram but didnt get a pairing code
Pairing codes are sent **only** when an unknown sender messages the bot and Pairing codes are sent **only** when an unknown sender messages the bot and

View File

@ -6,17 +6,20 @@ read_when:
# Elevated Mode (/elevated directives) # Elevated Mode (/elevated directives)
## What it does ## What it does
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full`. - `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full` (approvals still apply).
- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals).
- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`).
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host). - Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`. - Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`.
- Only `on|off` are accepted; anything else returns a hint and does not change state. - Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state.
## What it controls (and what it doesnt) ## What it controls (and what it doesnt)
- **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow). - **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow).
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key. - **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key.
- **Inline directive**: `/elevated on` inside a message applies to that message only. - **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only.
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned. - **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
- **Host execution**: elevated forces `exec` onto the gateway host with full security. - **Host execution**: elevated forces `exec` onto the gateway host with full security.
- **Approvals**: `full` skips exec approvals; `on`/`ask` still honor them.
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status. - **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used. - **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
@ -26,8 +29,8 @@ read_when:
3. Global default (`agents.defaults.elevatedDefault` in config). 3. Global default (`agents.defaults.elevatedDefault` in config).
## Setting a session default ## Setting a session default
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`. - Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated full`.
- Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`). - Confirmation reply is sent (`Elevated mode set to full...` / `Elevated mode disabled.`).
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state. - If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state.
- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level. - Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level.
@ -41,4 +44,4 @@ read_when:
## Logging + status ## Logging + status
- Elevated exec calls are logged at info level. - Elevated exec calls are logged at info level.
- Session status includes elevated mode (e.g. `elevated=on`). - Session status includes elevated mode (e.g. `elevated=ask`, `elevated=full`).

View File

@ -11,7 +11,7 @@ read_when:
Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run Exec approvals are the **companion app / node host guardrail** for letting a sandboxed agent run
commands on a real host (`gateway` or `node`). Think of it like a safety interlock: commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
commands are allowed only when policy + allowlist + (optional) user approval all agree. commands are allowed only when policy + allowlist + (optional) user approval all agree.
Exec approvals are **in addition** to tool policy and elevated gating. Exec approvals are **in addition** to tool policy and elevated gating (unless elevated is set to `full`, which skips approvals).
If the companion app UI is **not available**, any request that requires a prompt is If the companion app UI is **not available**, any request that requires a prompt is
resolved by the **ask fallback** (default: deny). resolved by the **ask fallback** (default: deny).
@ -88,6 +88,7 @@ If a prompt is required but no UI is reachable, fallback decides:
Allowlists are **per agent**. If multiple agents exist, switch which agent youre Allowlists are **per agent**. If multiple agents exist, switch which agent youre
editing in the macOS app. Patterns are **case-insensitive glob matches**. editing in the macOS app. Patterns are **case-insensitive glob matches**.
Patterns should resolve to **binary paths** (basename-only entries are ignored). Patterns should resolve to **binary paths** (basename-only entries are ignored).
Legacy `agents.default` entries are migrated to `agents.main` on load.
Examples: Examples:
- `~/Projects/**/bin/bird` - `~/Projects/**/bin/bird`

View File

@ -78,7 +78,7 @@ Text + native (when enabled):
- `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`) - `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
- `/verbose on|full|off` (alias: `/v`) - `/verbose on|full|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only) - `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`) - `/elevated on|off|ask|full` (alias: `/elev`; `full` skips exec approvals)
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current) - `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>` (send `/exec` to show current)
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`) - `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
- `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) - `/queue <mode>` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings)

View File

@ -78,7 +78,7 @@ Session controls:
- `/verbose <on|full|off>` - `/verbose <on|full|off>`
- `/reasoning <on|off|stream>` - `/reasoning <on|off|stream>`
- `/usage <off|tokens|full>` - `/usage <off|tokens|full>`
- `/elevated <on|off>` (alias: `/elev`) - `/elevated <on|off|ask|full>` (alias: `/elev`)
- `/activation <mention|always>` - `/activation <mention|always>`
- `/deliver <on|off>` - `/deliver <on|off>`

View File

@ -134,3 +134,29 @@ pnpm ui:dev # auto-installs UI deps on first run
``` ```
Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`). Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`).
## Debugging/testing: dev server + remote Gateway
The Control UI is static files; the WebSocket target is configurable and can be
different from the HTTP origin. This is handy when you want the Vite dev server
locally but the Gateway runs elsewhere.
1) Start the UI dev server: `pnpm ui:dev`
2) Open a URL like:
```text
http://localhost:5173/?gatewayUrl=ws://<gateway-host>:18789
```
Optional one-time auth (if needed):
```text
http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789&token=<gateway-token>
```
Notes:
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
- `token` is stored in localStorage; `password` is kept in memory only.
- Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.).
Remote access setup details: [Remote access](/gateway/remote).

View File

@ -7,19 +7,32 @@ import { describe, expect, it } from "vitest";
import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/plugins/types.js"; import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/plugins/types.js";
import { createLobsterTool } from "./lobster-tool.js"; import { createLobsterTool } from "./lobster-tool.js";
async function writeFakeLobster(params: { async function writeFakeLobsterScript(scriptBody: string, prefix = "clawdbot-lobster-plugin-") {
payload: unknown; const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
}) { const isWindows = process.platform === "win32";
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-"));
if (isWindows) {
const scriptPath = path.join(dir, "lobster.js");
const cmdPath = path.join(dir, "lobster.cmd");
await fs.writeFile(scriptPath, scriptBody, { encoding: "utf8" });
const cmd = `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`;
await fs.writeFile(cmdPath, cmd, { encoding: "utf8" });
return { dir, binPath: cmdPath };
}
const binPath = path.join(dir, "lobster"); const binPath = path.join(dir, "lobster");
const file = `#!/usr/bin/env node\n${scriptBody}\n`;
const file = `#!/usr/bin/env node\n` +
`process.stdout.write(JSON.stringify(${JSON.stringify(params.payload)}));\n`;
await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 }); await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 });
return { dir, binPath }; return { dir, binPath };
} }
async function writeFakeLobster(params: { payload: unknown }) {
const scriptBody =
`const payload = ${JSON.stringify(params.payload)};\n` +
`process.stdout.write(JSON.stringify(payload));\n`;
return await writeFakeLobsterScript(scriptBody);
}
function fakeApi(): ClawdbotPluginApi { function fakeApi(): ClawdbotPluginApi {
return { return {
id: "lobster", id: "lobster",
@ -82,12 +95,10 @@ describe("lobster plugin tool", () => {
}); });
it("rejects invalid JSON from lobster", async () => { it("rejects invalid JSON from lobster", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-bad-")); const { binPath } = await writeFakeLobsterScript(
const binPath = path.join(dir, "lobster"); `process.stdout.write("nope");\n`,
await fs.writeFile(binPath, `#!/usr/bin/env node\nprocess.stdout.write('nope');\n`, { "clawdbot-lobster-plugin-bad-",
encoding: "utf8", );
mode: 0o755,
});
const tool = createLobsterTool(fakeApi()); const tool = createLobsterTool(fakeApi());
await expect( await expect(

View File

@ -29,13 +29,22 @@ function resolveExecutablePath(lobsterPathRaw: string | undefined) {
return lobsterPath; return lobsterPath;
} }
async function runLobsterSubprocess(params: { function isWindowsSpawnEINVAL(err: unknown) {
execPath: string; if (!err || typeof err !== "object") return false;
argv: string[]; const code = (err as { code?: unknown }).code;
cwd: string; return code === "EINVAL";
timeoutMs: number; }
maxStdoutBytes: number;
}) { async function runLobsterSubprocessOnce(
params: {
execPath: string;
argv: string[];
cwd: string;
timeoutMs: number;
maxStdoutBytes: number;
},
useShell: boolean,
) {
const { execPath, argv, cwd } = params; const { execPath, argv, cwd } = params;
const timeoutMs = Math.max(200, params.timeoutMs); const timeoutMs = Math.max(200, params.timeoutMs);
const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes); const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
@ -51,6 +60,8 @@ async function runLobsterSubprocess(params: {
cwd, cwd,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env, env,
shell: useShell,
windowsHide: useShell ? true : undefined,
}); });
let stdout = ""; let stdout = "";
@ -102,6 +113,23 @@ async function runLobsterSubprocess(params: {
}); });
} }
async function runLobsterSubprocess(params: {
execPath: string;
argv: string[];
cwd: string;
timeoutMs: number;
maxStdoutBytes: number;
}) {
try {
return await runLobsterSubprocessOnce(params, false);
} catch (err) {
if (process.platform === "win32" && isWindowsSpawnEINVAL(err)) {
return await runLobsterSubprocessOnce(params, true);
}
throw err;
}
}
function parseEnvelope(stdout: string): LobsterEnvelope { function parseEnvelope(stdout: string): LobsterEnvelope {
let parsed: unknown; let parsed: unknown;
try { try {

2
pnpm-lock.yaml generated
View File

@ -301,6 +301,8 @@ importers:
extensions/imessage: {} extensions/imessage: {}
extensions/lobster: {}
extensions/matrix: extensions/matrix:
dependencies: dependencies:
'@matrix-org/matrix-sdk-crypto-nodejs': '@matrix-org/matrix-sdk-crypto-nodejs':

View File

@ -7,7 +7,6 @@ TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}"
DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}" DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}"
SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}" SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}"
ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX) ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX)
ENT_TMP_APP=$(mktemp -t clawdbot-entitlements-app.XXXXXX)
ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX) ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX)
ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX) ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX)
@ -21,7 +20,6 @@ Env:
CODESIGN_TIMESTAMP=auto|on|off CODESIGN_TIMESTAMP=auto|on|off
DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround
SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit
ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1
HELP HELP
exit 0 exit 0
fi fi
@ -182,43 +180,13 @@ cat > "$ENT_TMP_RUNTIME" <<'PLIST'
</plist> </plist>
PLIST PLIST
cat > "$ENT_TMP_APP" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.usernotifications.time-sensitive</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
</dict>
</plist>
PLIST
if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP_BASE" >/dev/null 2>&1 || \ /usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP_BASE" >/dev/null 2>&1 || \
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP_BASE" /usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP_BASE"
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP" >/dev/null 2>&1 || \
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP"
echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)." echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)."
fi fi
# The time-sensitive entitlement is restricted and requires explicit enablement
# (and typically a matching provisioning profile). It is *not* safe to enable
# unconditionally for local debug packaging since AMFI will refuse to launch.
APP_ENTITLEMENTS="$ENT_TMP_APP_BASE" APP_ENTITLEMENTS="$ENT_TMP_APP_BASE"
if [[ "${ENABLE_TIME_SENSITIVE_NOTIFICATIONS:-}" == "1" ]]; then
APP_ENTITLEMENTS="$ENT_TMP_APP"
else
echo "Note: Time Sensitive Notifications entitlement disabled."
echo " To force it: ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1 scripts/codesign-mac-app.sh <app>"
fi
# clear extended attributes to avoid stale signatures # clear extended attributes to avoid stale signatures
xattr -cr "$APP_BUNDLE" 2>/dev/null || true xattr -cr "$APP_BUNDLE" 2>/dev/null || true

View File

@ -11,6 +11,7 @@ COPY src ./src
COPY scripts ./scripts COPY scripts ./scripts
COPY docs ./docs COPY docs ./docs
COPY skills ./skills COPY skills ./skills
COPY extensions/memory-core ./extensions/memory-core
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
RUN pnpm build RUN pnpm build

View File

@ -51,14 +51,27 @@ TRASH
start_s="$(date +%s)" start_s="$(date +%s)"
while true; do while true; do
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
if NEEDLE="$needle_compact" node --input-type=module -e " if grep -a -F -q "$needle" "$WIZARD_LOG_PATH"; then
return 0
fi
if NEEDLE=\"$needle_compact\" node --input-type=module -e "
import fs from \"node:fs\"; import fs from \"node:fs\";
const file = process.env.WIZARD_LOG_PATH; const file = process.env.WIZARD_LOG_PATH;
const needle = process.env.NEEDLE ?? \"\"; const needle = process.env.NEEDLE ?? \"\";
let text = \"\"; let text = \"\";
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); } try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
text = text.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\").replace(/\\s+/g, \"\"); if (text.length > 20000) text = text.slice(-20000);
process.exit(text.includes(needle) ? 0 : 1); const sanitize = (value) => value.replace(/[\\x00-\\x1f\\x7f]/g, \"\");
const haystack = sanitize(text);
const safeNeedle = sanitize(needle);
const needsEscape = new Set([\"\\\\\", \"^\", \"$\", \".\", \"*\", \"+\", \"?\", \"(\", \")\", \"[\", \"]\", \"{\", \"}\", \"|\"]);
let escaped = \"\";
for (const ch of safeNeedle) {
escaped += needsEscape.has(ch) ? \"\\\\\" + ch : ch;
}
const pattern = escaped.split(\"\").join(\".*\");
const re = new RegExp(pattern, \"i\");
process.exit(re.test(haystack) ? 0 : 1);
"; then "; then
return 0 return 0
fi fi
@ -80,13 +93,35 @@ TRASH
} }
wait_for_gateway() { wait_for_gateway() {
for _ in $(seq 1 10); do for _ in $(seq 1 20); do
if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway-e2e.log; then if node --input-type=module -e "
import net from 'node:net';
const socket = net.createConnection({ host: '127.0.0.1', port: 18789 });
const timeout = setTimeout(() => {
socket.destroy();
process.exit(1);
}, 500);
socket.on('connect', () => {
clearTimeout(timeout);
socket.end();
process.exit(0);
});
socket.on('error', () => {
clearTimeout(timeout);
process.exit(1);
});
" >/dev/null 2>&1; then
return 0 return 0
fi fi
if [ -f /tmp/gateway-e2e.log ] && grep -E -q "listening on ws://[^ ]+:18789" /tmp/gateway-e2e.log; then
if [ -n "${GATEWAY_PID:-}" ] && kill -0 "$GATEWAY_PID" 2>/dev/null; then
return 0
fi
fi
sleep 1 sleep 1
done done
cat /tmp/gateway-e2e.log echo "Gateway failed to start"
cat /tmp/gateway-e2e.log || true
return 1 return 1
} }
@ -116,7 +151,7 @@ TRASH
WIZARD_LOG_PATH="$log_path" WIZARD_LOG_PATH="$log_path"
export WIZARD_LOG_PATH export WIZARD_LOG_PATH
# Run under script to keep an interactive TTY for clack prompts. # Run under script to keep an interactive TTY for clack prompts.
script -q -c "$command" "$log_path" < "$input_fifo" & script -q -f -c "$command" "$log_path" < "$input_fifo" &
wizard_pid=$! wizard_pid=$!
exec 3> "$input_fifo" exec 3> "$input_fifo"
@ -129,8 +164,18 @@ TRASH
"$send_fn" "$send_fn"
if ! wait "$wizard_pid"; then
wizard_status=$?
exec 3>&-
rm -f "$input_fifo"
stop_gateway "$gw_pid"
echo "Wizard exited with status $wizard_status"
if [ -f "$log_path" ]; then
tail -n 160 "$log_path" || true
fi
exit "$wizard_status"
fi
exec 3>&- exec 3>&-
wait "$wizard_pid"
rm -f "$input_fifo" rm -f "$input_fifo"
stop_gateway "$gw_pid" stop_gateway "$gw_pid"
if [ -n "$validate_fn" ]; then if [ -n "$validate_fn" ]; then
@ -176,14 +221,18 @@ TRASH
send_local_basic() { send_local_basic() {
# Risk acknowledgement (default is "No"). # Risk acknowledgement (default is "No").
wait_for_log "Continue?" 60
send $'"'"'y\r'"'"' 0.6 send $'"'"'y\r'"'"' 0.6
# Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI. # Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI.
send $'"'"'\r'"'"' 0.5 if wait_for_log "Where will the Gateway run?" 20; then
send $'"'"'\r'"'"' 0.5
fi
select_skip_hooks select_skip_hooks
} }
send_reset_config_only() { send_reset_config_only() {
# Risk acknowledgement (default is "No"). # Risk acknowledgement (default is "No").
wait_for_log "Continue?" 40 || true
send $'"'"'y\r'"'"' 0.8 send $'"'"'y\r'"'"' 0.8
# Select reset flow for existing config. # Select reset flow for existing config.
wait_for_log "Config handling" 40 || true wait_for_log "Config handling" 40 || true
@ -211,19 +260,27 @@ TRASH
send_skills_flow() { send_skills_flow() {
# Select skills section and skip optional installs. # Select skills section and skip optional installs.
wait_for_log "Where will the Gateway run?" 40 || true send $'"'"'\r'"'"' 1.2
send $'"'"'\r'"'"' 0.8
# Configure skills now? -> No # Configure skills now? -> No
wait_for_log "Configure skills now?" 40 || true send $'"'"'n\r'"'"' 1.5
send $'"'"'n\r'"'"' 0.8 send "" 1.0
wait_for_log "Configure complete." 40 || true
send "" 0.8
} }
run_case_local_basic() { run_case_local_basic() {
local home_dir local home_dir
home_dir="$(make_home local-basic)" home_dir="$(make_home local-basic)"
run_wizard local-basic "$home_dir" send_local_basic validate_local_basic_log export HOME="$home_dir"
mkdir -p "$HOME"
node dist/index.js onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--skip-channels \
--skip-skills \
--skip-daemon \
--skip-ui \
--skip-health
# Assert config + workspace scaffolding. # Assert config + workspace scaffolding.
workspace_dir="$HOME/clawd" workspace_dir="$HOME/clawd"
@ -283,25 +340,6 @@ if (errors.length > 0) {
} }
NODE NODE
node dist/index.js gateway --port 18789 --bind loopback > /tmp/gateway.log 2>&1 &
GW_PID=$!
# Gate on gateway readiness, then run health.
for _ in $(seq 1 10); do
if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then
break
fi
sleep 1
done
if ! grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then
cat /tmp/gateway.log
exit 1
fi
node dist/index.js health --timeout 2000 || (cat /tmp/gateway.log && exit 1)
kill "$GW_PID"
wait "$GW_PID" || true
} }
run_case_remote_non_interactive() { run_case_remote_non_interactive() {
@ -355,7 +393,7 @@ NODE
# Seed a remote config to exercise reset path. # Seed a remote config to exercise reset path.
cat > "$HOME/.clawdbot/clawdbot.json" <<'"'"'JSON'"'"' cat > "$HOME/.clawdbot/clawdbot.json" <<'"'"'JSON'"'"'
{ {
"agent": { "workspace": "/root/old" }, "agents": { "defaults": { "workspace": "/root/old" } },
"gateway": { "gateway": {
"mode": "remote", "mode": "remote",
"remote": { "url": "ws://old.example:18789", "token": "old-token" } "remote": { "url": "ws://old.example:18789", "token": "old-token" }
@ -363,7 +401,17 @@ NODE
} }
JSON JSON
run_wizard reset-config "$home_dir" send_reset_config_only node dist/index.js onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--reset \
--skip-channels \
--skip-skills \
--skip-daemon \
--skip-ui \
--skip-health
config_path="$HOME/.clawdbot/clawdbot.json" config_path="$HOME/.clawdbot/clawdbot.json"
assert_file "$config_path" assert_file "$config_path"

View File

@ -54,6 +54,7 @@ import { callGatewayTool } from "./tools/gateway.js";
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js"; import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js"; import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js"; import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
const DEFAULT_MAX_OUTPUT = clampNumber( const DEFAULT_MAX_OUTPUT = clampNumber(
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"), readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
@ -139,7 +140,7 @@ export type { BashSandboxConfig } from "./bash-tools.shared.js";
export type ExecElevatedDefaults = { export type ExecElevatedDefaults = {
enabled: boolean; enabled: boolean;
allowed: boolean; allowed: boolean;
defaultLevel: "on" | "off"; defaultLevel: "on" | "off" | "ask" | "full";
}; };
const execSchema = Type.Object({ const execSchema = Type.Object({
@ -659,6 +660,11 @@ export function createExecTool(
const notifyOnExit = defaults?.notifyOnExit !== false; const notifyOnExit = defaults?.notifyOnExit !== false;
const notifySessionKey = defaults?.sessionKey?.trim() || undefined; const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
const approvalRunningNoticeMs = resolveApprovalRunningNoticeMs(defaults?.approvalRunningNoticeMs); const approvalRunningNoticeMs = resolveApprovalRunningNoticeMs(defaults?.approvalRunningNoticeMs);
// Derive agentId only when sessionKey is an agent session key.
const parsedAgentSession = parseAgentSessionKey(defaults?.sessionKey);
const agentId =
defaults?.agentId ??
(parsedAgentSession ? resolveAgentIdFromSessionKey(defaults?.sessionKey) : undefined);
return { return {
name: "exec", name: "exec",
@ -700,12 +706,23 @@ export function createExecTool(
: clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000) : clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000)
: null; : null;
const elevatedDefaults = defaults?.elevated; const elevatedDefaults = defaults?.elevated;
const elevatedDefaultOn = const elevatedDefaultMode =
elevatedDefaults?.defaultLevel === "on" && elevatedDefaults?.defaultLevel === "full"
elevatedDefaults.enabled && ? "full"
elevatedDefaults.allowed; : elevatedDefaults?.defaultLevel === "ask"
const elevatedRequested = ? "ask"
typeof params.elevated === "boolean" ? params.elevated : elevatedDefaultOn; : elevatedDefaults?.defaultLevel === "on"
? "ask"
: "off";
const elevatedMode =
typeof params.elevated === "boolean"
? params.elevated
? elevatedDefaultMode === "full"
? "full"
: "ask"
: "off"
: elevatedDefaultMode;
const elevatedRequested = elevatedMode !== "off";
if (elevatedRequested) { if (elevatedRequested) {
if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) { if (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) {
const runtime = defaults?.sandbox ? "sandboxed" : "direct"; const runtime = defaults?.sandbox ? "sandboxed" : "direct";
@ -761,6 +778,10 @@ export function createExecTool(
const configuredAsk = defaults?.ask ?? "on-miss"; const configuredAsk = defaults?.ask ?? "on-miss";
const requestedAsk = normalizeExecAsk(params.ask); const requestedAsk = normalizeExecAsk(params.ask);
let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk); let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk);
const bypassApprovals = elevatedRequested && elevatedMode === "full";
if (bypassApprovals) {
ask = "off";
}
const sandbox = host === "sandbox" ? defaults?.sandbox : undefined; const sandbox = host === "sandbox" ? defaults?.sandbox : undefined;
const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd(); const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd();
@ -799,7 +820,7 @@ export function createExecTool(
if (host === "node") { if (host === "node") {
const approvals = resolveExecApprovals( const approvals = resolveExecApprovals(
defaults?.agentId, agentId,
host === "node" ? { security: "allowlist" } : undefined, host === "node" ? { security: "allowlist" } : undefined,
); );
const hostSecurity = minSecurity(security, approvals.agent.security); const hostSecurity = minSecurity(security, approvals.agent.security);
@ -865,7 +886,7 @@ export function createExecTool(
cwd: workdir, cwd: workdir,
env: nodeEnv, env: nodeEnv,
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined, timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
agentId: defaults?.agentId, agentId,
sessionKey: defaults?.sessionKey, sessionKey: defaults?.sessionKey,
approved: approvedByAsk, approved: approvedByAsk,
approvalDecision: approvalDecision ?? undefined, approvalDecision: approvalDecision ?? undefined,
@ -895,9 +916,9 @@ export function createExecTool(
host: "node", host: "node",
security: hostSecurity, security: hostSecurity,
ask: hostAsk, ask: hostAsk,
agentId: defaults?.agentId, agentId,
resolvedPath: undefined, resolvedPath: null,
sessionKey: defaults?.sessionKey, sessionKey: defaults?.sessionKey ?? null,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
}, },
)) as { decision?: string } | null; )) as { decision?: string } | null;
@ -1025,8 +1046,8 @@ export function createExecTool(
}; };
} }
if (host === "gateway") { if (host === "gateway" && !bypassApprovals) {
const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" }); const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
const hostSecurity = minSecurity(security, approvals.agent.security); const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask); const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback; const askFallback = approvals.agent.askFallback;
@ -1060,7 +1081,7 @@ export function createExecTool(
const approvalSlug = createApprovalSlug(approvalId); const approvalSlug = createApprovalSlug(approvalId);
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
const contextKey = `exec:${approvalId}`; const contextKey = `exec:${approvalId}`;
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath; const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath ?? null;
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000)); const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
const commandText = params.command; const commandText = params.command;
const effectiveTimeout = const effectiveTimeout =
@ -1080,9 +1101,9 @@ export function createExecTool(
host: "gateway", host: "gateway",
security: hostSecurity, security: hostSecurity,
ask: hostAsk, ask: hostAsk,
agentId: defaults?.agentId, agentId,
resolvedPath, resolvedPath,
sessionKey: defaults?.sessionKey, sessionKey: defaults?.sessionKey ?? null,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
}, },
)) as { decision?: string } | null; )) as { decision?: string } | null;
@ -1123,7 +1144,7 @@ export function createExecTool(
for (const segment of analysis.segments) { for (const segment of analysis.segments) {
const pattern = segment.resolution?.resolvedPath ?? ""; const pattern = segment.resolution?.resolvedPath ?? "";
if (pattern) { if (pattern) {
addAllowlistEntry(approvals.file, defaults?.agentId, pattern); addAllowlistEntry(approvals.file, agentId, pattern);
} }
} }
} }
@ -1152,7 +1173,7 @@ export function createExecTool(
seen.add(match.pattern); seen.add(match.pattern);
recordAllowlistUse( recordAllowlistUse(
approvals.file, approvals.file,
defaults?.agentId, agentId,
match, match,
commandText, commandText,
resolvedPath ?? undefined, resolvedPath ?? undefined,
@ -1242,7 +1263,7 @@ export function createExecTool(
seen.add(match.pattern); seen.add(match.pattern);
recordAllowlistUse( recordAllowlistUse(
approvals.file, approvals.file,
defaults?.agentId, agentId,
match, match,
params.command, params.command,
analysis.segments[0]?.resolution?.resolvedPath, analysis.segments[0]?.resolution?.resolvedPath,

View File

@ -183,6 +183,8 @@ export function buildSystemPrompt(params: {
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.config, config: params.config,
agentId: params.agentId, agentId: params.agentId,
workspaceDir: params.workspaceDir,
cwd: process.cwd(),
runtime: { runtime: {
host: "clawdbot", host: "clawdbot",
os: `${os.type()} ${os.release()}`, os: `${os.type()} ${os.release()}`,

View File

@ -68,6 +68,10 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in"); return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
} }
function isInstructionsRequiredError(raw: string): boolean {
return /instructions are required/i.test(raw);
}
function toInt(value: string | undefined, fallback: number): number { function toInt(value: string | undefined, fallback: number): number {
const trimmed = value?.trim(); const trimmed = value?.trim();
if (!trimmed) return fallback; if (!trimmed) return fallback;
@ -443,6 +447,15 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (chatgpt usage limit)`); logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
break; break;
} }
if (
allowNotFoundSkip &&
model.provider === "openai-codex" &&
isInstructionsRequiredError(message)
) {
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (instructions required)`);
break;
}
logProgress(`${progressLabel}: failed`); logProgress(`${progressLabel}: failed`);
failures.push({ model: id, error: message }); failures.push({ model: id, error: message });
break; break;

View File

@ -44,6 +44,8 @@ describe("resolveOpencodeZenModelApi", () => {
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages"); expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai"); expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses"); expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions"); expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions"); expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
}); });

View File

@ -87,19 +87,19 @@ export function resolveOpencodeZenAlias(modelIdOrAlias: string): string {
} }
/** /**
* OpenCode Zen routes models to different APIs based on model family. * OpenCode Zen routes models to specific API shapes by family.
*/ */
export function resolveOpencodeZenModelApi(modelId: string): ModelApi { export function resolveOpencodeZenModelApi(modelId: string): ModelApi {
const lower = modelId.toLowerCase(); const lower = modelId.toLowerCase();
if (lower.startsWith("claude-") || lower.startsWith("minimax") || lower.startsWith("alpha-gd4")) { if (lower.startsWith("gpt-")) {
return "openai-responses";
}
if (lower.startsWith("claude-") || lower.startsWith("minimax-")) {
return "anthropic-messages"; return "anthropic-messages";
} }
if (lower.startsWith("gemini-")) { if (lower.startsWith("gemini-")) {
return "google-generative-ai"; return "google-generative-ai";
} }
if (lower.startsWith("gpt-")) {
return "openai-responses";
}
return "openai-completions"; return "openai-completions";
} }

View File

@ -279,6 +279,8 @@ export async function runEmbeddedAttempt(
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.config, config: params.config,
agentId: sessionAgentId, agentId: sessionAgentId,
workspaceDir: effectiveWorkspace,
cwd: process.cwd(),
runtime: { runtime: {
host: machineName, host: machineName,
os: `${os.type()} ${os.release()}`, os: `${os.type()} ${os.release()}`,

View File

@ -76,6 +76,6 @@ export type EmbeddedSandboxInfo = {
allowedControlPorts?: number[]; allowedControlPorts?: number[];
elevated?: { elevated?: {
allowed: boolean; allowed: boolean;
defaultLevel: "on" | "off"; defaultLevel: "on" | "off" | "ask" | "full";
}; };
}; };

View File

@ -0,0 +1,106 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { buildSystemPromptParams } from "./system-prompt-params.js";
async function makeTempDir(label: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), `clawdbot-${label}-`));
}
async function makeRepoRoot(root: string): Promise<void> {
await fs.mkdir(path.join(root, ".git"), { recursive: true });
}
function buildParams(params: { config?: ClawdbotConfig; workspaceDir?: string; cwd?: string }) {
return buildSystemPromptParams({
config: params.config,
workspaceDir: params.workspaceDir,
cwd: params.cwd,
runtime: {
host: "host",
os: "os",
arch: "arch",
node: "node",
model: "model",
},
});
}
describe("buildSystemPromptParams repo root", () => {
it("detects repo root from workspaceDir", async () => {
const temp = await makeTempDir("workspace");
const repoRoot = path.join(temp, "repo");
const workspaceDir = path.join(repoRoot, "nested", "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(repoRoot);
const { runtimeInfo } = buildParams({ workspaceDir });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("falls back to cwd when workspaceDir has no repo", async () => {
const temp = await makeTempDir("cwd");
const repoRoot = path.join(temp, "repo");
const workspaceDir = path.join(temp, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(repoRoot);
const { runtimeInfo } = buildParams({ workspaceDir, cwd: repoRoot });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("uses configured repoRoot when valid", async () => {
const temp = await makeTempDir("config");
const repoRoot = path.join(temp, "config-root");
const workspaceDir = path.join(temp, "workspace");
await fs.mkdir(repoRoot, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(workspaceDir);
const config: ClawdbotConfig = {
agents: {
defaults: {
repoRoot,
},
},
};
const { runtimeInfo } = buildParams({ config, workspaceDir });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("ignores invalid repoRoot config and auto-detects", async () => {
const temp = await makeTempDir("invalid");
const repoRoot = path.join(temp, "repo");
const workspaceDir = path.join(repoRoot, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await makeRepoRoot(repoRoot);
const config: ClawdbotConfig = {
agents: {
defaults: {
repoRoot: path.join(temp, "missing"),
},
},
};
const { runtimeInfo } = buildParams({ config, workspaceDir });
expect(runtimeInfo.repoRoot).toBe(repoRoot);
});
it("returns undefined when no repo is found", async () => {
const workspaceDir = await makeTempDir("norepo");
const { runtimeInfo } = buildParams({ workspaceDir });
expect(runtimeInfo.repoRoot).toBeUndefined();
});
});

View File

@ -1,3 +1,6 @@
import fs from "node:fs";
import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { import {
formatUserTime, formatUserTime,
@ -18,6 +21,7 @@ export type RuntimeInfoInput = {
capabilities?: string[]; capabilities?: string[];
/** Supported message actions for the current channel (e.g., react, edit, unsend) */ /** Supported message actions for the current channel (e.g., react, edit, unsend) */
channelActions?: string[]; channelActions?: string[];
repoRoot?: string;
}; };
export type SystemPromptRuntimeParams = { export type SystemPromptRuntimeParams = {
@ -31,7 +35,14 @@ export function buildSystemPromptParams(params: {
config?: ClawdbotConfig; config?: ClawdbotConfig;
agentId?: string; agentId?: string;
runtime: Omit<RuntimeInfoInput, "agentId">; runtime: Omit<RuntimeInfoInput, "agentId">;
workspaceDir?: string;
cwd?: string;
}): SystemPromptRuntimeParams { }): SystemPromptRuntimeParams {
const repoRoot = resolveRepoRoot({
config: params.config,
workspaceDir: params.workspaceDir,
cwd: params.cwd,
});
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat); const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
@ -39,9 +50,56 @@ export function buildSystemPromptParams(params: {
runtimeInfo: { runtimeInfo: {
agentId: params.agentId, agentId: params.agentId,
...params.runtime, ...params.runtime,
repoRoot,
}, },
userTimezone, userTimezone,
userTime, userTime,
userTimeFormat, userTimeFormat,
}; };
} }
function resolveRepoRoot(params: {
config?: ClawdbotConfig;
workspaceDir?: string;
cwd?: string;
}): string | undefined {
const configured = params.config?.agents?.defaults?.repoRoot?.trim();
if (configured) {
try {
const resolved = path.resolve(configured);
const stat = fs.statSync(resolved);
if (stat.isDirectory()) return resolved;
} catch {
// ignore invalid config path
}
}
const candidates = [params.workspaceDir, params.cwd]
.map((value) => value?.trim())
.filter(Boolean) as string[];
const seen = new Set<string>();
for (const candidate of candidates) {
const resolved = path.resolve(candidate);
if (seen.has(resolved)) continue;
seen.add(resolved);
const root = findGitRoot(resolved);
if (root) return root;
}
return undefined;
}
function findGitRoot(startDir: string): string | null {
let current = path.resolve(startDir);
for (let i = 0; i < 12; i += 1) {
const gitPath = path.join(current, ".git");
try {
const stat = fs.statSync(gitPath);
if (stat.isDirectory() || stat.isFile()) return current;
} catch {
// ignore missing .git at this level
}
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return null;
}

View File

@ -284,6 +284,7 @@ describe("buildAgentSystemPrompt", () => {
{ {
agentId: "work", agentId: "work",
host: "host", host: "host",
repoRoot: "/repo",
os: "macOS", os: "macOS",
arch: "arm64", arch: "arm64",
node: "v20", node: "v20",
@ -297,6 +298,7 @@ describe("buildAgentSystemPrompt", () => {
expect(line).toContain("agent=work"); expect(line).toContain("agent=work");
expect(line).toContain("host=host"); expect(line).toContain("host=host");
expect(line).toContain("repo=/repo");
expect(line).toContain("os=macOS (arm64)"); expect(line).toContain("os=macOS (arm64)");
expect(line).toContain("node=v20"); expect(line).toContain("node=v20");
expect(line).toContain("model=anthropic/claude"); expect(line).toContain("model=anthropic/claude");
@ -320,7 +322,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("You are running in a sandboxed runtime"); expect(prompt).toContain("You are running in a sandboxed runtime");
expect(prompt).toContain("Sub-agents stay sandboxed"); expect(prompt).toContain("Sub-agents stay sandboxed");
expect(prompt).toContain("User can toggle with /elevated on|off."); expect(prompt).toContain("User can toggle with /elevated on|off|ask|full.");
expect(prompt).toContain("Current elevated level: on"); expect(prompt).toContain("Current elevated level: on");
}); });

View File

@ -160,6 +160,7 @@ export function buildAgentSystemPrompt(params: {
defaultModel?: string; defaultModel?: string;
channel?: string; channel?: string;
capabilities?: string[]; capabilities?: string[];
repoRoot?: string;
}; };
messageToolHints?: string[]; messageToolHints?: string[];
sandboxInfo?: { sandboxInfo?: {
@ -175,7 +176,7 @@ export function buildAgentSystemPrompt(params: {
allowedControlPorts?: number[]; allowedControlPorts?: number[];
elevated?: { elevated?: {
allowed: boolean; allowed: boolean;
defaultLevel: "on" | "off"; defaultLevel: "on" | "off" | "ask" | "full";
}; };
}; };
/** Reaction guidance for the agent (for Telegram minimal/extensive modes). */ /** Reaction guidance for the agent (for Telegram minimal/extensive modes). */
@ -200,7 +201,7 @@ export function buildAgentSystemPrompt(params: {
browser: "Control web browser", browser: "Control web browser",
canvas: "Present/eval/snapshot the Canvas", canvas: "Present/eval/snapshot the Canvas",
nodes: "List/describe/notify/camera/screen on paired nodes", nodes: "List/describe/notify/camera/screen on paired nodes",
cron: "Manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)", cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
message: "Send messages and channel actions", message: "Send messages and channel actions",
gateway: "Restart, apply config, or run updates on the running Clawdbot process", gateway: "Restart, apply config, or run updates on the running Clawdbot process",
agents_list: "List agent ids allowed for sessions_spawn", agents_list: "List agent ids allowed for sessions_spawn",
@ -351,7 +352,7 @@ export function buildAgentSystemPrompt(params: {
"- browser: control clawd's dedicated browser", "- browser: control clawd's dedicated browser",
"- canvas: present/eval/snapshot the Canvas", "- canvas: present/eval/snapshot the Canvas",
"- nodes: list/describe/notify/camera/screen on paired nodes", "- nodes: list/describe/notify/camera/screen on paired nodes",
"- cron: manage cron jobs and wake events (use for reminders; include recent context in reminder text if appropriate)", "- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
"- sessions_list: list sessions", "- sessions_list: list sessions",
"- sessions_history: fetch session history", "- sessions_history: fetch session history",
"- sessions_send: send to another session", "- sessions_send: send to another session",
@ -443,12 +444,14 @@ export function buildAgentSystemPrompt(params: {
params.sandboxInfo.elevated?.allowed params.sandboxInfo.elevated?.allowed
? "Elevated exec is available for this session." ? "Elevated exec is available for this session."
: "", : "",
params.sandboxInfo.elevated?.allowed ? "User can toggle with /elevated on|off." : "",
params.sandboxInfo.elevated?.allowed params.sandboxInfo.elevated?.allowed
? "You may also send /elevated on|off when needed." ? "User can toggle with /elevated on|off|ask|full."
: "", : "",
params.sandboxInfo.elevated?.allowed params.sandboxInfo.elevated?.allowed
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (on runs exec on host; off runs in sandbox).` ? "You may also send /elevated on|off|ask|full when needed."
: "",
params.sandboxInfo.elevated?.allowed
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (ask runs exec on host with approvals; full auto-approves).`
: "", : "",
] ]
.filter(Boolean) .filter(Boolean)
@ -570,6 +573,7 @@ export function buildRuntimeLine(
node?: string; node?: string;
model?: string; model?: string;
defaultModel?: string; defaultModel?: string;
repoRoot?: string;
}, },
runtimeChannel?: string, runtimeChannel?: string,
runtimeCapabilities: string[] = [], runtimeCapabilities: string[] = [],
@ -578,6 +582,7 @@ export function buildRuntimeLine(
return `Runtime: ${[ return `Runtime: ${[
runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "", runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "",
runtimeInfo?.host ? `host=${runtimeInfo.host}` : "", runtimeInfo?.host ? `host=${runtimeInfo.host}` : "",
runtimeInfo?.repoRoot ? `repo=${runtimeInfo.repoRoot}` : "",
runtimeInfo?.os runtimeInfo?.os
? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}` ? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}`
: runtimeInfo?.arch : runtimeInfo?.arch

View File

@ -395,9 +395,9 @@ function buildChatCommands(): ChatCommandDefinition[] {
args: [ args: [
{ {
name: "mode", name: "mode",
description: "on or off", description: "on, off, ask, or full",
type: "string", type: "string",
choices: ["on", "off"], choices: ["on", "off", "ask", "full"],
}, },
], ],
argsMenu: "auto", argsMenu: "auto",

View File

@ -18,6 +18,7 @@ describe("formatAgentEnvelope", () => {
host: "mac-mini", host: "mac-mini",
ip: "10.0.0.5", ip: "10.0.0.5",
timestamp: ts, timestamp: ts,
envelope: { timezone: "utc" },
body: "hello", body: "hello",
}); });
@ -26,7 +27,7 @@ describe("formatAgentEnvelope", () => {
expect(body).toBe("[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello"); expect(body).toBe("[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello");
}); });
it("formats timestamps in UTC regardless of local timezone", () => { it("formats timestamps in local timezone by default", () => {
const originalTz = process.env.TZ; const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles"; process.env.TZ = "America/Los_Angeles";
@ -39,10 +40,10 @@ describe("formatAgentEnvelope", () => {
process.env.TZ = originalTz; process.env.TZ = originalTz;
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello"); expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
}); });
it("formats timestamps in local timezone when configured", () => { it("formats timestamps in UTC when configured", () => {
const originalTz = process.env.TZ; const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles"; process.env.TZ = "America/Los_Angeles";
@ -50,13 +51,13 @@ describe("formatAgentEnvelope", () => {
const body = formatAgentEnvelope({ const body = formatAgentEnvelope({
channel: "WebChat", channel: "WebChat",
timestamp: ts, timestamp: ts,
envelope: { timezone: "local" }, envelope: { timezone: "utc" },
body: "hello", body: "hello",
}); });
process.env.TZ = originalTz; process.env.TZ = originalTz;
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/); expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
}); });
it("formats timestamps in user timezone when configured", () => { it("formats timestamps in user timezone when configured", () => {

View File

@ -16,7 +16,7 @@ export type AgentEnvelopeParams = {
export type EnvelopeFormatOptions = { export type EnvelopeFormatOptions = {
/** /**
* "utc" (default), "local", "user", or an explicit IANA timezone string. * "local" (default), "utc", "user", or an explicit IANA timezone string.
*/ */
timezone?: string; timezone?: string;
/** /**
@ -59,7 +59,7 @@ function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEn
const includeTimestamp = options?.includeTimestamp !== false; const includeTimestamp = options?.includeTimestamp !== false;
const includeElapsed = options?.includeElapsed !== false; const includeElapsed = options?.includeElapsed !== false;
return { return {
timezone: options?.timezone?.trim() || "utc", timezone: options?.timezone?.trim() || "local",
includeTimestamp, includeTimestamp,
includeElapsed, includeElapsed,
userTimezone: options?.userTimezone, userTimezone: options?.userTimezone,
@ -77,7 +77,7 @@ function resolveExplicitTimezone(value: string): string | undefined {
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone { function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
const trimmed = options.timezone?.trim(); const trimmed = options.timezone?.trim();
if (!trimmed) return { mode: "utc" }; if (!trimmed) return { mode: "local" };
const lowered = trimmed.toLowerCase(); const lowered = trimmed.toLowerCase();
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" }; if (lowered === "utc" || lowered === "gmt") return { mode: "utc" };
if (lowered === "local" || lowered === "host") return { mode: "local" }; if (lowered === "local" || lowered === "host") return { mode: "local" };

View File

@ -219,7 +219,7 @@ describe("directive behavior", () => {
); );
const events = drainSystemEvents(MAIN_SESSION_KEY); const events = drainSystemEvents(MAIN_SESSION_KEY);
expect(events.some((e) => e.includes("Elevated ON"))).toBe(true); expect(events.some((e) => e.includes("Elevated ASK"))).toBe(true);
}); });
}); });
it("queues a system event when toggling reasoning", async () => { it("queues a system event when toggling reasoning", async () => {

View File

@ -150,7 +150,7 @@ describe("directive behavior", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled"); expect(text).toContain("Elevated mode set to ask");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });

View File

@ -143,7 +143,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current elevated level: on"); expect(text).toContain("Current elevated level: on");
expect(text).toContain("Options: on, off."); expect(text).toContain("Options: on, off, ask, full.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
}); });
}); });

View File

@ -55,6 +55,16 @@ describe("directive parsing", () => {
expect(res.hasDirective).toBe(true); expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("on"); expect(res.elevatedLevel).toBe("on");
}); });
it("matches elevated ask", () => {
const res = extractElevatedDirective("/elevated ask please");
expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("ask");
});
it("matches elevated full", () => {
const res = extractElevatedDirective("/elevated full please");
expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("full");
});
it("matches think at start of line", () => { it("matches think at start of line", () => {
const res = extractThinkDirective("/think:high run slow"); const res = extractThinkDirective("/think:high run slow");

View File

@ -129,7 +129,7 @@ describe("trigger handling", () => {
cfg, cfg,
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled"); expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8"); const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>; const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
@ -223,7 +223,7 @@ describe("trigger handling", () => {
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok"); expect(text).toBe("ok");
expect(text).not.toContain("Elevated mode enabled"); expect(text).not.toContain("Elevated mode set to ask");
}); });
}); });
}); });

View File

@ -184,7 +184,7 @@ describe("trigger handling", () => {
cfg, cfg,
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled"); expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8"); const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>; const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
@ -226,7 +226,7 @@ describe("trigger handling", () => {
cfg, cfg,
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled"); expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8"); const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>; const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;

View File

@ -167,7 +167,7 @@ describe("trigger handling", () => {
cfg, cfg,
); );
const text = Array.isArray(res) ? res[0]?.text : res?.text; const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled"); expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8"); const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>; const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;

View File

@ -102,6 +102,8 @@ async function resolveContextReport(
const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({
config: params.cfg, config: params.cfg,
agentId: sessionAgentId, agentId: sessionAgentId,
workspaceDir,
cwd: process.cwd(),
runtime: { runtime: {
host: "unknown", host: "unknown",
os: "unknown", os: "unknown",
@ -118,7 +120,7 @@ async function resolveContextReport(
workspaceAccess: "rw" as const, workspaceAccess: "rw" as const,
elevated: { elevated: {
allowed: params.elevated.allowed, allowed: params.elevated.allowed,
defaultLevel: params.resolvedElevatedLevel === "off" ? ("off" as const) : ("on" as const), defaultLevel: (params.resolvedElevatedLevel ?? "off") as "on" | "off" | "ask" | "full",
}, },
} }
: { enabled: false }; : { enabled: false };

View File

@ -45,7 +45,7 @@ function formatTimestampWithAge(valueMs?: number) {
} }
function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined { function resolveRequesterSessionKey(params: Parameters<CommandHandler>[0]): string | undefined {
const raw = params.ctx.CommandTargetSessionKey?.trim() || params.sessionKey; const raw = params.sessionKey?.trim() || params.ctx.CommandTargetSessionKey?.trim();
if (!raw) return undefined; if (!raw) return undefined;
const { mainKey, alias } = resolveMainSessionAlias(params.cfg); const { mainKey, alias } = resolveMainSessionAlias(params.cfg);
return resolveInternalSessionKey({ key: raw, alias, mainKey }); return resolveInternalSessionKey({ key: raw, alias, mainKey });

View File

@ -215,6 +215,33 @@ describe("handleCommands subagents", () => {
expect(result.reply?.text).toContain("Subagents: none"); expect(result.reply?.text).toContain("Subagents: none");
}); });
it("lists subagents for the current command session over the target session", async () => {
resetSubagentRegistryForTests();
addSubagentRunForTests({
runId: "run-1",
childSessionKey: "agent:main:subagent:abc",
requesterSessionKey: "agent:main:slack:slash:U1",
requesterDisplayKey: "agent:main:slack:slash:U1",
task: "do thing",
cleanup: "keep",
createdAt: 1000,
startedAt: 1000,
});
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/subagents list", cfg, {
CommandSource: "native",
CommandTargetSessionKey: "agent:main:main",
});
params.sessionKey = "agent:main:slack:slash:U1";
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Subagents (current session)");
expect(result.reply?.text).toContain("agent:main:subagent:abc");
});
it("omits subagent status line when none exist", async () => { it("omits subagent status line when none exist", async () => {
resetSubagentRegistryForTests(); resetSubagentRegistryForTests();
const cfg = { const cfg = {

View File

@ -205,7 +205,7 @@ export async function handleDirectiveOnly(params: {
const level = currentElevatedLevel ?? "off"; const level = currentElevatedLevel ?? "off";
return { return {
text: [ text: [
withOptions(`Current elevated level: ${level}.`, "on, off"), withOptions(`Current elevated level: ${level}.`, "on, off, ask, full"),
shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null, shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null,
] ]
.filter(Boolean) .filter(Boolean)
@ -213,7 +213,7 @@ export async function handleDirectiveOnly(params: {
}; };
} }
return { return {
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`, text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on, ask, full.`,
}; };
} }
if (directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed)) { if (directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed)) {
@ -426,7 +426,9 @@ export async function handleDirectiveOnly(params: {
parts.push( parts.push(
directives.elevatedLevel === "off" directives.elevatedLevel === "off"
? formatDirectiveAck("Elevated mode disabled.") ? formatDirectiveAck("Elevated mode disabled.")
: formatDirectiveAck("Elevated mode enabled."), : directives.elevatedLevel === "full"
? formatDirectiveAck("Elevated mode set to full (auto-approve).")
: formatDirectiveAck("Elevated mode set to ask (approvals may still apply)."),
); );
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint()); if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
} }

View File

@ -16,10 +16,15 @@ export const withOptions = (line: string, options: string) =>
export const formatElevatedRuntimeHint = () => export const formatElevatedRuntimeHint = () =>
`${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`; `${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`;
export const formatElevatedEvent = (level: ElevatedLevel) => export const formatElevatedEvent = (level: ElevatedLevel) => {
level === "on" if (level === "full") {
? "Elevated ON — exec runs on host; set elevated:false to stay sandboxed." return "Elevated FULL — exec runs on host with auto-approval.";
: "Elevated OFF — exec stays in sandbox."; }
if (level === "ask" || level === "on") {
return "Elevated ASK — exec runs on host; approvals may still apply.";
}
return "Elevated OFF — exec stays in sandbox.";
};
export const formatReasoningEvent = (level: ReasoningLevel) => { export const formatReasoningEvent = (level: ReasoningLevel) => {
if (level === "stream") return "Reasoning STREAM — emit live <think>."; if (level === "stream") return "Reasoning STREAM — emit live <think>.";

View File

@ -5,8 +5,10 @@ import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system
import { prependSystemEvents } from "./session-updates.js"; import { prependSystemEvents } from "./session-updates.js";
describe("prependSystemEvents", () => { describe("prependSystemEvents", () => {
it("adds a UTC timestamp to queued system events", async () => { it("adds a local timestamp to queued system events by default", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
const timestamp = new Date("2026-01-12T20:19:17Z"); const timestamp = new Date("2026-01-12T20:19:17Z");
vi.setSystemTime(timestamp); vi.setSystemTime(timestamp);
@ -20,11 +22,10 @@ describe("prependSystemEvents", () => {
prefixedBodyBase: "User: hi", prefixedBodyBase: "User: hi",
}); });
const expectedTimestamp = "2026-01-12T20:19:17Z"; expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`);
resetSystemEventsForTest(); resetSystemEventsForTest();
process.env.TZ = originalTz;
vi.useRealTimers(); vi.useRealTimers();
}); });
}); });

View File

@ -1,5 +1,6 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { resolveUserTimezone } from "../../agents/date-time.js";
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
@ -27,9 +28,32 @@ export async function prependSystemEvents(params: {
return trimmed; return trimmed;
}; };
const formatSystemEventTimestamp = (ts: number) => { const resolveExplicitTimezone = (value: string): string | undefined => {
const date = new Date(ts); try {
if (Number.isNaN(date.getTime())) return "unknown-time"; new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
return value;
} catch {
return undefined;
}
};
const resolveSystemEventTimezone = (cfg: ClawdbotConfig) => {
const raw = cfg.agents?.defaults?.envelopeTimezone?.trim();
if (!raw) return { mode: "local" as const };
const lowered = raw.toLowerCase();
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" as const };
if (lowered === "local" || lowered === "host") return { mode: "local" as const };
if (lowered === "user") {
return {
mode: "iana" as const,
timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
};
}
const explicit = resolveExplicitTimezone(raw);
return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const };
};
const formatUtcTimestamp = (date: Date): string => {
const yyyy = String(date.getUTCFullYear()).padStart(4, "0"); const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
const dd = String(date.getUTCDate()).padStart(2, "0"); const dd = String(date.getUTCDate()).padStart(2, "0");
@ -39,6 +63,42 @@ export async function prependSystemEvents(params: {
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`; return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
}; };
const formatZonedTimestamp = (date: Date, timeZone?: string): string | undefined => {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
timeZoneName: "short",
}).formatToParts(date);
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
const yyyy = pick("year");
const mm = pick("month");
const dd = pick("day");
const hh = pick("hour");
const min = pick("minute");
const sec = pick("second");
const tz = [...parts]
.reverse()
.find((part) => part.type === "timeZoneName")
?.value?.trim();
if (!yyyy || !mm || !dd || !hh || !min || !sec) return undefined;
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
};
const formatSystemEventTimestamp = (ts: number, cfg: ClawdbotConfig) => {
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return "unknown-time";
const zone = resolveSystemEventTimezone(cfg);
if (zone.mode === "utc") return formatUtcTimestamp(date);
if (zone.mode === "local") return formatZonedTimestamp(date) ?? "unknown-time";
return formatZonedTimestamp(date, zone.timeZone) ?? "unknown-time";
};
const systemLines: string[] = []; const systemLines: string[] = [];
const queued = drainSystemEventEntries(params.sessionKey); const queued = drainSystemEventEntries(params.sessionKey);
systemLines.push( systemLines.push(
@ -46,7 +106,7 @@ export async function prependSystemEvents(params: {
.map((event) => { .map((event) => {
const compacted = compactSystemEvent(event.text); const compacted = compactSystemEvent(event.text);
if (!compacted) return null; if (!compacted) return null;
return `[${formatSystemEventTimestamp(event.ts)}] ${compacted}`; return `[${formatSystemEventTimestamp(event.ts, params.cfg)}] ${compacted}`;
}) })
.filter((v): v is string => Boolean(v)), .filter((v): v is string => Boolean(v)),
); );

View File

@ -324,7 +324,12 @@ export function buildStatusMessage(args: StatusArgs): string {
const queueDetails = formatQueueDetails(args.queue); const queueDetails = formatQueueDetails(args.queue);
const verboseLabel = const verboseLabel =
verboseLevel === "full" ? "verbose:full" : verboseLevel === "on" ? "verbose" : null; verboseLevel === "full" ? "verbose:full" : verboseLevel === "on" ? "verbose" : null;
const elevatedLabel = elevatedLevel === "on" ? "elevated" : null; const elevatedLabel =
elevatedLevel && elevatedLevel !== "off"
? elevatedLevel === "on"
? "elevated"
: `elevated:${elevatedLevel}`
: null;
const optionParts = [ const optionParts = [
`Runtime: ${runtime.label}`, `Runtime: ${runtime.label}`,
`Think: ${thinkLevel}`, `Think: ${thinkLevel}`,
@ -395,7 +400,7 @@ export function buildHelpMessage(cfg?: ClawdbotConfig): string {
"/think <level>", "/think <level>",
"/verbose on|full|off", "/verbose on|full|off",
"/reasoning on|off", "/reasoning on|off",
"/elevated on|off", "/elevated on|off|ask|full",
"/model <id>", "/model <id>",
"/usage off|tokens|full", "/usage off|tokens|full",
]; ];

View File

@ -1,6 +1,7 @@
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export type VerboseLevel = "off" | "on" | "full"; export type VerboseLevel = "off" | "on" | "full";
export type ElevatedLevel = "off" | "on"; export type ElevatedLevel = "off" | "on" | "ask" | "full";
export type ElevatedMode = "off" | "ask" | "full";
export type ReasoningLevel = "off" | "on" | "stream"; export type ReasoningLevel = "off" | "on" | "stream";
export type UsageDisplayLevel = "off" | "tokens" | "full"; export type UsageDisplayLevel = "off" | "tokens" | "full";
@ -112,10 +113,18 @@ export function normalizeElevatedLevel(raw?: string | null): ElevatedLevel | und
if (!raw) return undefined; if (!raw) return undefined;
const key = raw.toLowerCase(); const key = raw.toLowerCase();
if (["off", "false", "no", "0"].includes(key)) return "off"; if (["off", "false", "no", "0"].includes(key)) return "off";
if (["full", "auto", "auto-approve", "autoapprove"].includes(key)) return "full";
if (["ask", "prompt", "approval", "approve"].includes(key)) return "ask";
if (["on", "true", "yes", "1"].includes(key)) return "on"; if (["on", "true", "yes", "1"].includes(key)) return "on";
return undefined; return undefined;
} }
export function resolveElevatedMode(level?: ElevatedLevel | null): ElevatedMode {
if (!level || level === "off") return "off";
if (level === "full") return "full";
return "ask";
}
// Normalize reasoning visibility flags used to toggle reasoning exposure. // Normalize reasoning visibility flags used to toggle reasoning exposure.
export function normalizeReasoningLevel(raw?: string | null): ReasoningLevel | undefined { export function normalizeReasoningLevel(raw?: string | null): ReasoningLevel | undefined {
if (!raw) return undefined; if (!raw) return undefined;

View File

@ -180,3 +180,11 @@ export function decorateClawdProfile(
// ignore // ignore
} }
} }
export function ensureProfileCleanExit(userDataDir: string) {
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
const prefs = safeReadJson(preferencesPath) ?? {};
setDeep(prefs, ["exit_type"], "Normal");
setDeep(prefs, ["exited_cleanly"], true);
safeWriteJson(preferencesPath, prefs);
}

View File

@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
decorateClawdProfile, decorateClawdProfile,
ensureProfileCleanExit,
findChromeExecutableMac, findChromeExecutableMac,
findChromeExecutableWindows, findChromeExecutableWindows,
isChromeReachable, isChromeReachable,
@ -103,6 +104,18 @@ describe("browser chrome profile decoration", () => {
} }
}); });
it("writes clean exit prefs to avoid restore prompts", async () => {
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
try {
ensureProfileCleanExit(userDataDir);
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
expect(prefs.exit_type).toBe("Normal");
expect(prefs.exited_cleanly).toBe(true);
} finally {
await fsp.rm(userDataDir, { recursive: true, force: true });
}
});
it("is idempotent when rerun on an existing profile", async () => { it("is idempotent when rerun on an existing profile", async () => {
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-")); const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
try { try {

View File

@ -13,7 +13,11 @@ import {
type BrowserExecutable, type BrowserExecutable,
resolveBrowserExecutableForPlatform, resolveBrowserExecutableForPlatform,
} from "./chrome.executables.js"; } from "./chrome.executables.js";
import { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js"; import {
decorateClawdProfile,
ensureProfileCleanExit,
isProfileDecorated,
} from "./chrome.profile-decoration.js";
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js"; import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_PROFILE_NAME } from "./constants.js"; import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_PROFILE_NAME } from "./constants.js";
@ -26,7 +30,11 @@ export {
findChromeExecutableWindows, findChromeExecutableWindows,
resolveBrowserExecutableForPlatform, resolveBrowserExecutableForPlatform,
} from "./chrome.executables.js"; } from "./chrome.executables.js";
export { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js"; export {
decorateClawdProfile,
ensureProfileCleanExit,
isProfileDecorated,
} from "./chrome.profile-decoration.js";
function exists(filePath: string) { function exists(filePath: string) {
try { try {
@ -178,6 +186,8 @@ export async function launchClawdChrome(
"--disable-background-networking", "--disable-background-networking",
"--disable-component-update", "--disable-component-update",
"--disable-features=Translate,MediaRouter", "--disable-features=Translate,MediaRouter",
"--disable-session-crashed-bubble",
"--hide-crash-restore-bubble",
"--password-store=basic", "--password-store=basic",
]; ];
@ -246,6 +256,12 @@ export async function launchClawdChrome(
} }
} }
try {
ensureProfileCleanExit(userDataDir);
} catch (err) {
log.warn(`clawd browser clean-exit prefs failed: ${String(err)}`);
}
const proc = spawnOnce(); const proc = spawnOnce();
// Wait for CDP to come up. // Wait for CDP to come up.
const readyDeadline = Date.now() + 15_000; const readyDeadline = Date.now() + 15_000;

View File

@ -1 +1 @@
27d5aed982d9f110b44e85254877597e49efae61141de480b4e9f254c04131ce 0ae29522de4c48c6b6407290be18b94d7244d4e0036738abd19d93148f2c8cd4

View File

@ -171,7 +171,7 @@ describe("canvas host", () => {
const ws = new WebSocket(`ws://127.0.0.1:${server.port}${CANVAS_WS_PATH}`); const ws = new WebSocket(`ws://127.0.0.1:${server.port}${CANVAS_WS_PATH}`);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("ws open timeout")), 2000); const timer = setTimeout(() => reject(new Error("ws open timeout")), 5000);
ws.on("open", () => { ws.on("open", () => {
clearTimeout(timer); clearTimeout(timer);
resolve(); resolve();
@ -183,13 +183,14 @@ describe("canvas host", () => {
}); });
const msg = new Promise<string>((resolve, reject) => { const msg = new Promise<string>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("reload timeout")), 4000); const timer = setTimeout(() => reject(new Error("reload timeout")), 10_000);
ws.on("message", (data) => { ws.on("message", (data) => {
clearTimeout(timer); clearTimeout(timer);
resolve(rawDataToString(data)); resolve(rawDataToString(data));
}); });
}); });
await new Promise((resolve) => setTimeout(resolve, 100));
await fs.writeFile(index, "<html><body>v2</body></html>", "utf8"); await fs.writeFile(index, "<html><body>v2</body></html>", "utf8");
expect(await msg).toBe("reload"); expect(await msg).toBe("reload");
ws.close(); ws.close();
@ -197,7 +198,7 @@ describe("canvas host", () => {
await server.close(); await server.close();
await fs.rm(dir, { recursive: true, force: true }); await fs.rm(dir, { recursive: true, force: true });
} }
}, 10_000); }, 20_000);
it("serves the gateway-hosted A2UI scaffold", async () => { it("serves the gateway-hosted A2UI scaffold", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-canvas-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-canvas-"));

View File

@ -44,6 +44,14 @@ const entries: SubCliEntry[] = [
mod.registerGatewayCli(program); mod.registerGatewayCli(program);
}, },
}, },
{
name: "daemon",
description: "Gateway service (legacy alias)",
register: async (program) => {
const mod = await import("../daemon-cli.js");
mod.registerDaemonCli(program);
},
},
{ {
name: "logs", name: "logs",
description: "Gateway logs", description: "Gateway logs",

View File

@ -68,8 +68,12 @@ const STEP_LABELS: Record<string, string> = {
"clean check": "Working directory is clean", "clean check": "Working directory is clean",
"upstream check": "Upstream branch exists", "upstream check": "Upstream branch exists",
"git fetch": "Fetching latest changes", "git fetch": "Fetching latest changes",
"git rebase": "Rebasing onto upstream", "git rebase": "Rebasing onto target commit",
"git rev-parse @{upstream}": "Resolving upstream commit",
"git rev-list": "Enumerating candidate commits",
"git clone": "Cloning git checkout", "git clone": "Cloning git checkout",
"preflight worktree": "Preparing preflight worktree",
"preflight cleanup": "Cleaning preflight worktree",
"deps install": "Installing dependencies", "deps install": "Installing dependencies",
build: "Building", build: "Building",
"ui:build": "Building UI", "ui:build": "Building UI",

View File

@ -23,21 +23,33 @@ describe("legacy config detection", () => {
expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention"); expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention");
} }
}); });
it("migrates routing.allowFrom to channels.whatsapp.allowFrom", async () => { it("migrates routing.allowFrom to channels.whatsapp.allowFrom when whatsapp configured", async () => {
vi.resetModules(); vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js"); const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({ const res = migrateLegacyConfig({
routing: { allowFrom: ["+15555550123"] }, routing: { allowFrom: ["+15555550123"] },
channels: { whatsapp: {} },
}); });
expect(res.changes).toContain("Moved routing.allowFrom → channels.whatsapp.allowFrom."); expect(res.changes).toContain("Moved routing.allowFrom → channels.whatsapp.allowFrom.");
expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]); expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
expect(res.config?.routing?.allowFrom).toBeUndefined(); expect(res.config?.routing?.allowFrom).toBeUndefined();
}); });
it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups", async () => { it("drops routing.allowFrom when whatsapp missing", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { allowFrom: ["+15555550123"] },
});
expect(res.changes).toContain("Removed routing.allowFrom (channels.whatsapp not configured).");
expect(res.config?.channels?.whatsapp).toBeUndefined();
expect(res.config?.routing?.allowFrom).toBeUndefined();
});
it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups when whatsapp configured", async () => {
vi.resetModules(); vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js"); const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({ const res = migrateLegacyConfig({
routing: { groupChat: { requireMention: false } }, routing: { groupChat: { requireMention: false } },
channels: { whatsapp: {} },
}); });
expect(res.changes).toContain( expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.', 'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
@ -53,6 +65,26 @@ describe("legacy config detection", () => {
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false); expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined(); expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
}); });
it("migrates routing.groupChat.requireMention to telegram/imessage when whatsapp missing", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { groupChat: { requireMention: false } },
});
expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.',
);
expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.',
);
expect(res.changes).not.toContain(
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
);
expect(res.config?.channels?.whatsapp).toBeUndefined();
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
});
it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => { it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
vi.resetModules(); vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js"); const { migrateLegacyConfig } = await import("./config.js");

View File

@ -156,11 +156,16 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
const allowFrom = (routing as Record<string, unknown>).allowFrom; const allowFrom = (routing as Record<string, unknown>).allowFrom;
if (allowFrom === undefined) return; if (allowFrom === undefined) return;
const channels = ensureRecord(raw, "channels"); const channels = getRecord(raw.channels);
const whatsapp = const whatsapp = channels ? getRecord(channels.whatsapp) : null;
channels.whatsapp && typeof channels.whatsapp === "object" if (!whatsapp) {
? (channels.whatsapp as Record<string, unknown>) delete (routing as Record<string, unknown>).allowFrom;
: {}; if (Object.keys(routing as Record<string, unknown>).length === 0) {
delete raw.routing;
}
changes.push("Removed routing.allowFrom (channels.whatsapp not configured).");
return;
}
if (whatsapp.allowFrom === undefined) { if (whatsapp.allowFrom === undefined) {
whatsapp.allowFrom = allowFrom; whatsapp.allowFrom = allowFrom;
@ -173,8 +178,8 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
if (Object.keys(routing as Record<string, unknown>).length === 0) { if (Object.keys(routing as Record<string, unknown>).length === 0) {
delete raw.routing; delete raw.routing;
} }
channels.whatsapp = whatsapp; channels!.whatsapp = whatsapp;
raw.channels = channels; raw.channels = channels!;
}, },
}, },
{ {
@ -193,7 +198,11 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
if (requireMention === undefined) return; if (requireMention === undefined) return;
const channels = ensureRecord(raw, "channels"); const channels = ensureRecord(raw, "channels");
const applyTo = (key: "whatsapp" | "telegram" | "imessage") => { const applyTo = (
key: "whatsapp" | "telegram" | "imessage",
options?: { requireExisting?: boolean },
) => {
if (options?.requireExisting && !isRecord(channels[key])) return;
const section = const section =
channels[key] && typeof channels[key] === "object" channels[key] && typeof channels[key] === "object"
? (channels[key] as Record<string, unknown>) ? (channels[key] as Record<string, unknown>)
@ -222,7 +231,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
} }
}; };
applyTo("whatsapp"); applyTo("whatsapp", { requireExisting: true });
applyTo("telegram"); applyTo("telegram");
applyTo("imessage"); applyTo("imessage");

View File

@ -198,6 +198,7 @@ const FIELD_LABELS: Record<string, string> = {
"skills.load.watch": "Watch Skills", "skills.load.watch": "Watch Skills",
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
"agents.defaults.workspace": "Workspace", "agents.defaults.workspace": "Workspace",
"agents.defaults.repoRoot": "Repo Root",
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
"agents.defaults.envelopeTimezone": "Envelope Timezone", "agents.defaults.envelopeTimezone": "Envelope Timezone",
"agents.defaults.envelopeTimestamp": "Envelope Timestamp", "agents.defaults.envelopeTimestamp": "Envelope Timestamp",
@ -436,6 +437,8 @@ const FIELD_HELP: Record<string, string> = {
"auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).", "auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).",
"agents.defaults.bootstrapMaxChars": "agents.defaults.bootstrapMaxChars":
"Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).",
"agents.defaults.repoRoot":
"Optional repository root shown in the system prompt runtime line (overrides auto-detect).",
"agents.defaults.envelopeTimezone": "agents.defaults.envelopeTimezone":
'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).', 'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).',
"agents.defaults.envelopeTimestamp": "agents.defaults.envelopeTimestamp":

View File

@ -99,6 +99,8 @@ export type AgentDefaultsConfig = {
models?: Record<string, AgentModelEntryConfig>; models?: Record<string, AgentModelEntryConfig>;
/** Agent working directory (preferred). Used as the default cwd for agent runs. */ /** Agent working directory (preferred). Used as the default cwd for agent runs. */
workspace?: string; workspace?: string;
/** Optional repository root for system prompt runtime line (overrides auto-detect). */
repoRoot?: string;
/** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */ /** Skip bootstrap (BOOTSTRAP.md creation, etc.) for pre-configured deployments. */
skipBootstrap?: boolean; skipBootstrap?: boolean;
/** Max chars for injected bootstrap files before truncation (default: 20000). */ /** Max chars for injected bootstrap files before truncation (default: 20000). */
@ -134,7 +136,7 @@ export type AgentDefaultsConfig = {
/** Default verbose level when no /verbose directive is present. */ /** Default verbose level when no /verbose directive is present. */
verboseDefault?: "off" | "on" | "full"; verboseDefault?: "off" | "on" | "full";
/** Default elevated level when no /elevated directive is present. */ /** Default elevated level when no /elevated directive is present. */
elevatedDefault?: "off" | "on"; elevatedDefault?: "off" | "on" | "ask" | "full";
/** Default block streaming level when no override is present. */ /** Default block streaming level when no override is present. */
blockStreamingDefault?: "off" | "on"; blockStreamingDefault?: "off" | "on";
/** /**

View File

@ -42,6 +42,7 @@ export const AgentDefaultsSchema = z
) )
.optional(), .optional(),
workspace: z.string().optional(), workspace: z.string().optional(),
repoRoot: z.string().optional(),
skipBootstrap: z.boolean().optional(), skipBootstrap: z.boolean().optional(),
bootstrapMaxChars: z.number().int().positive().optional(), bootstrapMaxChars: z.number().int().positive().optional(),
userTimezone: z.string().optional(), userTimezone: z.string().optional(),
@ -112,7 +113,9 @@ export const AgentDefaultsSchema = z
]) ])
.optional(), .optional(),
verboseDefault: z.union([z.literal("off"), z.literal("on"), z.literal("full")]).optional(), verboseDefault: z.union([z.literal("off"), z.literal("on"), z.literal("full")]).optional(),
elevatedDefault: z.union([z.literal("off"), z.literal("on")]).optional(), elevatedDefault: z
.union([z.literal("off"), z.literal("on"), z.literal("ask"), z.literal("full")])
.optional(),
blockStreamingDefault: z.union([z.literal("off"), z.literal("on")]).optional(), blockStreamingDefault: z.union([z.literal("off"), z.literal("on")]).optional(),
blockStreamingBreak: z.union([z.literal("text_end"), z.literal("message_end")]).optional(), blockStreamingBreak: z.union([z.literal("text_end"), z.literal("message_end")]).optional(),
blockStreamingChunk: BlockStreamingChunkSchema.optional(), blockStreamingChunk: BlockStreamingChunkSchema.optional(),

View File

@ -113,6 +113,30 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in"); return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
} }
function isInstructionsRequiredError(error: string): boolean {
return /instructions are required/i.test(error);
}
function isOpenAIReasoningSequenceError(error: string): boolean {
const msg = error.toLowerCase();
return msg.includes("required following item") && msg.includes("reasoning");
}
function isToolNonceRefusal(error: string): boolean {
const msg = error.toLowerCase();
if (!msg.includes("nonce")) return false;
return (
msg.includes("token") ||
msg.includes("secret") ||
msg.includes("local file") ||
msg.includes("disclose") ||
msg.includes("can't help") ||
msg.includes("cant help") ||
msg.includes("can't comply") ||
msg.includes("cant comply")
);
}
function isMissingProfileError(error: string): boolean { function isMissingProfileError(error: string): boolean {
return /no credentials found for profile/i.test(error); return /no credentials found for profile/i.test(error);
} }
@ -856,6 +880,27 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
logProgress(`${progressLabel}: skip (chatgpt usage limit)`); logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
break; break;
} }
if (model.provider === "openai-codex" && isInstructionsRequiredError(message)) {
skippedCount += 1;
logProgress(`${progressLabel}: skip (instructions required)`);
break;
}
if (
(model.provider === "openai" || model.provider === "openai-codex") &&
isOpenAIReasoningSequenceError(message)
) {
skippedCount += 1;
logProgress(`${progressLabel}: skip (openai reasoning sequence error)`);
break;
}
if (
(model.provider === "openai" || model.provider === "openai-codex") &&
isToolNonceRefusal(message)
) {
skippedCount += 1;
logProgress(`${progressLabel}: skip (tool probe refusal)`);
break;
}
if (isMissingProfileError(message)) { if (isMissingProfileError(message)) {
skippedCount += 1; skippedCount += 1;
logProgress(`${progressLabel}: skip (missing auth profile)`); logProgress(`${progressLabel}: skip (missing auth profile)`);

View File

@ -169,7 +169,7 @@ export async function applySessionsPatchToStore(params: {
delete next.elevatedLevel; delete next.elevatedLevel;
} else if (raw !== undefined) { } else if (raw !== undefined) {
const normalized = normalizeElevatedLevel(String(raw)); const normalized = normalizeElevatedLevel(String(raw));
if (!normalized) return invalid('invalid elevatedLevel (use "on"|"off")'); if (!normalized) return invalid('invalid elevatedLevel (use "on"|"off"|"ask"|"full")');
// Persist "off" explicitly so patches can override defaults. // Persist "off" explicitly so patches can override defaults.
next.elevatedLevel = normalized; next.elevatedLevel = normalized;
} }

View File

@ -14,6 +14,7 @@ import {
normalizeSafeBins, normalizeSafeBins,
resolveCommandResolution, resolveCommandResolution,
resolveExecApprovals, resolveExecApprovals,
resolveExecApprovalsFromFile,
type ExecAllowlistEntry, type ExecAllowlistEntry,
} from "./exec-approvals.js"; } from "./exec-approvals.js";
@ -227,3 +228,32 @@ describe("exec approvals wildcard agent", () => {
} }
}); });
}); });
describe("exec approvals default agent migration", () => {
it("migrates legacy default agent entries to main", () => {
const file = {
version: 1,
agents: {
default: { allowlist: [{ pattern: "/bin/legacy" }] },
},
};
const resolved = resolveExecApprovalsFromFile({ file });
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/legacy"]);
expect(resolved.file.agents?.default).toBeUndefined();
expect(resolved.file.agents?.main?.allowlist?.[0]?.pattern).toBe("/bin/legacy");
});
it("prefers main agent settings when both main and default exist", () => {
const file = {
version: 1,
agents: {
main: { ask: "always", allowlist: [{ pattern: "/bin/main" }] },
default: { ask: "off", allowlist: [{ pattern: "/bin/legacy" }] },
},
};
const resolved = resolveExecApprovalsFromFile({ file });
expect(resolved.agent.ask).toBe("always");
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/main", "/bin/legacy"]);
expect(resolved.file.agents?.default).toBeUndefined();
});
});

View File

@ -4,6 +4,8 @@ import net from "node:net";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
export type ExecHost = "sandbox" | "gateway" | "node"; export type ExecHost = "sandbox" | "gateway" | "node";
export type ExecSecurity = "deny" | "allowlist" | "full"; export type ExecSecurity = "deny" | "allowlist" | "full";
export type ExecAsk = "off" | "on-miss" | "always"; export type ExecAsk = "off" | "on-miss" | "always";
@ -84,6 +86,35 @@ export function resolveExecApprovalsSocketPath(): string {
return expandHome(DEFAULT_SOCKET); return expandHome(DEFAULT_SOCKET);
} }
function normalizeAllowlistPattern(value: string | undefined): string | null {
const trimmed = value?.trim() ?? "";
return trimmed ? trimmed.toLowerCase() : null;
}
function mergeLegacyAgent(
current: ExecApprovalsAgent,
legacy: ExecApprovalsAgent,
): ExecApprovalsAgent {
const allowlist: ExecAllowlistEntry[] = [];
const seen = new Set<string>();
const pushEntry = (entry: ExecAllowlistEntry) => {
const key = normalizeAllowlistPattern(entry.pattern);
if (!key || seen.has(key)) return;
seen.add(key);
allowlist.push(entry);
};
for (const entry of current.allowlist ?? []) pushEntry(entry);
for (const entry of legacy.allowlist ?? []) pushEntry(entry);
return {
security: current.security ?? legacy.security,
ask: current.ask ?? legacy.ask,
askFallback: current.askFallback ?? legacy.askFallback,
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
allowlist: allowlist.length > 0 ? allowlist : undefined,
};
}
function ensureDir(filePath: string) { function ensureDir(filePath: string) {
const dir = path.dirname(filePath); const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
@ -92,6 +123,13 @@ function ensureDir(filePath: string) {
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile { export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
const socketPath = file.socket?.path?.trim(); const socketPath = file.socket?.path?.trim();
const token = file.socket?.token?.trim(); const token = file.socket?.token?.trim();
const agents = { ...file.agents };
const legacyDefault = agents.default;
if (legacyDefault) {
const main = agents[DEFAULT_AGENT_ID];
agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault;
delete agents.default;
}
const normalized: ExecApprovalsFile = { const normalized: ExecApprovalsFile = {
version: 1, version: 1,
socket: { socket: {
@ -104,7 +142,7 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi
askFallback: file.defaults?.askFallback, askFallback: file.defaults?.askFallback,
autoAllowSkills: file.defaults?.autoAllowSkills, autoAllowSkills: file.defaults?.autoAllowSkills,
}, },
agents: file.agents ?? {}, agents,
}; };
return normalized; return normalized;
} }
@ -231,7 +269,7 @@ export function resolveExecApprovalsFromFile(params: {
}): ExecApprovalsResolved { }): ExecApprovalsResolved {
const file = normalizeExecApprovals(params.file); const file = normalizeExecApprovals(params.file);
const defaults = file.defaults ?? {}; const defaults = file.defaults ?? {};
const agentKey = params.agentId ?? "default"; const agentKey = params.agentId ?? DEFAULT_AGENT_ID;
const agent = file.agents?.[agentKey] ?? {}; const agent = file.agents?.[agentKey] ?? {};
const wildcard = file.agents?.["*"] ?? {}; const wildcard = file.agents?.["*"] ?? {};
const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY; const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY;
@ -696,7 +734,7 @@ export function recordAllowlistUse(
command: string, command: string,
resolvedPath?: string, resolvedPath?: string,
) { ) {
const target = agentId ?? "default"; const target = agentId ?? DEFAULT_AGENT_ID;
const agents = approvals.agents ?? {}; const agents = approvals.agents ?? {};
const existing = agents[target] ?? {}; const existing = agents[target] ?? {};
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : []; const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
@ -720,7 +758,7 @@ export function addAllowlistEntry(
agentId: string | undefined, agentId: string | undefined,
pattern: string, pattern: string,
) { ) {
const target = agentId ?? "default"; const target = agentId ?? DEFAULT_AGENT_ID;
const agents = approvals.agents ?? {}; const agents = approvals.agents ?? {};
const existing = agents[target] ?? {}; const existing = agents[target] ?? {};
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : []; const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];

View File

@ -74,7 +74,9 @@ describe("runGatewayUpdate", () => {
stdout: "origin/main", stdout: "origin/main",
}, },
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" }, [`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
[`git -C ${tempDir} rebase @{upstream}`]: { code: 1, stderr: "conflict" }, [`git -C ${tempDir} rev-parse @{upstream}`]: { stdout: "upstream123" },
[`git -C ${tempDir} rev-list --max-count=10 upstream123`]: { stdout: "upstream123\n" },
[`git -C ${tempDir} rebase upstream123`]: { code: 1, stderr: "conflict" },
[`git -C ${tempDir} rebase --abort`]: { stdout: "" }, [`git -C ${tempDir} rebase --abort`]: { stdout: "" },
}); });

View File

@ -1,4 +1,5 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js"; import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
@ -63,6 +64,7 @@ type UpdateRunnerOptions = {
const DEFAULT_TIMEOUT_MS = 20 * 60_000; const DEFAULT_TIMEOUT_MS = 20 * 60_000;
const MAX_LOG_CHARS = 8000; const MAX_LOG_CHARS = 8000;
const PREFLIGHT_MAX_COMMITS = 10;
const START_DIRS = ["cwd", "argv1", "process"]; const START_DIRS = ["cwd", "argv1", "process"];
function normalizeDir(value?: string | null) { function normalizeDir(value?: string | null) {
@ -420,8 +422,152 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
); );
steps.push(fetchStep); steps.push(fetchStep);
const upstreamShaStep = await runStep(
step(
"git rev-parse @{upstream}",
["git", "-C", gitRoot, "rev-parse", "@{upstream}"],
gitRoot,
),
);
steps.push(upstreamShaStep);
const upstreamSha = upstreamShaStep.stdoutTail?.trim();
if (!upstreamShaStep.stdoutTail || !upstreamSha) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "no-upstream-sha",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const revListStep = await runStep(
step(
"git rev-list",
["git", "-C", gitRoot, "rev-list", `--max-count=${PREFLIGHT_MAX_COMMITS}`, upstreamSha],
gitRoot,
),
);
steps.push(revListStep);
if (revListStep.exitCode !== 0) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "preflight-revlist-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const candidates = (revListStep.stdoutTail ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (candidates.length === 0) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "preflight-no-candidates",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const manager = await detectPackageManager(gitRoot);
const preflightRoot = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-preflight-"));
const worktreeDir = path.join(preflightRoot, "worktree");
const worktreeStep = await runStep(
step(
"preflight worktree",
["git", "-C", gitRoot, "worktree", "add", "--detach", worktreeDir, upstreamSha],
gitRoot,
),
);
steps.push(worktreeStep);
if (worktreeStep.exitCode !== 0) {
await fs.rm(preflightRoot, { recursive: true, force: true }).catch(() => {});
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "preflight-worktree-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
let selectedSha: string | null = null;
try {
for (const sha of candidates) {
const shortSha = sha.slice(0, 8);
const checkoutStep = await runStep(
step(
`preflight checkout (${shortSha})`,
["git", "-C", worktreeDir, "checkout", "--detach", sha],
worktreeDir,
),
);
steps.push(checkoutStep);
if (checkoutStep.exitCode !== 0) continue;
const depsStep = await runStep(
step(`preflight deps install (${shortSha})`, managerInstallArgs(manager), worktreeDir),
);
steps.push(depsStep);
if (depsStep.exitCode !== 0) continue;
const lintStep = await runStep(
step(`preflight lint (${shortSha})`, managerScriptArgs(manager, "lint"), worktreeDir),
);
steps.push(lintStep);
if (lintStep.exitCode !== 0) continue;
const buildStep = await runStep(
step(`preflight build (${shortSha})`, managerScriptArgs(manager, "build"), worktreeDir),
);
steps.push(buildStep);
if (buildStep.exitCode !== 0) continue;
selectedSha = sha;
break;
}
} finally {
const removeStep = await runStep(
step(
"preflight cleanup",
["git", "-C", gitRoot, "worktree", "remove", "--force", worktreeDir],
gitRoot,
),
);
steps.push(removeStep);
await runCommand(["git", "-C", gitRoot, "worktree", "prune"], {
cwd: gitRoot,
timeoutMs,
}).catch(() => null);
await fs.rm(preflightRoot, { recursive: true, force: true }).catch(() => {});
}
if (!selectedSha) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "preflight-no-good-commit",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const rebaseStep = await runStep( const rebaseStep = await runStep(
step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot), step("git rebase", ["git", "-C", gitRoot, "rebase", selectedSha], gitRoot),
); );
steps.push(rebaseStep); steps.push(rebaseStep);
if (rebaseStep.exitCode !== 0) { if (rebaseStep.exitCode !== 0) {

View File

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot } from "./bot.js"; import { createTelegramBot } from "./bot.js";
@ -119,8 +119,11 @@ const getOnHandler = (event: string) => {
return handler as (ctx: Record<string, unknown>) => Promise<void>; return handler as (ctx: Record<string, unknown>) => Promise<void>;
}; };
const ORIGINAL_TZ = process.env.TZ;
describe("createTelegramBot", () => { describe("createTelegramBot", () => {
beforeEach(() => { beforeEach(() => {
process.env.TZ = "UTC";
resetInboundDedupe(); resetInboundDedupe();
loadConfig.mockReturnValue({ loadConfig.mockReturnValue({
channels: { channels: {
@ -138,6 +141,9 @@ describe("createTelegramBot", () => {
botCtorSpy.mockReset(); botCtorSpy.mockReset();
_sequentializeKey = undefined; _sequentializeKey = undefined;
}); });
afterEach(() => {
process.env.TZ = ORIGINAL_TZ;
});
// groupPolicy tests // groupPolicy tests

View File

@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
@ -122,8 +122,11 @@ const getOnHandler = (event: string) => {
return handler as (ctx: Record<string, unknown>) => Promise<void>; return handler as (ctx: Record<string, unknown>) => Promise<void>;
}; };
const ORIGINAL_TZ = process.env.TZ;
describe("createTelegramBot", () => { describe("createTelegramBot", () => {
beforeEach(() => { beforeEach(() => {
process.env.TZ = "UTC";
resetInboundDedupe(); resetInboundDedupe();
loadConfig.mockReturnValue({ loadConfig.mockReturnValue({
channels: { channels: {
@ -141,6 +144,9 @@ describe("createTelegramBot", () => {
botCtorSpy.mockReset(); botCtorSpy.mockReset();
sequentializeKey = undefined; sequentializeKey = undefined;
}); });
afterEach(() => {
process.env.TZ = ORIGINAL_TZ;
});
// groupPolicy tests // groupPolicy tests

View File

@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { import {
listNativeCommandSpecs, listNativeCommandSpecs,
listNativeCommandSpecsForConfig, listNativeCommandSpecsForConfig,
@ -148,8 +148,11 @@ const getOnHandler = (event: string) => {
return handler as (ctx: Record<string, unknown>) => Promise<void>; return handler as (ctx: Record<string, unknown>) => Promise<void>;
}; };
const ORIGINAL_TZ = process.env.TZ;
describe("createTelegramBot", () => { describe("createTelegramBot", () => {
beforeEach(() => { beforeEach(() => {
process.env.TZ = "UTC";
resetInboundDedupe(); resetInboundDedupe();
loadConfig.mockReturnValue({ loadConfig.mockReturnValue({
channels: { channels: {
@ -168,6 +171,9 @@ describe("createTelegramBot", () => {
botCtorSpy.mockReset(); botCtorSpy.mockReset();
sequentializeKey = undefined; sequentializeKey = undefined;
}); });
afterEach(() => {
process.env.TZ = ORIGINAL_TZ;
});
it("installs grammY throttler", () => { it("installs grammY throttler", () => {
createTelegramBot({ token: "tok" }); createTelegramBot({ token: "tok" });
@ -556,94 +562,106 @@ describe("createTelegramBot", () => {
}); });
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
const originalTz = process.env.TZ;
process.env.TZ = "UTC";
onSpy.mockReset(); onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset(); replySpy.mockReset();
loadConfig.mockReturnValue({ try {
identity: { name: "Bert" }, loadConfig.mockReturnValue({
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, identity: { name: "Bert" },
channels: { messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
telegram: { channels: {
groupPolicy: "open", telegram: {
groups: { "*": { requireMention: true } }, groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
}, },
}, });
});
createTelegramBot({ token: "tok" }); createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({ await handler({
message: { message: {
chat: { id: 7, type: "group", title: "Test Group" }, chat: { id: 7, type: "group", title: "Test Group" },
text: "bert: introduce yourself", text: "bert: introduce yourself",
date: 1736380800, date: 1736380800,
message_id: 1, message_id: 1,
from: { id: 9, first_name: "Ada" }, from: { id: 9, first_name: "Ada" },
}, },
me: { username: "clawdbot_bot" }, me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }), getFile: async () => ({ download: async () => new Uint8Array() }),
}); });
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload); expectInboundContextContract(payload);
expect(payload.WasMentioned).toBe(true); expect(payload.WasMentioned).toBe(true);
const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
const timestampPattern = escapeRegExp(expectedTimestamp); const timestampPattern = escapeRegExp(expectedTimestamp);
expect(payload.Body).toMatch( expect(payload.Body).toMatch(
new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`), new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
); );
expect(payload.SenderName).toBe("Ada"); expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9"); expect(payload.SenderId).toBe("9");
} finally {
process.env.TZ = originalTz;
}
}); });
it("includes sender identity in group envelope headers", async () => { it("includes sender identity in group envelope headers", async () => {
const originalTz = process.env.TZ;
process.env.TZ = "UTC";
onSpy.mockReset(); onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset(); replySpy.mockReset();
loadConfig.mockReturnValue({ try {
channels: { loadConfig.mockReturnValue({
telegram: { channels: {
groupPolicy: "open", telegram: {
groups: { "*": { requireMention: false } }, groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
}, },
}, });
});
createTelegramBot({ token: "tok" }); createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({ await handler({
message: { message: {
chat: { id: 42, type: "group", title: "Ops" }, chat: { id: 42, type: "group", title: "Ops" },
text: "hello", text: "hello",
date: 1736380800, date: 1736380800,
message_id: 2, message_id: 2,
from: { from: {
id: 99, id: 99,
first_name: "Ada", first_name: "Ada",
last_name: "Lovelace", last_name: "Lovelace",
username: "ada", username: "ada",
},
}, },
}, me: { username: "clawdbot_bot" },
me: { username: "clawdbot_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }),
getFile: async () => ({ download: async () => new Uint8Array() }), });
});
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload); expectInboundContextContract(payload);
const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z"));
const timestampPattern = escapeRegExp(expectedTimestamp); const timestampPattern = escapeRegExp(expectedTimestamp);
expect(payload.Body).toMatch( expect(payload.Body).toMatch(
new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`), new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`),
); );
expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99"); expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada"); expect(payload.SenderUsername).toBe("ada");
} finally {
process.env.TZ = originalTz;
}
}); });
it("reacts to mention-gated group messages when ackReaction is enabled", async () => { it("reacts to mention-gated group messages when ackReaction is enabled", async () => {

View File

@ -3,7 +3,7 @@ import { formatThinkingLevels, listThinkingLevelLabels } from "../auto-reply/thi
const VERBOSE_LEVELS = ["on", "off"]; const VERBOSE_LEVELS = ["on", "off"];
const REASONING_LEVELS = ["on", "off"]; const REASONING_LEVELS = ["on", "off"];
const ELEVATED_LEVELS = ["on", "off"]; const ELEVATED_LEVELS = ["on", "off", "ask", "full"];
const ACTIVATION_LEVELS = ["mention", "always"]; const ACTIVATION_LEVELS = ["mention", "always"];
const USAGE_FOOTER_LEVELS = ["off", "tokens", "full"]; const USAGE_FOOTER_LEVELS = ["off", "tokens", "full"];
@ -83,7 +83,7 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman
}, },
{ {
name: "elevated", name: "elevated",
description: "Set elevated on/off", description: "Set elevated on/off/ask/full",
getArgumentCompletions: (prefix) => getArgumentCompletions: (prefix) =>
ELEVATED_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({ ELEVATED_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({
value, value,
@ -130,8 +130,8 @@ export function helpText(options: SlashCommandOptions = {}): string {
"/verbose <on|off>", "/verbose <on|off>",
"/reasoning <on|off>", "/reasoning <on|off>",
"/usage <off|tokens|full>", "/usage <off|tokens|full>",
"/elevated <on|off>", "/elevated <on|off|ask|full>",
"/elev <on|off>", "/elev <on|off|ask|full>",
"/activation <mention|always>", "/activation <mention|always>",
"/new or /reset", "/new or /reset",
"/abort", "/abort",

View File

@ -371,7 +371,11 @@ export function createCommandHandlers(context: CommandHandlerContext) {
} }
case "elevated": case "elevated":
if (!args) { if (!args) {
chatLog.addSystem("usage: /elevated <on|off>"); chatLog.addSystem("usage: /elevated <on|off|ask|full>");
break;
}
if (!["on", "off", "ask", "full"].includes(args)) {
chatLog.addSystem("usage: /elevated <on|off|ask|full>");
break; break;
} }
try { try {

View File

@ -54,7 +54,7 @@ const sectionIcons = {
}; };
// Section metadata // Section metadata
const SECTION_META: Record<string, { label: string; description: string }> = { export const SECTION_META: Record<string, { label: string; description: string }> = {
env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" }, env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" },
update: { label: "Updates", description: "Auto-update settings and release channel" }, update: { label: "Updates", description: "Auto-update settings and release channel" },
agents: { label: "Agents", description: "Agent configurations, models, and identities" }, agents: { label: "Agents", description: "Agent configurations, models, and identities" },

View File

@ -1,4 +1,4 @@
export { renderConfigForm, type ConfigFormProps } from "./config-form.render"; export { renderConfigForm, type ConfigFormProps, SECTION_META } from "./config-form.render";
export { export {
analyzeConfigSchema, analyzeConfigSchema,
type ConfigSchemaAnalysis, type ConfigSchemaAnalysis,

View File

@ -1,6 +1,6 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types"; import type { ConfigUiHints } from "../types";
import { analyzeConfigSchema, renderConfigForm } from "./config-form"; import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form";
import { import {
hintForPath, hintForPath,
humanize, humanize,