diff --git a/CHANGELOG.md b/CHANGELOG.md index cf05ea0d5..352d9c591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,24 @@ Docs: https://docs.clawd.bot - 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. - 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. - 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. - 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 don’t have to constantly convert. + ### Fixes - 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. +- 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) - 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. ## 2026.1.21 diff --git a/apps/macos/Sources/Clawdbot/ExecApprovals.swift b/apps/macos/Sources/Clawdbot/ExecApprovals.swift index ad1a18300..53e0b10a8 100644 --- a/apps/macos/Sources/Clawdbot/ExecApprovals.swift +++ b/apps/macos/Sources/Clawdbot/ExecApprovals.swift @@ -149,6 +149,7 @@ struct ExecApprovalsResolvedDefaults { enum ExecApprovalsStore { 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 defaultAsk: ExecAsk = .onMiss private static let defaultAskFallback: ExecSecurity = .deny @@ -165,13 +166,22 @@ enum ExecApprovalsStore { static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile { let socketPath = file.socket?.path?.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( version: 1, socket: ExecApprovalsSocketConfig( path: socketPath.isEmpty ? nil : socketPath, token: token.isEmpty ? nil : token), defaults: file.defaults, - agents: file.agents) + agents: agents) } static func readSnapshot() -> ExecApprovalsSnapshot { @@ -272,9 +282,7 @@ enum ExecApprovalsStore { ask: defaults.ask ?? self.defaultAsk, askFallback: defaults.askFallback ?? self.defaultAskFallback, autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills) - let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) - ? agentId!.trimmingCharacters(in: .whitespacesAndNewlines) - : "default" + let key = self.agentKey(agentId) let agentEntry = file.agents?[key] ?? ExecApprovalsAgent() let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent() let resolvedAgent = ExecApprovalsResolvedDefaults( @@ -457,7 +465,36 @@ enum ExecApprovalsStore { private static func agentKey(_ agentId: String?) -> String { 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() + 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) } } diff --git a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift index 6031677ea..154932c64 100644 --- a/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/Clawdbot/GatewayLaunchAgentManager.swift @@ -115,7 +115,7 @@ extension GatewayLaunchAgentManager { quiet: Bool) async -> CommandResult { let command = CommandResolver.clawdbotCommand( - subcommand: "daemon", + subcommand: "gateway", extraArgs: self.withJsonFlag(args), // Launchd management must always run locally, even if remote mode is configured. configRoot: ["gateway": ["mode": "local"]]) diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift index 5c64f0d63..daa07466d 100644 --- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift @@ -2,15 +2,12 @@ import AppKit import ClawdbotDiscovery import ClawdbotIPC import ClawdbotKit -import CoreLocation import Observation import SwiftUI struct GeneralSettings: View { @Bindable var state: AppState @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 gatewayManager = GatewayProcessManager.shared @State private var gatewayDiscovery = GatewayDiscoveryModel( @@ -20,7 +17,6 @@ struct GeneralSettings: View { @State private var showRemoteAdvanced = false private let isPreview = ProcessInfo.processInfo.isPreview private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode } - @State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue var body: some View { 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.", 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( title: "Enable Peekaboo Bridge", subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.", @@ -106,27 +81,12 @@ struct GeneralSettings: View { .onAppear { guard !self.isPreview else { return } self.refreshGatewayStatus() - self.lastLocationModeRaw = self.locationModeRaw } .onChange(of: self.state.canvasEnabled) { _, enabled in if !enabled { 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 { @@ -135,26 +95,6 @@ struct GeneralSettings: View { 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 { VStack(alignment: .leading, spacing: 10) { Text("Clawdbot runs") diff --git a/apps/macos/Sources/Clawdbot/PermissionsSettings.swift b/apps/macos/Sources/Clawdbot/PermissionsSettings.swift index edce7f41b..f5a926032 100644 --- a/apps/macos/Sources/Clawdbot/PermissionsSettings.swift +++ b/apps/macos/Sources/Clawdbot/PermissionsSettings.swift @@ -1,4 +1,6 @@ import ClawdbotIPC +import ClawdbotKit +import CoreLocation import SwiftUI struct PermissionsSettings: View { @@ -17,6 +19,8 @@ struct PermissionsSettings: View { .padding(.horizontal, 2) .padding(.vertical, 6) + LocationAccessSettings() + Button("Restart onboarding") { self.showOnboarding() } .buttonStyle(.bordered) 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 { let status: [Capability: Bool] let refresh: () async -> Void diff --git a/docs/cli/update.md b/docs/cli/update.md index acac61b20..9ebe509b0 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -69,11 +69,13 @@ High-level: 1. Requires a clean worktree (no uncommitted changes). 2. Switches to the selected channel (tag or branch). -3. Fetches and rebases against `@{upstream}` (dev only). -4. Installs deps (pnpm preferred; npm fallback). -5. Builds + builds the Control UI. -6. Runs `clawdbot doctor` as the final “safe update” check. -7. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins. +3. Fetches upstream (dev only). +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. Rebases onto the selected commit (dev only). +6. Installs deps (pnpm preferred; npm fallback). +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 diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index b46f11578..1a52fc501 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -24,7 +24,7 @@ The prompt is intentionally compact and uses fixed sections: - **Current Date & Time**: user-local time, timezone, and time format. - **Reply Tags**: optional reply tag syntax for supported providers. - **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. ## Prompt modes diff --git a/docs/concepts/timezone.md b/docs/concepts/timezone.md index 3a6d3a4dd..b8b168cc9 100644 --- a/docs/concepts/timezone.md +++ b/docs/concepts/timezone.md @@ -9,15 +9,15 @@ read_when: 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: ``` -[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: @@ -25,7 +25,7 @@ You can override this with: { agents: { defaults: { - envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone + envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone envelopeTimestamp: "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). - Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset. - `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers. @@ -40,10 +41,10 @@ You can override this with: ### 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:** diff --git a/docs/date-time.md b/docs/date-time.md index 99da67630..8b711350d 100644 --- a/docs/date-time.md +++ b/docs/date-time.md @@ -7,18 +7,18 @@ read_when: # 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. -## 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: @@ -26,7 +26,7 @@ You can override this behavior: { agents: { defaults: { - envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone + envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone envelopeTimestamp: "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: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone). - Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone. @@ -42,10 +43,10 @@ You can override this behavior: ### Examples -**UTC (default):** +**Local (default):** ``` -[WhatsApp +1555 2026-01-18T05:19Z] hello +[WhatsApp +1555 2026-01-18 00:19 PST] hello ``` **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 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 diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 389f2f223..6f2b064bf 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1280,6 +1280,18 @@ Default: `~/clawd`. If `agents.defaults.sandbox` is enabled, non-main sessions can override this with their own per-scope workspaces under `agents.defaults.sandbox.workspaceRoot`. +### `agents.defaults.repoRoot` + +Optional repository root to show in the system prompt’s 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` 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: - `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. - Tool policy still applies; if `exec` is denied, elevated cannot be used. diff --git a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md index 8c5fd19e8..d28481ebb 100644 --- a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md +++ b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md @@ -91,7 +91,8 @@ Available groups: ## Elevated: exec-only “run on host” Elevated does **not** grant extra tools; it only affects `exec`. -- If you’re sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs on the host. +- If you’re 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 you’re already running direct, elevated is effectively a no-op (still gated). - Elevated is **not** skill-scoped and does **not** override tool allow/deny. diff --git a/docs/gateway/security.md b/docs/gateway/security.md index e429205ef..d969ce3e6 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -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 agent’s 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 it’s 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) 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. - 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. + - For chat-only personal assistants with trusted input and no tools, smaller models are usually fine. ## Reasoning & verbose output in groups diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 94e769764..1cef34b11 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -53,6 +53,15 @@ Almost always a Node/npm PATH issue. Start here: - [Models](/cli/models) - [OAuth / auth concepts](/concepts/oauth) +### `/model` says `model not allowed` + +This usually means `agents.defaults.models` is configured as an allowlist. When it’s 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 Paste a safe report: diff --git a/docs/install/index.md b/docs/install/index.md index 467ea6bf0..57b09c4cf 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -155,18 +155,21 @@ Quick diagnosis: ```bash node -v npm -v -npm bin -g +npm prefix -g echo "$PATH" ``` -If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t 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 can’t find global npm binaries (including `clawdbot`). Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`): ```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). ## Update / uninstall diff --git a/docs/install/node.md b/docs/install/node.md index 8987a859b..6a622e198 100644 --- a/docs/install/node.md +++ b/docs/install/node.md @@ -19,33 +19,36 @@ Run: ```bash node -v npm -v -npm bin -g +npm prefix -g echo "$PATH" ``` -If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t 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 can’t find global npm binaries (including `clawdbot`). ## Fix: put npm’s global bin dir on PATH -1) Find your global bin directory: +1) Find your global npm prefix: ```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` - bash: `~/.bashrc` -Example (replace the path with your `npm bin -g` output): +Example (replace the path with your `npm prefix -g` output): ```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). +On Windows, add the output of `npm prefix -g` to your PATH. + ## Fix: avoid `sudo npm install -g` / permission errors (Linux) If `npm install -g ...` fails with `EACCES`, switch npm’s global prefix to a user-writable directory: @@ -63,7 +66,7 @@ Persist the `export PATH=...` line in your shell startup file. You’ll have the fewest surprises if Node/npm are installed in a way that: - 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: diff --git a/docs/refactor/exec-host.md b/docs/refactor/exec-host.md index c71a456ef..3b4e1a15c 100644 --- a/docs/refactor/exec-host.md +++ b/docs/refactor/exec-host.md @@ -216,7 +216,7 @@ Option B: ## Slash commands - `/exec host= security= ask= node=` - 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 - The runner service is the portable execution target. diff --git a/docs/start/faq.md b/docs/start/faq.md index ff45c89b0..c292303ab 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -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) - [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 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 didn’t 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) - [Chat commands, aborting tasks, and “it won’t 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. +### 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 didn’t get a pairing code Pairing codes are sent **only** when an unknown sender messages the bot and diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index 2e74162c5..8b561b473 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -6,17 +6,20 @@ read_when: # Elevated Mode (/elevated directives) ## 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). -- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`. -- Only `on|off` are accepted; anything else returns a hint and does not change state. +- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`. +- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state. ## What it controls (and what it doesn’t) - **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. -- **Inline directive**: `/elevated on` inside a message applies to that message only. +- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key. +- **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. - **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. - **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). ## Setting a session default -- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`. -- Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`). +- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated full`. +- 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. - Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level. @@ -41,4 +44,4 @@ read_when: ## Logging + status - 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`). diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index 517b73fbe..59ac7d119 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -11,7 +11,7 @@ read_when: 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 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 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 you’re editing in the macOS app. Patterns are **case-insensitive glob matches**. Patterns should resolve to **binary paths** (basename-only entries are ignored). +Legacy `agents.default` entries are migrated to `agents.main` on load. Examples: - `~/Projects/**/bin/bird` diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index a0dfbf8c7..a96e1760f 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -78,7 +78,7 @@ Text + native (when enabled): - `/think ` (dynamic choices by model/provider; aliases: `/thinking`, `/t`) - `/verbose on|full|off` (alias: `/v`) - `/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= security= ask= node=` (send `/exec` to show current) - `/model ` (alias: `/models`; or `/` from `agents.defaults.models.*.alias`) - `/queue ` (plus options like `debounce:2s cap:25 drop:summarize`; send `/queue` to see current settings) diff --git a/docs/tui.md b/docs/tui.md index 57fffa493..1c94aee1d 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -78,7 +78,7 @@ Session controls: - `/verbose ` - `/reasoning ` - `/usage ` -- `/elevated ` (alias: `/elev`) +- `/elevated ` (alias: `/elev`) - `/activation ` - `/deliver ` diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 0d473c858..bcead1b7a 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -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`). + +## 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://:18789 +``` + +Optional one-time auth (if needed): + +```text +http://localhost:5173/?gatewayUrl=wss://:18789&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). diff --git a/extensions/lobster/src/lobster-tool.test.ts b/extensions/lobster/src/lobster-tool.test.ts index 1c69b3280..5c887cc76 100644 --- a/extensions/lobster/src/lobster-tool.test.ts +++ b/extensions/lobster/src/lobster-tool.test.ts @@ -7,19 +7,32 @@ import { describe, expect, it } from "vitest"; import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/plugins/types.js"; import { createLobsterTool } from "./lobster-tool.js"; -async function writeFakeLobster(params: { - payload: unknown; -}) { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-")); +async function writeFakeLobsterScript(scriptBody: string, prefix = "clawdbot-lobster-plugin-") { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + const isWindows = process.platform === "win32"; + + 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 file = `#!/usr/bin/env node\n` + - `process.stdout.write(JSON.stringify(${JSON.stringify(params.payload)}));\n`; - + const file = `#!/usr/bin/env node\n${scriptBody}\n`; await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 }); 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 { return { id: "lobster", @@ -82,12 +95,10 @@ describe("lobster plugin tool", () => { }); it("rejects invalid JSON from lobster", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-bad-")); - const binPath = path.join(dir, "lobster"); - await fs.writeFile(binPath, `#!/usr/bin/env node\nprocess.stdout.write('nope');\n`, { - encoding: "utf8", - mode: 0o755, - }); + const { binPath } = await writeFakeLobsterScript( + `process.stdout.write("nope");\n`, + "clawdbot-lobster-plugin-bad-", + ); const tool = createLobsterTool(fakeApi()); await expect( diff --git a/extensions/lobster/src/lobster-tool.ts b/extensions/lobster/src/lobster-tool.ts index 71dade859..60c0a2429 100644 --- a/extensions/lobster/src/lobster-tool.ts +++ b/extensions/lobster/src/lobster-tool.ts @@ -29,13 +29,22 @@ function resolveExecutablePath(lobsterPathRaw: string | undefined) { return lobsterPath; } -async function runLobsterSubprocess(params: { - execPath: string; - argv: string[]; - cwd: string; - timeoutMs: number; - maxStdoutBytes: number; -}) { +function isWindowsSpawnEINVAL(err: unknown) { + if (!err || typeof err !== "object") return false; + const code = (err as { code?: unknown }).code; + return code === "EINVAL"; +} + +async function runLobsterSubprocessOnce( + params: { + execPath: string; + argv: string[]; + cwd: string; + timeoutMs: number; + maxStdoutBytes: number; + }, + useShell: boolean, +) { const { execPath, argv, cwd } = params; const timeoutMs = Math.max(200, params.timeoutMs); const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes); @@ -51,6 +60,8 @@ async function runLobsterSubprocess(params: { cwd, stdio: ["ignore", "pipe", "pipe"], env, + shell: useShell, + windowsHide: useShell ? true : undefined, }); 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 { let parsed: unknown; try { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2827b8511..f66d3c25f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -301,6 +301,8 @@ importers: extensions/imessage: {} + extensions/lobster: {} + extensions/matrix: dependencies: '@matrix-org/matrix-sdk-crypto-nodejs': diff --git a/scripts/codesign-mac-app.sh b/scripts/codesign-mac-app.sh index 318fc8d60..d8eab87c6 100755 --- a/scripts/codesign-mac-app.sh +++ b/scripts/codesign-mac-app.sh @@ -7,7 +7,6 @@ TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}" DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}" SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}" 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_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX) @@ -21,7 +20,6 @@ Env: CODESIGN_TIMESTAMP=auto|on|off DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit - ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1 HELP exit 0 fi @@ -182,43 +180,13 @@ cat > "$ENT_TMP_RUNTIME" <<'PLIST' PLIST -cat > "$ENT_TMP_APP" <<'PLIST' - - - - - com.apple.developer.usernotifications.time-sensitive - - com.apple.security.automation.apple-events - - com.apple.security.device.audio-input - - com.apple.security.device.camera - - com.apple.security.personal-information.location - - - -PLIST - 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 "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)." 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" -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 " -fi # clear extended attributes to avoid stale signatures xattr -cr "$APP_BUNDLE" 2>/dev/null || true diff --git a/scripts/e2e/Dockerfile b/scripts/e2e/Dockerfile index 5092e38d1..b5a7c5500 100644 --- a/scripts/e2e/Dockerfile +++ b/scripts/e2e/Dockerfile @@ -11,6 +11,7 @@ COPY src ./src COPY scripts ./scripts COPY docs ./docs COPY skills ./skills +COPY extensions/memory-core ./extensions/memory-core RUN pnpm install --frozen-lockfile RUN pnpm build diff --git a/scripts/e2e/onboard-docker.sh b/scripts/e2e/onboard-docker.sh index 560c2d9a5..42de5434d 100755 --- a/scripts/e2e/onboard-docker.sh +++ b/scripts/e2e/onboard-docker.sh @@ -51,14 +51,27 @@ TRASH start_s="$(date +%s)" while true; do 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\"; const file = process.env.WIZARD_LOG_PATH; const needle = process.env.NEEDLE ?? \"\"; let text = \"\"; try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); } - text = text.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\").replace(/\\s+/g, \"\"); - process.exit(text.includes(needle) ? 0 : 1); + if (text.length > 20000) text = text.slice(-20000); + 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 return 0 fi @@ -80,13 +93,35 @@ TRASH } wait_for_gateway() { - for _ in $(seq 1 10); do - if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway-e2e.log; then + for _ in $(seq 1 20); do + 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 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 done - cat /tmp/gateway-e2e.log + echo "Gateway failed to start" + cat /tmp/gateway-e2e.log || true return 1 } @@ -116,7 +151,7 @@ TRASH WIZARD_LOG_PATH="$log_path" export WIZARD_LOG_PATH # 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=$! exec 3> "$input_fifo" @@ -129,8 +164,18 @@ TRASH "$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>&- - wait "$wizard_pid" rm -f "$input_fifo" stop_gateway "$gw_pid" if [ -n "$validate_fn" ]; then @@ -176,14 +221,18 @@ TRASH send_local_basic() { # Risk acknowledgement (default is "No"). + wait_for_log "Continue?" 60 send $'"'"'y\r'"'"' 0.6 # 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 } send_reset_config_only() { # Risk acknowledgement (default is "No"). + wait_for_log "Continue?" 40 || true send $'"'"'y\r'"'"' 0.8 # Select reset flow for existing config. wait_for_log "Config handling" 40 || true @@ -211,19 +260,27 @@ TRASH send_skills_flow() { # Select skills section and skip optional installs. - wait_for_log "Where will the Gateway run?" 40 || true - send $'"'"'\r'"'"' 0.8 + send $'"'"'\r'"'"' 1.2 # Configure skills now? -> No - wait_for_log "Configure skills now?" 40 || true - send $'"'"'n\r'"'"' 0.8 - wait_for_log "Configure complete." 40 || true - send "" 0.8 + send $'"'"'n\r'"'"' 1.5 + send "" 1.0 } run_case_local_basic() { local home_dir 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. workspace_dir="$HOME/clawd" @@ -283,25 +340,6 @@ if (errors.length > 0) { } 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() { @@ -355,7 +393,7 @@ NODE # Seed a remote config to exercise reset path. cat > "$HOME/.clawdbot/clawdbot.json" <<'"'"'JSON'"'"' { - "agent": { "workspace": "/root/old" }, + "agents": { "defaults": { "workspace": "/root/old" } }, "gateway": { "mode": "remote", "remote": { "url": "ws://old.example:18789", "token": "old-token" } @@ -363,7 +401,17 @@ NODE } 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" assert_file "$config_path" diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index 3d58bc705..d7aaa218f 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -54,6 +54,7 @@ import { callGatewayTool } from "./tools/gateway.js"; import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js"; import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js"; import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js"; +import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js"; const DEFAULT_MAX_OUTPUT = clampNumber( readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"), @@ -139,7 +140,7 @@ export type { BashSandboxConfig } from "./bash-tools.shared.js"; export type ExecElevatedDefaults = { enabled: boolean; allowed: boolean; - defaultLevel: "on" | "off"; + defaultLevel: "on" | "off" | "ask" | "full"; }; const execSchema = Type.Object({ @@ -659,6 +660,11 @@ export function createExecTool( const notifyOnExit = defaults?.notifyOnExit !== false; const notifySessionKey = defaults?.sessionKey?.trim() || undefined; 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 { name: "exec", @@ -700,12 +706,23 @@ export function createExecTool( : clampNumber(params.yieldMs ?? defaultBackgroundMs, defaultBackgroundMs, 10, 120_000) : null; const elevatedDefaults = defaults?.elevated; - const elevatedDefaultOn = - elevatedDefaults?.defaultLevel === "on" && - elevatedDefaults.enabled && - elevatedDefaults.allowed; - const elevatedRequested = - typeof params.elevated === "boolean" ? params.elevated : elevatedDefaultOn; + const elevatedDefaultMode = + elevatedDefaults?.defaultLevel === "full" + ? "full" + : elevatedDefaults?.defaultLevel === "ask" + ? "ask" + : 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 (!elevatedDefaults?.enabled || !elevatedDefaults.allowed) { const runtime = defaults?.sandbox ? "sandboxed" : "direct"; @@ -761,6 +778,10 @@ export function createExecTool( const configuredAsk = defaults?.ask ?? "on-miss"; const requestedAsk = normalizeExecAsk(params.ask); let ask = maxAsk(configuredAsk, requestedAsk ?? configuredAsk); + const bypassApprovals = elevatedRequested && elevatedMode === "full"; + if (bypassApprovals) { + ask = "off"; + } const sandbox = host === "sandbox" ? defaults?.sandbox : undefined; const rawWorkdir = params.workdir?.trim() || defaults?.cwd || process.cwd(); @@ -799,7 +820,7 @@ export function createExecTool( if (host === "node") { const approvals = resolveExecApprovals( - defaults?.agentId, + agentId, host === "node" ? { security: "allowlist" } : undefined, ); const hostSecurity = minSecurity(security, approvals.agent.security); @@ -865,7 +886,7 @@ export function createExecTool( cwd: workdir, env: nodeEnv, timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined, - agentId: defaults?.agentId, + agentId, sessionKey: defaults?.sessionKey, approved: approvedByAsk, approvalDecision: approvalDecision ?? undefined, @@ -895,9 +916,9 @@ export function createExecTool( host: "node", security: hostSecurity, ask: hostAsk, - agentId: defaults?.agentId, - resolvedPath: undefined, - sessionKey: defaults?.sessionKey, + agentId, + resolvedPath: null, + sessionKey: defaults?.sessionKey ?? null, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, }, )) as { decision?: string } | null; @@ -1025,8 +1046,8 @@ export function createExecTool( }; } - if (host === "gateway") { - const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" }); + if (host === "gateway" && !bypassApprovals) { + const approvals = resolveExecApprovals(agentId, { security: "allowlist" }); const hostSecurity = minSecurity(security, approvals.agent.security); const hostAsk = maxAsk(ask, approvals.agent.ask); const askFallback = approvals.agent.askFallback; @@ -1060,7 +1081,7 @@ export function createExecTool( const approvalSlug = createApprovalSlug(approvalId); const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS; 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 commandText = params.command; const effectiveTimeout = @@ -1080,9 +1101,9 @@ export function createExecTool( host: "gateway", security: hostSecurity, ask: hostAsk, - agentId: defaults?.agentId, + agentId, resolvedPath, - sessionKey: defaults?.sessionKey, + sessionKey: defaults?.sessionKey ?? null, timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS, }, )) as { decision?: string } | null; @@ -1123,7 +1144,7 @@ export function createExecTool( for (const segment of analysis.segments) { const pattern = segment.resolution?.resolvedPath ?? ""; if (pattern) { - addAllowlistEntry(approvals.file, defaults?.agentId, pattern); + addAllowlistEntry(approvals.file, agentId, pattern); } } } @@ -1152,7 +1173,7 @@ export function createExecTool( seen.add(match.pattern); recordAllowlistUse( approvals.file, - defaults?.agentId, + agentId, match, commandText, resolvedPath ?? undefined, @@ -1242,7 +1263,7 @@ export function createExecTool( seen.add(match.pattern); recordAllowlistUse( approvals.file, - defaults?.agentId, + agentId, match, params.command, analysis.segments[0]?.resolution?.resolvedPath, diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index c1d96ea71..26ee43495 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -183,6 +183,8 @@ export function buildSystemPrompt(params: { const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ config: params.config, agentId: params.agentId, + workspaceDir: params.workspaceDir, + cwd: process.cwd(), runtime: { host: "clawdbot", os: `${os.type()} ${os.release()}`, diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index 032c82992..6d0122d1e 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -68,6 +68,10 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean { 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 { const trimmed = value?.trim(); if (!trimmed) return fallback; @@ -443,6 +447,15 @@ describeLive("live models (profile keys)", () => { logProgress(`${progressLabel}: skip (chatgpt usage limit)`); break; } + if ( + allowNotFoundSkip && + model.provider === "openai-codex" && + isInstructionsRequiredError(message) + ) { + skipped.push({ model: id, reason: message }); + logProgress(`${progressLabel}: skip (instructions required)`); + break; + } logProgress(`${progressLabel}: failed`); failures.push({ model: id, error: message }); break; diff --git a/src/agents/opencode-zen-models.test.ts b/src/agents/opencode-zen-models.test.ts index e732bae44..d46b0ac6b 100644 --- a/src/agents/opencode-zen-models.test.ts +++ b/src/agents/opencode-zen-models.test.ts @@ -44,6 +44,8 @@ describe("resolveOpencodeZenModelApi", () => { expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages"); expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai"); 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("some-unknown-model")).toBe("openai-completions"); }); diff --git a/src/agents/opencode-zen-models.ts b/src/agents/opencode-zen-models.ts index 6eb87b855..bf1734d5e 100644 --- a/src/agents/opencode-zen-models.ts +++ b/src/agents/opencode-zen-models.ts @@ -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 { 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"; } if (lower.startsWith("gemini-")) { return "google-generative-ai"; } - if (lower.startsWith("gpt-")) { - return "openai-responses"; - } return "openai-completions"; } diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 19450226c..f16a71759 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -279,6 +279,8 @@ export async function runEmbeddedAttempt( const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ config: params.config, agentId: sessionAgentId, + workspaceDir: effectiveWorkspace, + cwd: process.cwd(), runtime: { host: machineName, os: `${os.type()} ${os.release()}`, diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index a8aa3c48c..56380cd1d 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -76,6 +76,6 @@ export type EmbeddedSandboxInfo = { allowedControlPorts?: number[]; elevated?: { allowed: boolean; - defaultLevel: "on" | "off"; + defaultLevel: "on" | "off" | "ask" | "full"; }; }; diff --git a/src/agents/system-prompt-params.test.ts b/src/agents/system-prompt-params.test.ts new file mode 100644 index 000000000..fd108a3c7 --- /dev/null +++ b/src/agents/system-prompt-params.test.ts @@ -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 { + return fs.mkdtemp(path.join(os.tmpdir(), `clawdbot-${label}-`)); +} + +async function makeRepoRoot(root: string): Promise { + 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(); + }); +}); diff --git a/src/agents/system-prompt-params.ts b/src/agents/system-prompt-params.ts index 21a97831a..9de8f481a 100644 --- a/src/agents/system-prompt-params.ts +++ b/src/agents/system-prompt-params.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import path from "node:path"; + import type { ClawdbotConfig } from "../config/config.js"; import { formatUserTime, @@ -18,6 +21,7 @@ export type RuntimeInfoInput = { capabilities?: string[]; /** Supported message actions for the current channel (e.g., react, edit, unsend) */ channelActions?: string[]; + repoRoot?: string; }; export type SystemPromptRuntimeParams = { @@ -31,7 +35,14 @@ export function buildSystemPromptParams(params: { config?: ClawdbotConfig; agentId?: string; runtime: Omit; + workspaceDir?: string; + cwd?: string; }): SystemPromptRuntimeParams { + const repoRoot = resolveRepoRoot({ + config: params.config, + workspaceDir: params.workspaceDir, + cwd: params.cwd, + }); const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat); const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat); @@ -39,9 +50,56 @@ export function buildSystemPromptParams(params: { runtimeInfo: { agentId: params.agentId, ...params.runtime, + repoRoot, }, userTimezone, userTime, 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(); + 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; +} diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index fce27677a..b5fe28556 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -284,6 +284,7 @@ describe("buildAgentSystemPrompt", () => { { agentId: "work", host: "host", + repoRoot: "/repo", os: "macOS", arch: "arm64", node: "v20", @@ -297,6 +298,7 @@ describe("buildAgentSystemPrompt", () => { expect(line).toContain("agent=work"); expect(line).toContain("host=host"); + expect(line).toContain("repo=/repo"); expect(line).toContain("os=macOS (arm64)"); expect(line).toContain("node=v20"); 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("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"); }); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index af5eec98f..6a20391c0 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -160,6 +160,7 @@ export function buildAgentSystemPrompt(params: { defaultModel?: string; channel?: string; capabilities?: string[]; + repoRoot?: string; }; messageToolHints?: string[]; sandboxInfo?: { @@ -175,7 +176,7 @@ export function buildAgentSystemPrompt(params: { allowedControlPorts?: number[]; elevated?: { allowed: boolean; - defaultLevel: "on" | "off"; + defaultLevel: "on" | "off" | "ask" | "full"; }; }; /** Reaction guidance for the agent (for Telegram minimal/extensive modes). */ @@ -200,7 +201,7 @@ export function buildAgentSystemPrompt(params: { browser: "Control web browser", canvas: "Present/eval/snapshot the Canvas", 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", gateway: "Restart, apply config, or run updates on the running Clawdbot process", agents_list: "List agent ids allowed for sessions_spawn", @@ -351,7 +352,7 @@ export function buildAgentSystemPrompt(params: { "- browser: control clawd's dedicated browser", "- canvas: present/eval/snapshot the Canvas", "- 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_history: fetch session history", "- sessions_send: send to another session", @@ -443,12 +444,14 @@ export function buildAgentSystemPrompt(params: { params.sandboxInfo.elevated?.allowed ? "Elevated exec is available for this session." : "", - params.sandboxInfo.elevated?.allowed ? "User can toggle with /elevated on|off." : "", 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 - ? `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) @@ -570,6 +573,7 @@ export function buildRuntimeLine( node?: string; model?: string; defaultModel?: string; + repoRoot?: string; }, runtimeChannel?: string, runtimeCapabilities: string[] = [], @@ -578,6 +582,7 @@ export function buildRuntimeLine( return `Runtime: ${[ runtimeInfo?.agentId ? `agent=${runtimeInfo.agentId}` : "", runtimeInfo?.host ? `host=${runtimeInfo.host}` : "", + runtimeInfo?.repoRoot ? `repo=${runtimeInfo.repoRoot}` : "", runtimeInfo?.os ? `os=${runtimeInfo.os}${runtimeInfo?.arch ? ` (${runtimeInfo.arch})` : ""}` : runtimeInfo?.arch diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 4ce176b1d..7e6d76399 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -395,9 +395,9 @@ function buildChatCommands(): ChatCommandDefinition[] { args: [ { name: "mode", - description: "on or off", + description: "on, off, ask, or full", type: "string", - choices: ["on", "off"], + choices: ["on", "off", "ask", "full"], }, ], argsMenu: "auto", diff --git a/src/auto-reply/envelope.test.ts b/src/auto-reply/envelope.test.ts index d811fbd2f..7860ecb49 100644 --- a/src/auto-reply/envelope.test.ts +++ b/src/auto-reply/envelope.test.ts @@ -18,6 +18,7 @@ describe("formatAgentEnvelope", () => { host: "mac-mini", ip: "10.0.0.5", timestamp: ts, + envelope: { timezone: "utc" }, body: "hello", }); @@ -26,7 +27,7 @@ describe("formatAgentEnvelope", () => { 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; process.env.TZ = "America/Los_Angeles"; @@ -39,10 +40,10 @@ describe("formatAgentEnvelope", () => { 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; process.env.TZ = "America/Los_Angeles"; @@ -50,13 +51,13 @@ describe("formatAgentEnvelope", () => { const body = formatAgentEnvelope({ channel: "WebChat", timestamp: ts, - envelope: { timezone: "local" }, + envelope: { timezone: "utc" }, body: "hello", }); 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", () => { diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index 513be2aa4..53622d5e5 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -16,7 +16,7 @@ export type AgentEnvelopeParams = { 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; /** @@ -59,7 +59,7 @@ function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEn const includeTimestamp = options?.includeTimestamp !== false; const includeElapsed = options?.includeElapsed !== false; return { - timezone: options?.timezone?.trim() || "utc", + timezone: options?.timezone?.trim() || "local", includeTimestamp, includeElapsed, userTimezone: options?.userTimezone, @@ -77,7 +77,7 @@ function resolveExplicitTimezone(value: string): string | undefined { function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone { const trimmed = options.timezone?.trim(); - if (!trimmed) return { mode: "utc" }; + if (!trimmed) return { mode: "local" }; const lowered = trimmed.toLowerCase(); if (lowered === "utc" || lowered === "gmt") return { mode: "utc" }; if (lowered === "local" || lowered === "host") return { mode: "local" }; diff --git a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts index b636b85d6..1997ebe3b 100644 --- a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts @@ -219,7 +219,7 @@ describe("directive behavior", () => { ); 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 () => { diff --git a/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts b/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts index bf0ac3df2..abf7eb0c1 100644 --- a/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts @@ -150,7 +150,7 @@ describe("directive behavior", () => { ); 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(); }); }); diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts index 3ff09e217..4fb307f0d 100644 --- a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts @@ -143,7 +143,7 @@ describe("directive behavior", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; 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(); }); }); diff --git a/src/auto-reply/reply.directive.parse.test.ts b/src/auto-reply/reply.directive.parse.test.ts index 72f22262f..545c5e169 100644 --- a/src/auto-reply/reply.directive.parse.test.ts +++ b/src/auto-reply/reply.directive.parse.test.ts @@ -55,6 +55,16 @@ describe("directive parsing", () => { expect(res.hasDirective).toBe(true); 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", () => { const res = extractThinkDirective("/think:high run slow"); diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts index 193172535..43cc0e5b2 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts @@ -129,7 +129,7 @@ describe("trigger handling", () => { cfg, ); 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 store = JSON.parse(storeRaw) as Record; @@ -223,7 +223,7 @@ describe("trigger handling", () => { ); const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toBe("ok"); - expect(text).not.toContain("Elevated mode enabled"); + expect(text).not.toContain("Elevated mode set to ask"); }); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts index 8ace7a4bc..fe4479df1 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts @@ -184,7 +184,7 @@ describe("trigger handling", () => { cfg, ); 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 store = JSON.parse(storeRaw) as Record; @@ -226,7 +226,7 @@ describe("trigger handling", () => { cfg, ); 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 store = JSON.parse(storeRaw) as Record; diff --git a/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts b/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts index 0d6a0b303..183c4a67e 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts @@ -167,7 +167,7 @@ describe("trigger handling", () => { cfg, ); 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 store = JSON.parse(storeRaw) as Record; diff --git a/src/auto-reply/reply/commands-context-report.ts b/src/auto-reply/reply/commands-context-report.ts index 5ba3aedc9..5cdc9f3d7 100644 --- a/src/auto-reply/reply/commands-context-report.ts +++ b/src/auto-reply/reply/commands-context-report.ts @@ -102,6 +102,8 @@ async function resolveContextReport( const { runtimeInfo, userTimezone, userTime, userTimeFormat } = buildSystemPromptParams({ config: params.cfg, agentId: sessionAgentId, + workspaceDir, + cwd: process.cwd(), runtime: { host: "unknown", os: "unknown", @@ -118,7 +120,7 @@ async function resolveContextReport( workspaceAccess: "rw" as const, elevated: { 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 }; diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts index f57c34f95..a1e33c642 100644 --- a/src/auto-reply/reply/commands-subagents.ts +++ b/src/auto-reply/reply/commands-subagents.ts @@ -45,7 +45,7 @@ function formatTimestampWithAge(valueMs?: number) { } function resolveRequesterSessionKey(params: Parameters[0]): string | undefined { - const raw = params.ctx.CommandTargetSessionKey?.trim() || params.sessionKey; + const raw = params.sessionKey?.trim() || params.ctx.CommandTargetSessionKey?.trim(); if (!raw) return undefined; const { mainKey, alias } = resolveMainSessionAlias(params.cfg); return resolveInternalSessionKey({ key: raw, alias, mainKey }); diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index fa104de03..cf383ed86 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -215,6 +215,33 @@ describe("handleCommands subagents", () => { 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 () => { resetSubagentRegistryForTests(); const cfg = { diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index 33f19ee3f..7f056e6cd 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -205,7 +205,7 @@ export async function handleDirectiveOnly(params: { const level = currentElevatedLevel ?? "off"; return { text: [ - withOptions(`Current elevated level: ${level}.`, "on, off"), + withOptions(`Current elevated level: ${level}.`, "on, off, ask, full"), shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null, ] .filter(Boolean) @@ -213,7 +213,7 @@ export async function handleDirectiveOnly(params: { }; } 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)) { @@ -426,7 +426,9 @@ export async function handleDirectiveOnly(params: { parts.push( directives.elevatedLevel === "off" ? 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()); } diff --git a/src/auto-reply/reply/directive-handling.shared.ts b/src/auto-reply/reply/directive-handling.shared.ts index 961fe50a7..2fa4fd1ed 100644 --- a/src/auto-reply/reply/directive-handling.shared.ts +++ b/src/auto-reply/reply/directive-handling.shared.ts @@ -16,10 +16,15 @@ export const withOptions = (line: string, options: string) => export const formatElevatedRuntimeHint = () => `${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`; -export const formatElevatedEvent = (level: ElevatedLevel) => - level === "on" - ? "Elevated ON — exec runs on host; set elevated:false to stay sandboxed." - : "Elevated OFF — exec stays in sandbox."; +export const formatElevatedEvent = (level: ElevatedLevel) => { + if (level === "full") { + return "Elevated FULL — exec runs on host with auto-approval."; + } + 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) => { if (level === "stream") return "Reasoning STREAM — emit live ."; diff --git a/src/auto-reply/reply/session-updates.test.ts b/src/auto-reply/reply/session-updates.test.ts index 05def80da..d673e2b4f 100644 --- a/src/auto-reply/reply/session-updates.test.ts +++ b/src/auto-reply/reply/session-updates.test.ts @@ -5,8 +5,10 @@ import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system import { prependSystemEvents } from "./session-updates.js"; 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(); + const originalTz = process.env.TZ; + process.env.TZ = "America/Los_Angeles"; const timestamp = new Date("2026-01-12T20:19:17Z"); vi.setSystemTime(timestamp); @@ -20,11 +22,10 @@ describe("prependSystemEvents", () => { prefixedBodyBase: "User: hi", }); - const expectedTimestamp = "2026-01-12T20:19:17Z"; - - expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`); + expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./); resetSystemEventsForTest(); + process.env.TZ = originalTz; vi.useRealTimers(); }); }); diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 227e61cd5..e5ad81d8e 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; +import { resolveUserTimezone } from "../../agents/date-time.js"; import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js"; import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js"; import type { ClawdbotConfig } from "../../config/config.js"; @@ -27,9 +28,32 @@ export async function prependSystemEvents(params: { return trimmed; }; - const formatSystemEventTimestamp = (ts: number) => { - const date = new Date(ts); - if (Number.isNaN(date.getTime())) return "unknown-time"; + const resolveExplicitTimezone = (value: string): string | undefined => { + try { + 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 mm = String(date.getUTCMonth() + 1).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`; }; + 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 queued = drainSystemEventEntries(params.sessionKey); systemLines.push( @@ -46,7 +106,7 @@ export async function prependSystemEvents(params: { .map((event) => { const compacted = compactSystemEvent(event.text); if (!compacted) return null; - return `[${formatSystemEventTimestamp(event.ts)}] ${compacted}`; + return `[${formatSystemEventTimestamp(event.ts, params.cfg)}] ${compacted}`; }) .filter((v): v is string => Boolean(v)), ); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 2184c5f9a..eaf2d20a8 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -324,7 +324,12 @@ export function buildStatusMessage(args: StatusArgs): string { const queueDetails = formatQueueDetails(args.queue); const verboseLabel = 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 = [ `Runtime: ${runtime.label}`, `Think: ${thinkLevel}`, @@ -395,7 +400,7 @@ export function buildHelpMessage(cfg?: ClawdbotConfig): string { "/think ", "/verbose on|full|off", "/reasoning on|off", - "/elevated on|off", + "/elevated on|off|ask|full", "/model ", "/usage off|tokens|full", ]; diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 6f9637dbd..aabb2cf17 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -1,6 +1,7 @@ export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; 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 UsageDisplayLevel = "off" | "tokens" | "full"; @@ -112,10 +113,18 @@ export function normalizeElevatedLevel(raw?: string | null): ElevatedLevel | und if (!raw) return undefined; const key = raw.toLowerCase(); 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"; 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. export function normalizeReasoningLevel(raw?: string | null): ReasoningLevel | undefined { if (!raw) return undefined; diff --git a/src/browser/chrome.profile-decoration.ts b/src/browser/chrome.profile-decoration.ts index 7c5d96d6f..49192466c 100644 --- a/src/browser/chrome.profile-decoration.ts +++ b/src/browser/chrome.profile-decoration.ts @@ -180,3 +180,11 @@ export function decorateClawdProfile( // 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); +} diff --git a/src/browser/chrome.test.ts b/src/browser/chrome.test.ts index a8a42ae95..da8e384da 100644 --- a/src/browser/chrome.test.ts +++ b/src/browser/chrome.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { decorateClawdProfile, + ensureProfileCleanExit, findChromeExecutableMac, findChromeExecutableWindows, 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 () => { const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-")); try { diff --git a/src/browser/chrome.ts b/src/browser/chrome.ts index eebf399bc..6f610bcc4 100644 --- a/src/browser/chrome.ts +++ b/src/browser/chrome.ts @@ -13,7 +13,11 @@ import { type BrowserExecutable, resolveBrowserExecutableForPlatform, } 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 { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_PROFILE_NAME } from "./constants.js"; @@ -26,7 +30,11 @@ export { findChromeExecutableWindows, resolveBrowserExecutableForPlatform, } 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) { try { @@ -178,6 +186,8 @@ export async function launchClawdChrome( "--disable-background-networking", "--disable-component-update", "--disable-features=Translate,MediaRouter", + "--disable-session-crashed-bubble", + "--hide-crash-restore-bubble", "--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(); // Wait for CDP to come up. const readyDeadline = Date.now() + 15_000; diff --git a/src/canvas-host/a2ui/.bundle.hash b/src/canvas-host/a2ui/.bundle.hash index 6e2a91754..91cb22241 100644 --- a/src/canvas-host/a2ui/.bundle.hash +++ b/src/canvas-host/a2ui/.bundle.hash @@ -1 +1 @@ -27d5aed982d9f110b44e85254877597e49efae61141de480b4e9f254c04131ce +0ae29522de4c48c6b6407290be18b94d7244d4e0036738abd19d93148f2c8cd4 diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index 8f3d8e839..f51e2f5e0 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -171,7 +171,7 @@ describe("canvas host", () => { const ws = new WebSocket(`ws://127.0.0.1:${server.port}${CANVAS_WS_PATH}`); await new Promise((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", () => { clearTimeout(timer); resolve(); @@ -183,13 +183,14 @@ describe("canvas host", () => { }); const msg = new Promise((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) => { clearTimeout(timer); resolve(rawDataToString(data)); }); }); + await new Promise((resolve) => setTimeout(resolve, 100)); await fs.writeFile(index, "v2", "utf8"); expect(await msg).toBe("reload"); ws.close(); @@ -197,7 +198,7 @@ describe("canvas host", () => { await server.close(); await fs.rm(dir, { recursive: true, force: true }); } - }, 10_000); + }, 20_000); it("serves the gateway-hosted A2UI scaffold", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-canvas-")); diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index bc2496b70..26beb81e2 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -44,6 +44,14 @@ const entries: SubCliEntry[] = [ 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", description: "Gateway logs", diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index 088a021bf..37ac4fc1c 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -68,8 +68,12 @@ const STEP_LABELS: Record = { "clean check": "Working directory is clean", "upstream check": "Upstream branch exists", "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", + "preflight worktree": "Preparing preflight worktree", + "preflight cleanup": "Cleaning preflight worktree", "deps install": "Installing dependencies", build: "Building", "ui:build": "Building UI", diff --git a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts index fd0f2f296..dbc08339d 100644 --- a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts +++ b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts @@ -23,21 +23,33 @@ describe("legacy config detection", () => { 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(); const { migrateLegacyConfig } = await import("./config.js"); const res = migrateLegacyConfig({ routing: { allowFrom: ["+15555550123"] }, + channels: { whatsapp: {} }, }); expect(res.changes).toContain("Moved routing.allowFrom → channels.whatsapp.allowFrom."); expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]); 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(); const { migrateLegacyConfig } = await import("./config.js"); const res = migrateLegacyConfig({ routing: { groupChat: { requireMention: false } }, + channels: { whatsapp: {} }, }); expect(res.changes).toContain( '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?.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 () => { vi.resetModules(); const { migrateLegacyConfig } = await import("./config.js"); diff --git a/src/config/legacy.migrations.part-1.ts b/src/config/legacy.migrations.part-1.ts index d1d0a57e7..f537c3ce8 100644 --- a/src/config/legacy.migrations.part-1.ts +++ b/src/config/legacy.migrations.part-1.ts @@ -156,11 +156,16 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ const allowFrom = (routing as Record).allowFrom; if (allowFrom === undefined) return; - const channels = ensureRecord(raw, "channels"); - const whatsapp = - channels.whatsapp && typeof channels.whatsapp === "object" - ? (channels.whatsapp as Record) - : {}; + const channels = getRecord(raw.channels); + const whatsapp = channels ? getRecord(channels.whatsapp) : null; + if (!whatsapp) { + delete (routing as Record).allowFrom; + if (Object.keys(routing as Record).length === 0) { + delete raw.routing; + } + changes.push("Removed routing.allowFrom (channels.whatsapp not configured)."); + return; + } if (whatsapp.allowFrom === undefined) { whatsapp.allowFrom = allowFrom; @@ -173,8 +178,8 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ if (Object.keys(routing as Record).length === 0) { delete raw.routing; } - channels.whatsapp = whatsapp; - raw.channels = channels; + channels!.whatsapp = whatsapp; + raw.channels = channels!; }, }, { @@ -193,7 +198,11 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ if (requireMention === undefined) return; 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 = channels[key] && typeof channels[key] === "object" ? (channels[key] as Record) @@ -222,7 +231,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ } }; - applyTo("whatsapp"); + applyTo("whatsapp", { requireExisting: true }); applyTo("telegram"); applyTo("imessage"); diff --git a/src/config/schema.ts b/src/config/schema.ts index 4fca28504..3bb39674d 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -198,6 +198,7 @@ const FIELD_LABELS: Record = { "skills.load.watch": "Watch Skills", "skills.load.watchDebounceMs": "Skills Watch Debounce (ms)", "agents.defaults.workspace": "Workspace", + "agents.defaults.repoRoot": "Repo Root", "agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars", "agents.defaults.envelopeTimezone": "Envelope Timezone", "agents.defaults.envelopeTimestamp": "Envelope Timestamp", @@ -436,6 +437,8 @@ const FIELD_HELP: Record = { "auth.cooldowns.failureWindowHours": "Failure window (hours) for backoff counters (default: 24).", "agents.defaults.bootstrapMaxChars": "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": 'Timezone for message envelopes ("utc", "local", "user", or an IANA timezone string).', "agents.defaults.envelopeTimestamp": diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 11f7cf10d..d4bac779c 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -99,6 +99,8 @@ export type AgentDefaultsConfig = { models?: Record; /** Agent working directory (preferred). Used as the default cwd for agent runs. */ 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. */ skipBootstrap?: boolean; /** 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. */ verboseDefault?: "off" | "on" | "full"; /** 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. */ blockStreamingDefault?: "off" | "on"; /** diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts index c6c0ab3b2..c4b8a8f2c 100644 --- a/src/config/zod-schema.agent-defaults.ts +++ b/src/config/zod-schema.agent-defaults.ts @@ -42,6 +42,7 @@ export const AgentDefaultsSchema = z ) .optional(), workspace: z.string().optional(), + repoRoot: z.string().optional(), skipBootstrap: z.boolean().optional(), bootstrapMaxChars: z.number().int().positive().optional(), userTimezone: z.string().optional(), @@ -112,7 +113,9 @@ export const AgentDefaultsSchema = z ]) .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(), blockStreamingBreak: z.union([z.literal("text_end"), z.literal("message_end")]).optional(), blockStreamingChunk: BlockStreamingChunkSchema.optional(), diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 5ca96efc9..8b95e6eb8 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -113,6 +113,30 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean { 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("can’t help") || + msg.includes("can't comply") || + msg.includes("can’t comply") + ); +} + function isMissingProfileError(error: string): boolean { return /no credentials found for profile/i.test(error); } @@ -856,6 +880,27 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { logProgress(`${progressLabel}: skip (chatgpt usage limit)`); 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)) { skippedCount += 1; logProgress(`${progressLabel}: skip (missing auth profile)`); diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts index 1a3736971..1d34217b7 100644 --- a/src/gateway/sessions-patch.ts +++ b/src/gateway/sessions-patch.ts @@ -169,7 +169,7 @@ export async function applySessionsPatchToStore(params: { delete next.elevatedLevel; } else if (raw !== undefined) { 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. next.elevatedLevel = normalized; } diff --git a/src/infra/exec-approvals.test.ts b/src/infra/exec-approvals.test.ts index d6474a39c..f6d77b2f1 100644 --- a/src/infra/exec-approvals.test.ts +++ b/src/infra/exec-approvals.test.ts @@ -14,6 +14,7 @@ import { normalizeSafeBins, resolveCommandResolution, resolveExecApprovals, + resolveExecApprovalsFromFile, type ExecAllowlistEntry, } 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(); + }); +}); diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index ee8b1c541..b6d3549f4 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -4,6 +4,8 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; +import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; + export type ExecHost = "sandbox" | "gateway" | "node"; export type ExecSecurity = "deny" | "allowlist" | "full"; export type ExecAsk = "off" | "on-miss" | "always"; @@ -84,6 +86,35 @@ export function resolveExecApprovalsSocketPath(): string { 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(); + 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) { const dir = path.dirname(filePath); fs.mkdirSync(dir, { recursive: true }); @@ -92,6 +123,13 @@ function ensureDir(filePath: string) { export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile { const socketPath = file.socket?.path?.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 = { version: 1, socket: { @@ -104,7 +142,7 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi askFallback: file.defaults?.askFallback, autoAllowSkills: file.defaults?.autoAllowSkills, }, - agents: file.agents ?? {}, + agents, }; return normalized; } @@ -231,7 +269,7 @@ export function resolveExecApprovalsFromFile(params: { }): ExecApprovalsResolved { const file = normalizeExecApprovals(params.file); const defaults = file.defaults ?? {}; - const agentKey = params.agentId ?? "default"; + const agentKey = params.agentId ?? DEFAULT_AGENT_ID; const agent = file.agents?.[agentKey] ?? {}; const wildcard = file.agents?.["*"] ?? {}; const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY; @@ -696,7 +734,7 @@ export function recordAllowlistUse( command: string, resolvedPath?: string, ) { - const target = agentId ?? "default"; + const target = agentId ?? DEFAULT_AGENT_ID; const agents = approvals.agents ?? {}; const existing = agents[target] ?? {}; const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : []; @@ -720,7 +758,7 @@ export function addAllowlistEntry( agentId: string | undefined, pattern: string, ) { - const target = agentId ?? "default"; + const target = agentId ?? DEFAULT_AGENT_ID; const agents = approvals.agents ?? {}; const existing = agents[target] ?? {}; const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : []; diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index f01a03d67..e33159326 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -74,7 +74,9 @@ describe("runGatewayUpdate", () => { stdout: "origin/main", }, [`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: "" }, }); diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 994788ee2..0a5196fd7 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js"; @@ -63,6 +64,7 @@ type UpdateRunnerOptions = { const DEFAULT_TIMEOUT_MS = 20 * 60_000; const MAX_LOG_CHARS = 8000; +const PREFLIGHT_MAX_COMMITS = 10; const START_DIRS = ["cwd", "argv1", "process"]; function normalizeDir(value?: string | null) { @@ -420,8 +422,152 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise< ); 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( - step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot), + step("git rebase", ["git", "-C", gitRoot, "rebase", selectedSha], gitRoot), ); steps.push(rebaseStep); if (rebaseStep.exitCode !== 0) { diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index a648b9511..902662e33 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -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 { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import { createTelegramBot } from "./bot.js"; @@ -119,8 +119,11 @@ const getOnHandler = (event: string) => { return handler as (ctx: Record) => Promise; }; +const ORIGINAL_TZ = process.env.TZ; + describe("createTelegramBot", () => { beforeEach(() => { + process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { @@ -138,6 +141,9 @@ describe("createTelegramBot", () => { botCtorSpy.mockReset(); _sequentializeKey = undefined; }); + afterEach(() => { + process.env.TZ = ORIGINAL_TZ; + }); // groupPolicy tests diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index e27e119f0..84541cf98 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -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 { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; @@ -122,8 +122,11 @@ const getOnHandler = (event: string) => { return handler as (ctx: Record) => Promise; }; +const ORIGINAL_TZ = process.env.TZ; + describe("createTelegramBot", () => { beforeEach(() => { + process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { @@ -141,6 +144,9 @@ describe("createTelegramBot", () => { botCtorSpy.mockReset(); sequentializeKey = undefined; }); + afterEach(() => { + process.env.TZ = ORIGINAL_TZ; + }); // groupPolicy tests diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 21f52c50d..315147ddc 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { listNativeCommandSpecs, listNativeCommandSpecsForConfig, @@ -148,8 +148,11 @@ const getOnHandler = (event: string) => { return handler as (ctx: Record) => Promise; }; +const ORIGINAL_TZ = process.env.TZ; + describe("createTelegramBot", () => { beforeEach(() => { + process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { @@ -168,6 +171,9 @@ describe("createTelegramBot", () => { botCtorSpy.mockReset(); sequentializeKey = undefined; }); + afterEach(() => { + process.env.TZ = ORIGINAL_TZ; + }); it("installs grammY throttler", () => { createTelegramBot({ token: "tok" }); @@ -556,94 +562,106 @@ describe("createTelegramBot", () => { }); it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { + const originalTz = process.env.TZ; + process.env.TZ = "UTC"; onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); - loadConfig.mockReturnValue({ - identity: { name: "Bert" }, - messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: true } }, + try { + loadConfig.mockReturnValue({ + identity: { name: "Bert" }, + messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: true } }, + }, }, - }, - }); + }); - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; - await handler({ - message: { - chat: { id: 7, type: "group", title: "Test Group" }, - text: "bert: introduce yourself", - date: 1736380800, - message_id: 1, - from: { id: 9, first_name: "Ada" }, - }, - me: { username: "clawdbot_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); + await handler({ + message: { + chat: { id: 7, type: "group", title: "Test Group" }, + text: "bert: introduce yourself", + date: 1736380800, + message_id: 1, + from: { id: 9, first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expectInboundContextContract(payload); - expect(payload.WasMentioned).toBe(true); - const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); - const timestampPattern = escapeRegExp(expectedTimestamp); - expect(payload.Body).toMatch( - new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`), - ); - expect(payload.SenderName).toBe("Ada"); - expect(payload.SenderId).toBe("9"); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expectInboundContextContract(payload); + expect(payload.WasMentioned).toBe(true); + const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); + expect(payload.Body).toMatch( + new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`), + ); + expect(payload.SenderName).toBe("Ada"); + expect(payload.SenderId).toBe("9"); + } finally { + process.env.TZ = originalTz; + } }); it("includes sender identity in group envelope headers", async () => { + const originalTz = process.env.TZ; + process.env.TZ = "UTC"; onSpy.mockReset(); const replySpy = replyModule.__replySpy as unknown as ReturnType; replySpy.mockReset(); - loadConfig.mockReturnValue({ - channels: { - telegram: { - groupPolicy: "open", - groups: { "*": { requireMention: false } }, + try { + loadConfig.mockReturnValue({ + channels: { + telegram: { + groupPolicy: "open", + groups: { "*": { requireMention: false } }, + }, }, - }, - }); + }); - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; + createTelegramBot({ token: "tok" }); + const handler = getOnHandler("message") as (ctx: Record) => Promise; - await handler({ - message: { - chat: { id: 42, type: "group", title: "Ops" }, - text: "hello", - date: 1736380800, - message_id: 2, - from: { - id: 99, - first_name: "Ada", - last_name: "Lovelace", - username: "ada", + await handler({ + message: { + chat: { id: 42, type: "group", title: "Ops" }, + text: "hello", + date: 1736380800, + message_id: 2, + from: { + id: 99, + first_name: "Ada", + last_name: "Lovelace", + username: "ada", + }, }, - }, - me: { username: "clawdbot_bot" }, - getFile: async () => ({ download: async () => new Uint8Array() }), - }); + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); - expect(replySpy).toHaveBeenCalledTimes(1); - const payload = replySpy.mock.calls[0][0]; - expectInboundContextContract(payload); - const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); - const timestampPattern = escapeRegExp(expectedTimestamp); - expect(payload.Body).toMatch( - new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`), - ); - expect(payload.SenderName).toBe("Ada Lovelace"); - expect(payload.SenderId).toBe("99"); - expect(payload.SenderUsername).toBe("ada"); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expectInboundContextContract(payload); + const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); + expect(payload.Body).toMatch( + new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`), + ); + expect(payload.SenderName).toBe("Ada Lovelace"); + expect(payload.SenderId).toBe("99"); + expect(payload.SenderUsername).toBe("ada"); + } finally { + process.env.TZ = originalTz; + } }); it("reacts to mention-gated group messages when ackReaction is enabled", async () => { diff --git a/src/tui/commands.ts b/src/tui/commands.ts index b85049472..59806cfbd 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -3,7 +3,7 @@ import { formatThinkingLevels, listThinkingLevelLabels } from "../auto-reply/thi const VERBOSE_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 USAGE_FOOTER_LEVELS = ["off", "tokens", "full"]; @@ -83,7 +83,7 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman }, { name: "elevated", - description: "Set elevated on/off", + description: "Set elevated on/off/ask/full", getArgumentCompletions: (prefix) => ELEVATED_LEVELS.filter((v) => v.startsWith(prefix.toLowerCase())).map((value) => ({ value, @@ -130,8 +130,8 @@ export function helpText(options: SlashCommandOptions = {}): string { "/verbose ", "/reasoning ", "/usage ", - "/elevated ", - "/elev ", + "/elevated ", + "/elev ", "/activation ", "/new or /reset", "/abort", diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 40584da0e..79765b5fc 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -371,7 +371,11 @@ export function createCommandHandlers(context: CommandHandlerContext) { } case "elevated": if (!args) { - chatLog.addSystem("usage: /elevated "); + chatLog.addSystem("usage: /elevated "); + break; + } + if (!["on", "off", "ask", "full"].includes(args)) { + chatLog.addSystem("usage: /elevated "); break; } try { diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts index 60f2feb75..149b40801 100644 --- a/ui/src/ui/views/config-form.render.ts +++ b/ui/src/ui/views/config-form.render.ts @@ -54,7 +54,7 @@ const sectionIcons = { }; // Section metadata -const SECTION_META: Record = { +export const SECTION_META: Record = { env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" }, update: { label: "Updates", description: "Auto-update settings and release channel" }, agents: { label: "Agents", description: "Agent configurations, models, and identities" }, diff --git a/ui/src/ui/views/config-form.ts b/ui/src/ui/views/config-form.ts index 6675bef26..0bcfe0a9c 100644 --- a/ui/src/ui/views/config-form.ts +++ b/ui/src/ui/views/config-form.ts @@ -1,4 +1,4 @@ -export { renderConfigForm, type ConfigFormProps } from "./config-form.render"; +export { renderConfigForm, type ConfigFormProps, SECTION_META } from "./config-form.render"; export { analyzeConfigSchema, type ConfigSchemaAnalysis, diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 9af992024..53b550efe 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -1,6 +1,6 @@ import { html, nothing } from "lit"; import type { ConfigUiHints } from "../types"; -import { analyzeConfigSchema, renderConfigForm } from "./config-form"; +import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form"; import { hintForPath, humanize,