Compare commits

..
51 changed files with 317 additions and 963 deletions

No files matched your search

+1 -8
View File
@@ -8,24 +8,17 @@ Docs: https://docs.clawd.bot
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- 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.
- Cron: clarify reminder systemEvent wording guidance. (#1204) Thanks @cpojer.
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
## 2026.1.21
@@ -149,7 +149,6 @@ 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
@@ -166,22 +165,13 @@ 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: agents)
agents: file.agents)
}
static func readSnapshot() -> ExecApprovalsSnapshot {
@@ -282,7 +272,9 @@ enum ExecApprovalsStore {
ask: defaults.ask ?? self.defaultAsk,
askFallback: defaults.askFallback ?? self.defaultAskFallback,
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
let key = self.agentKey(agentId)
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
: "default"
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
let resolvedAgent = ExecApprovalsResolvedDefaults(
@@ -465,36 +457,7 @@ enum ExecApprovalsStore {
private static func agentKey(_ agentId: String?) -> String {
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? self.defaultAgentId : trimmed
}
private static func normalizedPattern(_ pattern: String?) -> String? {
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? nil : trimmed.lowercased()
}
private static func mergeAgents(
current: ExecApprovalsAgent,
legacy: ExecApprovalsAgent
) -> ExecApprovalsAgent {
var seen = Set<String>()
var allowlist: [ExecAllowlistEntry] = []
func append(_ entry: ExecAllowlistEntry) {
guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else {
return
}
seen.insert(key)
allowlist.append(entry)
}
for entry in current.allowlist ?? [] { append(entry) }
for entry in legacy.allowlist ?? [] { append(entry) }
return ExecApprovalsAgent(
security: current.security ?? legacy.security,
ask: current.ask ?? legacy.ask,
askFallback: current.askFallback ?? legacy.askFallback,
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
allowlist: allowlist.isEmpty ? nil : allowlist)
return trimmed.isEmpty ? "default" : trimmed
}
}
@@ -2,12 +2,15 @@ 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(
@@ -17,6 +20,7 @@ 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) {
@@ -56,6 +60,27 @@ 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.",
@@ -81,12 +106,27 @@ 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<Bool> {
@@ -95,6 +135,26 @@ 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")
@@ -1,6 +1,4 @@
import ClawdbotIPC
import ClawdbotKit
import CoreLocation
import SwiftUI
struct PermissionsSettings: View {
@@ -19,8 +17,6 @@ struct PermissionsSettings: View {
.padding(.horizontal, 2)
.padding(.vertical, 6)
LocationAccessSettings()
Button("Restart onboarding") { self.showOnboarding() }
.buttonStyle(.bordered)
Spacer()
@@ -30,72 +26,6 @@ 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
+5 -7
View File
@@ -69,13 +69,11 @@ High-level:
1. Requires a clean worktree (no uncommitted changes).
2. Switches to the selected channel (tag or branch).
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.
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.
## `--update` shorthand
+6 -7
View File
@@ -9,15 +9,15 @@ read_when:
Clawdbot standardizes timestamps so the model sees a **single reference time**.
## Message envelopes (local by default)
## Message envelopes (UTC by default)
Inbound messages are wrapped in an envelope like:
```
[Provider ... 2026-01-05 16:26 PST] message text
[Provider ... 2026-01-05T21:26Z] message text
```
The timestamp in the envelope is **host-local by default**, with minutes precision.
The timestamp in the envelope is **UTC by default**, with minutes precision.
You can override this with:
@@ -25,7 +25,7 @@ You can override this with:
{
agents: {
defaults: {
envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone
envelopeTimestamp: "on", // "on" | "off"
envelopeElapsed: "on" // "on" | "off"
}
@@ -33,7 +33,6 @@ 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.
@@ -41,10 +40,10 @@ You can override this with:
### Examples
**Local (default):**
**UTC (default):**
```
[Signal Alice +1555 2026-01-18 00:19 PST] hello
[Signal Alice +1555 2026-01-18T05:19Z] hello
```
**Fixed timezone:**
+11 -13
View File
@@ -7,18 +7,18 @@ read_when:
# Date & Time
Clawdbot defaults to **host-local time for transport timestamps** and **user-local time only in the system prompt**.
Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**.
Provider timestamps are preserved so tools keep their native semantics.
## Message envelopes (local by default)
## Message envelopes (UTC by default)
Inbound messages are wrapped with a timestamp (minute precision):
Inbound messages are wrapped with a UTC timestamp (minute precision):
```
[Provider ... 2026-01-05 16:26 PST] message text
[Provider ... 2026-01-05T21:26Z] message text
```
This envelope timestamp is **host-local by default**, regardless of the provider timezone.
This envelope timestamp is **UTC by default**, regardless of the host timezone.
You can override this behavior:
@@ -26,7 +26,7 @@ You can override this behavior:
{
agents: {
defaults: {
envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone
envelopeTimestamp: "on", // "on" | "off"
envelopeElapsed: "on" // "on" | "off"
}
@@ -34,7 +34,6 @@ 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.
@@ -43,10 +42,10 @@ You can override this behavior:
### Examples
**Local (default):**
**UTC (default):**
```
[WhatsApp +1555 2026-01-18 00:19 PST] hello
[WhatsApp +1555 2026-01-18T05:19Z] hello
```
**User timezone:**
@@ -74,13 +73,12 @@ 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 (local by default)
## System event lines (UTC)
Queued system events inserted into agent context are prefixed with a timestamp using the
same timezone selection as message envelopes (default: host-local).
Queued system events inserted into agent context are prefixed with a UTC timestamp:
```
System: [2026-01-12 12:19:17 PST] Model switched.
System: [2026-01-12T20:19:17Z] Model switched.
```
### Configure user timezone + format
-15
View File
@@ -178,20 +178,6 @@ 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.
@@ -201,7 +187,6 @@ 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
-9
View File
@@ -53,15 +53,6 @@ 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:
+3 -6
View File
@@ -155,21 +155,18 @@ Quick diagnosis:
```bash
node -v
npm -v
npm prefix -g
npm bin -g
echo "$PATH"
```
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`).
If the output of `npm bin -g` 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
# macOS / Linux
export PATH="$(npm prefix -g)/bin:$PATH"
export PATH="/path/from/npm/bin/-g:$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
+8 -11
View File
@@ -19,36 +19,33 @@ Run:
```bash
node -v
npm -v
npm prefix -g
npm bin -g
echo "$PATH"
```
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`).
If the output of `npm bin -g` 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 npm prefix:
1) Find your global bin directory:
```bash
npm prefix -g
npm bin -g
```
2) Add the global npm bin directory to your shell startup file:
2) Add it to your shell startup file:
- zsh: `~/.zshrc`
- bash: `~/.bashrc`
Example (replace the path with your `npm prefix -g` output):
Example (replace the path with your `npm bin -g` output):
```bash
# macOS / Linux
export PATH="/path/from/npm/prefix/bin:$PATH"
export PATH="/path/from/npm/bin/-g:$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:
@@ -66,7 +63,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 the global npm bin dir stable and on PATH in new shells
- makes `npm bin -g` stable and on PATH in new shells
Common choices:
-24
View File
@@ -117,8 +117,6 @@ 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)
@@ -1541,28 +1539,6 @@ 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
-1
View File
@@ -88,7 +88,6 @@ If a prompt is required but no UI is reachable, fallback decides:
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
editing in the macOS app. Patterns are **case-insensitive glob matches**.
Patterns should resolve to **binary paths** (basename-only entries are ignored).
Legacy `agents.default` entries are migrated to `agents.main` on load.
Examples:
- `~/Projects/**/bin/bird`
+14 -25
View File
@@ -7,32 +7,19 @@ import { describe, expect, it } from "vitest";
import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/plugins/types.js";
import { createLobsterTool } from "./lobster-tool.js";
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 };
}
async function writeFakeLobster(params: {
payload: unknown;
}) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-"));
const binPath = path.join(dir, "lobster");
const file = `#!/usr/bin/env node\n${scriptBody}\n`;
const file = `#!/usr/bin/env node\n` +
`process.stdout.write(JSON.stringify(${JSON.stringify(params.payload)}));\n`;
await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 });
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",
@@ -95,10 +82,12 @@ describe("lobster plugin tool", () => {
});
it("rejects invalid JSON from lobster", async () => {
const { binPath } = await writeFakeLobsterScript(
`process.stdout.write("nope");\n`,
"clawdbot-lobster-plugin-bad-",
);
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 tool = createLobsterTool(fakeApi());
await expect(
+7 -35
View File
@@ -29,22 +29,13 @@ function resolveExecutablePath(lobsterPathRaw: string | undefined) {
return lobsterPath;
}
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,
) {
async function runLobsterSubprocess(params: {
execPath: string;
argv: string[];
cwd: string;
timeoutMs: number;
maxStdoutBytes: number;
}) {
const { execPath, argv, cwd } = params;
const timeoutMs = Math.max(200, params.timeoutMs);
const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
@@ -60,8 +51,6 @@ async function runLobsterSubprocessOnce(
cwd,
stdio: ["ignore", "pipe", "pipe"],
env,
shell: useShell,
windowsHide: useShell ? true : undefined,
});
let stdout = "";
@@ -113,23 +102,6 @@ async function runLobsterSubprocessOnce(
});
}
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 {
-2
View File
@@ -301,8 +301,6 @@ importers:
extensions/imessage: {}
extensions/lobster: {}
extensions/matrix:
dependencies:
'@matrix-org/matrix-sdk-crypto-nodejs':
+32
View File
@@ -7,6 +7,7 @@ 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)
@@ -20,6 +21,7 @@ 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
@@ -180,13 +182,43 @@ cat > "$ENT_TMP_RUNTIME" <<'PLIST'
</plist>
PLIST
cat > "$ENT_TMP_APP" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.usernotifications.time-sensitive</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
</dict>
</plist>
PLIST
if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then
/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 <app>"
fi
# clear extended attributes to avoid stale signatures
xattr -cr "$APP_BUNDLE" 2>/dev/null || true
-1
View File
@@ -11,7 +11,6 @@ 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
+37 -85
View File
@@ -51,27 +51,14 @@ TRASH
start_s="$(date +%s)"
while true; do
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
if grep -a -F -q "$needle" "$WIZARD_LOG_PATH"; then
return 0
fi
if NEEDLE=\"$needle_compact\" node --input-type=module -e "
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); }
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);
text = text.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\").replace(/\\s+/g, \"\");
process.exit(text.includes(needle) ? 0 : 1);
"; then
return 0
fi
@@ -93,35 +80,13 @@ TRASH
}
wait_for_gateway() {
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
for _ in $(seq 1 10); do
if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway-e2e.log; 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
echo "Gateway failed to start"
cat /tmp/gateway-e2e.log || true
cat /tmp/gateway-e2e.log
return 1
}
@@ -151,7 +116,7 @@ TRASH
WIZARD_LOG_PATH="$log_path"
export WIZARD_LOG_PATH
# Run under script to keep an interactive TTY for clack prompts.
script -q -f -c "$command" "$log_path" < "$input_fifo" &
script -q -c "$command" "$log_path" < "$input_fifo" &
wizard_pid=$!
exec 3> "$input_fifo"
@@ -164,18 +129,8 @@ 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
@@ -221,18 +176,14 @@ 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.
if wait_for_log "Where will the Gateway run?" 20; then
send $'"'"'\r'"'"' 0.5
fi
send $'"'"'\r'"'"' 0.5
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
@@ -260,27 +211,19 @@ TRASH
send_skills_flow() {
# Select skills section and skip optional installs.
send $'"'"'\r'"'"' 1.2
wait_for_log "Where will the Gateway run?" 40 || true
send $'"'"'\r'"'"' 0.8
# Configure skills now? -> No
send $'"'"'n\r'"'"' 1.5
send "" 1.0
wait_for_log "Configure skills now?" 40 || true
send $'"'"'n\r'"'"' 0.8
wait_for_log "Configure complete." 40 || true
send "" 0.8
}
run_case_local_basic() {
local home_dir
home_dir="$(make_home local-basic)"
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
run_wizard local-basic "$home_dir" send_local_basic validate_local_basic_log
# Assert config + workspace scaffolding.
workspace_dir="$HOME/clawd"
@@ -340,6 +283,25 @@ 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() {
@@ -393,7 +355,7 @@ NODE
# Seed a remote config to exercise reset path.
cat > "$HOME/.clawdbot/clawdbot.json" <<'"'"'JSON'"'"'
{
"agents": { "defaults": { "workspace": "/root/old" } },
"agent": { "workspace": "/root/old" },
"gateway": {
"mode": "remote",
"remote": { "url": "ws://old.example:18789", "token": "old-token" }
@@ -401,17 +363,7 @@ NODE
}
JSON
node dist/index.js onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--reset \
--skip-channels \
--skip-skills \
--skip-daemon \
--skip-ui \
--skip-health
run_wizard reset-config "$home_dir" send_reset_config_only
config_path="$HOME/.clawdbot/clawdbot.json"
assert_file "$config_path"
+12 -18
View File
@@ -54,7 +54,6 @@ 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"),
@@ -660,11 +659,6 @@ 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",
@@ -805,7 +799,7 @@ export function createExecTool(
if (host === "node") {
const approvals = resolveExecApprovals(
agentId,
defaults?.agentId,
host === "node" ? { security: "allowlist" } : undefined,
);
const hostSecurity = minSecurity(security, approvals.agent.security);
@@ -871,7 +865,7 @@ export function createExecTool(
cwd: workdir,
env: nodeEnv,
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
agentId,
agentId: defaults?.agentId,
sessionKey: defaults?.sessionKey,
approved: approvedByAsk,
approvalDecision: approvalDecision ?? undefined,
@@ -901,9 +895,9 @@ export function createExecTool(
host: "node",
security: hostSecurity,
ask: hostAsk,
agentId,
resolvedPath: null,
sessionKey: defaults?.sessionKey ?? null,
agentId: defaults?.agentId,
resolvedPath: undefined,
sessionKey: defaults?.sessionKey,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
},
)) as { decision?: string } | null;
@@ -1032,7 +1026,7 @@ export function createExecTool(
}
if (host === "gateway") {
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" });
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
@@ -1066,7 +1060,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 ?? null;
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath;
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
const commandText = params.command;
const effectiveTimeout =
@@ -1086,9 +1080,9 @@ export function createExecTool(
host: "gateway",
security: hostSecurity,
ask: hostAsk,
agentId,
agentId: defaults?.agentId,
resolvedPath,
sessionKey: defaults?.sessionKey ?? null,
sessionKey: defaults?.sessionKey,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
},
)) as { decision?: string } | null;
@@ -1129,7 +1123,7 @@ export function createExecTool(
for (const segment of analysis.segments) {
const pattern = segment.resolution?.resolvedPath ?? "";
if (pattern) {
addAllowlistEntry(approvals.file, agentId, pattern);
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
}
}
}
@@ -1158,7 +1152,7 @@ export function createExecTool(
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
agentId,
defaults?.agentId,
match,
commandText,
resolvedPath ?? undefined,
@@ -1248,7 +1242,7 @@ export function createExecTool(
seen.add(match.pattern);
recordAllowlistUse(
approvals.file,
agentId,
defaults?.agentId,
match,
params.command,
analysis.segments[0]?.resolution?.resolvedPath,
-13
View File
@@ -68,10 +68,6 @@ 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;
@@ -447,15 +443,6 @@ 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;
-2
View File
@@ -44,8 +44,6 @@ 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");
});
+5 -5
View File
@@ -87,19 +87,19 @@ export function resolveOpencodeZenAlias(modelIdOrAlias: string): string {
}
/**
* OpenCode Zen routes models to specific API shapes by family.
* OpenCode Zen routes models to different APIs based on model family.
*/
export function resolveOpencodeZenModelApi(modelId: string): ModelApi {
const lower = modelId.toLowerCase();
if (lower.startsWith("gpt-")) {
return "openai-responses";
}
if (lower.startsWith("claude-") || lower.startsWith("minimax-")) {
if (lower.startsWith("claude-") || lower.startsWith("minimax") || lower.startsWith("alpha-gd4")) {
return "anthropic-messages";
}
if (lower.startsWith("gemini-")) {
return "google-generative-ai";
}
if (lower.startsWith("gpt-")) {
return "openai-responses";
}
return "openai-completions";
}
+2 -2
View File
@@ -200,7 +200,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; 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)",
cron: "Manage cron jobs and wake events (use for reminders; set systemEvent to a reminder-style message, especially after long delays; 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 +351,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; 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)",
"- cron: manage cron jobs and wake events (use for reminders; set systemEvent to a reminder-style message, especially after long delays; include recent context in reminder text if appropriate)",
"- sessions_list: list sessions",
"- sessions_history: fetch session history",
"- sessions_send: send to another session",
+5 -6
View File
@@ -18,7 +18,6 @@ describe("formatAgentEnvelope", () => {
host: "mac-mini",
ip: "10.0.0.5",
timestamp: ts,
envelope: { timezone: "utc" },
body: "hello",
});
@@ -27,7 +26,7 @@ describe("formatAgentEnvelope", () => {
expect(body).toBe("[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello");
});
it("formats timestamps in local timezone by default", () => {
it("formats timestamps in UTC regardless of local timezone", () => {
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
@@ -40,10 +39,10 @@ describe("formatAgentEnvelope", () => {
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 UTC when configured", () => {
it("formats timestamps in local timezone when configured", () => {
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
@@ -51,13 +50,13 @@ describe("formatAgentEnvelope", () => {
const body = formatAgentEnvelope({
channel: "WebChat",
timestamp: ts,
envelope: { timezone: "utc" },
envelope: { timezone: "local" },
body: "hello",
});
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 user timezone when configured", () => {
+3 -3
View File
@@ -16,7 +16,7 @@ export type AgentEnvelopeParams = {
export type EnvelopeFormatOptions = {
/**
* "local" (default), "utc", "user", or an explicit IANA timezone string.
* "utc" (default), "local", "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() || "local",
timezone: options?.timezone?.trim() || "utc",
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: "local" };
if (!trimmed) return { mode: "utc" };
const lowered = trimmed.toLowerCase();
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" };
if (lowered === "local" || lowered === "host") return { mode: "local" };
+4 -5
View File
@@ -5,10 +5,8 @@ import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system
import { prependSystemEvents } from "./session-updates.js";
describe("prependSystemEvents", () => {
it("adds a local timestamp to queued system events by default", async () => {
it("adds a UTC timestamp to queued system events", 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);
@@ -22,10 +20,11 @@ describe("prependSystemEvents", () => {
prefixedBodyBase: "User: hi",
});
expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
const expectedTimestamp = "2026-01-12T20:19:17Z";
expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`);
resetSystemEventsForTest();
process.env.TZ = originalTz;
vi.useRealTimers();
});
});
+4 -64
View File
@@ -1,6 +1,5 @@
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";
@@ -28,32 +27,9 @@ export async function prependSystemEvents(params: {
return trimmed;
};
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 formatSystemEventTimestamp = (ts: number) => {
const date = new Date(ts);
if (Number.isNaN(date.getTime())) return "unknown-time";
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");
@@ -63,42 +39,6 @@ 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(
@@ -106,7 +46,7 @@ export async function prependSystemEvents(params: {
.map((event) => {
const compacted = compactSystemEvent(event.text);
if (!compacted) return null;
return `[${formatSystemEventTimestamp(event.ts, params.cfg)}] ${compacted}`;
return `[${formatSystemEventTimestamp(event.ts)}] ${compacted}`;
})
.filter((v): v is string => Boolean(v)),
);
-8
View File
@@ -180,11 +180,3 @@ 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);
}
-13
View File
@@ -7,7 +7,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
decorateClawdProfile,
ensureProfileCleanExit,
findChromeExecutableMac,
findChromeExecutableWindows,
isChromeReachable,
@@ -104,18 +103,6 @@ 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 {
+2 -18
View File
@@ -13,11 +13,7 @@ import {
type BrowserExecutable,
resolveBrowserExecutableForPlatform,
} from "./chrome.executables.js";
import {
decorateClawdProfile,
ensureProfileCleanExit,
isProfileDecorated,
} from "./chrome.profile-decoration.js";
import { decorateClawdProfile, 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";
@@ -30,11 +26,7 @@ export {
findChromeExecutableWindows,
resolveBrowserExecutableForPlatform,
} from "./chrome.executables.js";
export {
decorateClawdProfile,
ensureProfileCleanExit,
isProfileDecorated,
} from "./chrome.profile-decoration.js";
export { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js";
function exists(filePath: string) {
try {
@@ -186,8 +178,6 @@ 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",
];
@@ -256,12 +246,6 @@ 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;
+1 -1
View File
@@ -1 +1 @@
0ae29522de4c48c6b6407290be18b94d7244d4e0036738abd19d93148f2c8cd4
27d5aed982d9f110b44e85254877597e49efae61141de480b4e9f254c04131ce
+1 -5
View File
@@ -68,12 +68,8 @@ const STEP_LABELS: Record<string, string> = {
"clean check": "Working directory is clean",
"upstream check": "Upstream branch exists",
"git fetch": "Fetching latest changes",
"git rebase": "Rebasing onto target commit",
"git rev-parse @{upstream}": "Resolving upstream commit",
"git rev-list": "Enumerating candidate commits",
"git rebase": "Rebasing onto upstream",
"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",
+1 -10
View File
@@ -34,7 +34,6 @@ export type AgentIdentity = {
creature?: string;
vibe?: string;
theme?: string;
avatar?: string;
};
export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
@@ -91,7 +90,6 @@ export function parseIdentityMarkdown(content: string): AgentIdentity {
if (label === "creature") identity.creature = value;
if (label === "vibe") identity.vibe = value;
if (label === "theme") identity.theme = value;
if (label === "avatar") identity.avatar = value;
}
return identity;
}
@@ -101,14 +99,7 @@ export function loadAgentIdentity(workspace: string): AgentIdentity | null {
try {
const content = fs.readFileSync(identityPath, "utf-8");
const parsed = parseIdentityMarkdown(content);
if (
!parsed.name &&
!parsed.emoji &&
!parsed.theme &&
!parsed.creature &&
!parsed.vibe &&
!parsed.avatar
) {
if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe) {
return null;
}
return parsed;
@@ -23,33 +23,21 @@ describe("legacy config detection", () => {
expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention");
}
});
it("migrates routing.allowFrom to channels.whatsapp.allowFrom when whatsapp configured", async () => {
it("migrates routing.allowFrom to channels.whatsapp.allowFrom", 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("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 () => {
it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups", 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.',
@@ -65,26 +53,6 @@ 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");
+9 -18
View File
@@ -156,16 +156,11 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
const allowFrom = (routing as Record<string, unknown>).allowFrom;
if (allowFrom === undefined) return;
const channels = getRecord(raw.channels);
const whatsapp = channels ? getRecord(channels.whatsapp) : null;
if (!whatsapp) {
delete (routing as Record<string, unknown>).allowFrom;
if (Object.keys(routing as Record<string, unknown>).length === 0) {
delete raw.routing;
}
changes.push("Removed routing.allowFrom (channels.whatsapp not configured).");
return;
}
const channels = ensureRecord(raw, "channels");
const whatsapp =
channels.whatsapp && typeof channels.whatsapp === "object"
? (channels.whatsapp as Record<string, unknown>)
: {};
if (whatsapp.allowFrom === undefined) {
whatsapp.allowFrom = allowFrom;
@@ -178,8 +173,8 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
if (Object.keys(routing as Record<string, unknown>).length === 0) {
delete raw.routing;
}
channels!.whatsapp = whatsapp;
raw.channels = channels!;
channels.whatsapp = whatsapp;
raw.channels = channels;
},
},
{
@@ -198,11 +193,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
if (requireMention === undefined) return;
const channels = ensureRecord(raw, "channels");
const applyTo = (
key: "whatsapp" | "telegram" | "imessage",
options?: { requireExisting?: boolean },
) => {
if (options?.requireExisting && !isRecord(channels[key])) return;
const applyTo = (key: "whatsapp" | "telegram" | "imessage") => {
const section =
channels[key] && typeof channels[key] === "object"
? (channels[key] as Record<string, unknown>)
@@ -231,7 +222,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
}
};
applyTo("whatsapp", { requireExisting: true });
applyTo("whatsapp");
applyTo("telegram");
applyTo("imessage");
-2
View File
@@ -154,6 +154,4 @@ export type IdentityConfig = {
name?: string;
theme?: string;
emoji?: string;
/** Path to a custom avatar image (relative to workspace or absolute). */
avatar?: string;
};
@@ -113,30 +113,6 @@ 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);
}
@@ -880,27 +856,6 @@ 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)`);
-30
View File
@@ -14,7 +14,6 @@ import {
normalizeSafeBins,
resolveCommandResolution,
resolveExecApprovals,
resolveExecApprovalsFromFile,
type ExecAllowlistEntry,
} from "./exec-approvals.js";
@@ -228,32 +227,3 @@ 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();
});
});
+4 -42
View File
@@ -4,8 +4,6 @@ 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";
@@ -86,35 +84,6 @@ 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<string>();
const pushEntry = (entry: ExecAllowlistEntry) => {
const key = normalizeAllowlistPattern(entry.pattern);
if (!key || seen.has(key)) return;
seen.add(key);
allowlist.push(entry);
};
for (const entry of current.allowlist ?? []) pushEntry(entry);
for (const entry of legacy.allowlist ?? []) pushEntry(entry);
return {
security: current.security ?? legacy.security,
ask: current.ask ?? legacy.ask,
askFallback: current.askFallback ?? legacy.askFallback,
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
allowlist: allowlist.length > 0 ? allowlist : undefined,
};
}
function ensureDir(filePath: string) {
const dir = path.dirname(filePath);
fs.mkdirSync(dir, { recursive: true });
@@ -123,13 +92,6 @@ 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: {
@@ -142,7 +104,7 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi
askFallback: file.defaults?.askFallback,
autoAllowSkills: file.defaults?.autoAllowSkills,
},
agents,
agents: file.agents ?? {},
};
return normalized;
}
@@ -269,7 +231,7 @@ export function resolveExecApprovalsFromFile(params: {
}): ExecApprovalsResolved {
const file = normalizeExecApprovals(params.file);
const defaults = file.defaults ?? {};
const agentKey = params.agentId ?? DEFAULT_AGENT_ID;
const agentKey = params.agentId ?? "default";
const agent = file.agents?.[agentKey] ?? {};
const wildcard = file.agents?.["*"] ?? {};
const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY;
@@ -734,7 +696,7 @@ export function recordAllowlistUse(
command: string,
resolvedPath?: string,
) {
const target = agentId ?? DEFAULT_AGENT_ID;
const target = agentId ?? "default";
const agents = approvals.agents ?? {};
const existing = agents[target] ?? {};
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
@@ -758,7 +720,7 @@ export function addAllowlistEntry(
agentId: string | undefined,
pattern: string,
) {
const target = agentId ?? DEFAULT_AGENT_ID;
const target = agentId ?? "default";
const agents = approvals.agents ?? {};
const existing = agents[target] ?? {};
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
+1 -3
View File
@@ -74,9 +74,7 @@ describe("runGatewayUpdate", () => {
stdout: "origin/main",
},
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
[`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 @{upstream}`]: { code: 1, stderr: "conflict" },
[`git -C ${tempDir} rebase --abort`]: { stdout: "" },
});
+1 -147
View File
@@ -1,5 +1,4 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
@@ -64,7 +63,6 @@ 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) {
@@ -422,152 +420,8 @@ 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", selectedSha], gitRoot),
step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot),
);
steps.push(rebaseStep);
if (rebaseStep.exitCode !== 0) {
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot } from "./bot.js";
@@ -89,7 +89,6 @@ vi.mock("grammy", () => ({
const sequentializeMiddleware = vi.fn();
const sequentializeSpy = vi.fn(() => sequentializeMiddleware);
let _sequentializeKey: ((ctx: unknown) => string) | undefined;
let originalTz: string | undefined;
vi.mock("@grammyjs/runner", () => ({
sequentialize: (keyFn: (ctx: unknown) => string) => {
_sequentializeKey = keyFn;
@@ -121,8 +120,6 @@ const getOnHandler = (event: string) => {
describe("createTelegramBot", () => {
beforeEach(() => {
originalTz = process.env.TZ;
process.env.TZ = "UTC";
resetInboundDedupe();
loadConfig.mockReturnValue({
channels: {
@@ -141,10 +138,6 @@ describe("createTelegramBot", () => {
_sequentializeKey = undefined;
});
afterEach(() => {
process.env.TZ = originalTz;
});
// groupPolicy tests
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
@@ -183,9 +176,7 @@ describe("createTelegramBot", () => {
expect(payload.WasMentioned).toBe(true);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
expect(payload.Body).toMatch(
/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
);
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
});
it("keeps group envelope headers stable (sender identity is separate)", async () => {
onSpy.mockReset();
@@ -226,7 +217,7 @@ describe("createTelegramBot", () => {
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/);
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
});
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
onSpy.mockReset();
@@ -329,7 +329,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toMatch(
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09 01:00 [^\]]+\]/,
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
);
expect(payload.Body).toContain("hello world");
} finally {
+61 -77
View File
@@ -451,7 +451,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.Body).toMatch(
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09 01:00 [^\]]+\]/,
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
);
expect(payload.Body).toContain("hello world");
} finally {
@@ -551,102 +551,86 @@ 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<typeof vi.fn>;
replySpy.mockReset();
try {
loadConfig.mockReturnValue({
identity: { name: "Bert" },
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
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<string, unknown>) => Promise<void>;
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
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);
expect(payload.Body).toMatch(
/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
} finally {
process.env.TZ = originalTz;
}
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.WasMentioned).toBe(true);
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9");
});
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<typeof vi.fn>;
replySpy.mockReset();
try {
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
});
},
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
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);
expect(payload.Body).toMatch(
/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
);
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
} finally {
process.env.TZ = originalTz;
}
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expectInboundContextContract(payload);
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada");
});
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
@@ -329,11 +329,11 @@ describe("web auto-reply", () => {
const firstArgs = resolver.mock.calls[0][0];
const secondArgs = resolver.mock.calls[1][0];
expect(firstArgs.Body).toMatch(
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01 01:00 [^\]]+\] \[clawdbot\] first/,
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T00:00Z\] \[clawdbot\] first/,
);
expect(firstArgs.Body).not.toContain("second");
expect(secondArgs.Body).toMatch(
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01 02:00 [^\]]+\] \[clawdbot\] second/,
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T01:00Z\] \[clawdbot\] second/,
);
expect(secondArgs.Body).not.toContain("first");
-6
View File
@@ -89,12 +89,6 @@
color: rgba(134, 142, 150, 1);
}
/* Image avatar support */
img.chat-avatar {
object-fit: cover;
object-position: center;
}
/* Minimal Bubble Design - dynamic width based on content */
.chat-bubble {
position: relative;
+1 -7
View File
@@ -105,7 +105,7 @@ export function renderMessageGroup(
`;
}
function renderAvatar(role: string, avatarUrl?: string) {
function renderAvatar(role: string) {
const normalized = normalizeRoleForGrouping(role);
const initial =
normalized === "user"
@@ -123,12 +123,6 @@ function renderAvatar(role: string, avatarUrl?: string) {
: normalized === "tool"
? "tool"
: "other";
// If avatar URL is provided for assistant, show image
if (avatarUrl && normalized === "assistant") {
return html`<img class="chat-avatar ${className}" src="${avatarUrl}" alt="Assistant" />`;
}
return html`<div class="chat-avatar ${className}">${initial}</div>`;
}
+1 -1
View File
@@ -54,7 +54,7 @@ const sectionIcons = {
};
// Section metadata
export const SECTION_META: Record<string, { label: string; description: string }> = {
const SECTION_META: Record<string, { label: string; description: string }> = {
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" },
+1 -1
View File
@@ -1,4 +1,4 @@
export { renderConfigForm, type ConfigFormProps, SECTION_META } from "./config-form.render";
export { renderConfigForm, type ConfigFormProps } from "./config-form.render";
export {
analyzeConfigSchema,
type ConfigSchemaAnalysis,
+1 -1
View File
@@ -1,6 +1,6 @@
import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form";
import { analyzeConfigSchema, renderConfigForm } from "./config-form";
import {
hintForPath,
humanize,