Compare commits

..
Author SHA1 Message Date
Peter Steinberger 3c163c71b5 fix: export SECTION_META for config form (#1418) (thanks @MaudeBot) 2026-01-22 04:33:37 +00:00
Maude Bot 277881b52f fix(ui): export SECTION_META from config-form module
Export the SECTION_META constant from config-form.render.ts and
re-export it through config-form.ts so it can be imported by config.ts.

This fixes a runtime error where SECTION_META was being referenced
but not properly exported from its source module.
2026-01-22 04:16:51 +00:00
29 changed files with 270 additions and 655 deletions

No files matched your search

+1 -3
View File
@@ -8,7 +8,6 @@ Docs: https://docs.clawd.bot
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster - 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. - Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting - Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Docs: add /model allowlist troubleshooting note. (#1405)
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky. - Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead). - Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Signal: add typing indicators and DM read receipts via signal-cli. - Signal: add typing indicators and DM read receipts via signal-cli.
@@ -19,14 +18,13 @@ Docs: https://docs.clawd.bot
### Fixes ### Fixes
- Config: avoid stack traces for invalid configs and log the config path. - Config: avoid stack traces for invalid configs and log the config path.
- Doctor: avoid recreating WhatsApp config when only legacy routing keys remain. (#900)
- Doctor: warn when gateway.mode is unset with configure/config guidance. - Doctor: warn when gateway.mode is unset with configure/config guidance.
- OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416) - OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416)
- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362) - macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr. - Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
- Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj. - Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai. - UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
- UI: export config form section metadata for shared usage. (#1418) Thanks @MaudeBot.
## 2026.1.21 ## 2026.1.21
@@ -2,12 +2,15 @@ import AppKit
import ClawdbotDiscovery import ClawdbotDiscovery
import ClawdbotIPC import ClawdbotIPC
import ClawdbotKit import ClawdbotKit
import CoreLocation
import Observation import Observation
import SwiftUI import SwiftUI
struct GeneralSettings: View { struct GeneralSettings: View {
@Bindable var state: AppState @Bindable var state: AppState
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false @AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
private let healthStore = HealthStore.shared private let healthStore = HealthStore.shared
private let gatewayManager = GatewayProcessManager.shared private let gatewayManager = GatewayProcessManager.shared
@State private var gatewayDiscovery = GatewayDiscoveryModel( @State private var gatewayDiscovery = GatewayDiscoveryModel(
@@ -17,6 +20,7 @@ struct GeneralSettings: View {
@State private var showRemoteAdvanced = false @State private var showRemoteAdvanced = false
private let isPreview = ProcessInfo.processInfo.isPreview private let isPreview = ProcessInfo.processInfo.isPreview
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode } private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
var body: some View { var body: some View {
ScrollView(.vertical) { ScrollView(.vertical) {
@@ -56,6 +60,27 @@ struct GeneralSettings: View {
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.", subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
binding: self.$cameraEnabled) binding: self.$cameraEnabled)
VStack(alignment: .leading, spacing: 6) {
Text("Location Access")
.font(.body)
Picker("", selection: self.$locationModeRaw) {
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
}
.labelsHidden()
.pickerStyle(.menu)
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
.disabled(self.locationMode == .off)
Text("Always may require System Settings to approve background location.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
SettingsToggleRow( SettingsToggleRow(
title: "Enable Peekaboo Bridge", title: "Enable Peekaboo Bridge",
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.", subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
@@ -81,12 +106,27 @@ struct GeneralSettings: View {
.onAppear { .onAppear {
guard !self.isPreview else { return } guard !self.isPreview else { return }
self.refreshGatewayStatus() self.refreshGatewayStatus()
self.lastLocationModeRaw = self.locationModeRaw
} }
.onChange(of: self.state.canvasEnabled) { _, enabled in .onChange(of: self.state.canvasEnabled) { _, enabled in
if !enabled { if !enabled {
CanvasManager.shared.hideAll() CanvasManager.shared.hideAll()
} }
} }
.onChange(of: self.locationModeRaw) { _, newValue in
let previous = self.lastLocationModeRaw
self.lastLocationModeRaw = newValue
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
Task {
let granted = await self.requestLocationAuthorization(mode: mode)
if !granted {
await MainActor.run {
self.locationModeRaw = previous
self.lastLocationModeRaw = previous
}
}
}
}
} }
private var activeBinding: Binding<Bool> { private var activeBinding: Binding<Bool> {
@@ -95,6 +135,26 @@ struct GeneralSettings: View {
set: { self.state.isPaused = !$0 }) set: { self.state.isPaused = !$0 })
} }
private var locationMode: ClawdbotLocationMode {
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
}
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
guard mode != .off else { return true }
guard CLLocationManager.locationServicesEnabled() else {
await MainActor.run { LocationPermissionHelper.openSettings() }
return false
}
let status = CLLocationManager().authorizationStatus
let requireAlways = mode == .always
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
return true
}
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
}
private var connectionSection: some View { private var connectionSection: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("Clawdbot runs") Text("Clawdbot runs")
@@ -1,6 +1,4 @@
import ClawdbotIPC import ClawdbotIPC
import ClawdbotKit
import CoreLocation
import SwiftUI import SwiftUI
struct PermissionsSettings: View { struct PermissionsSettings: View {
@@ -19,8 +17,6 @@ struct PermissionsSettings: View {
.padding(.horizontal, 2) .padding(.horizontal, 2)
.padding(.vertical, 6) .padding(.vertical, 6)
LocationAccessSettings()
Button("Restart onboarding") { self.showOnboarding() } Button("Restart onboarding") { self.showOnboarding() }
.buttonStyle(.bordered) .buttonStyle(.bordered)
Spacer() Spacer()
@@ -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 { struct PermissionStatusList: View {
let status: [Capability: Bool] let status: [Capability: Bool]
let refresh: () async -> Void let refresh: () async -> Void
+5 -7
View File
@@ -69,13 +69,11 @@ High-level:
1. Requires a clean worktree (no uncommitted changes). 1. Requires a clean worktree (no uncommitted changes).
2. Switches to the selected channel (tag or branch). 2. Switches to the selected channel (tag or branch).
3. Fetches upstream (dev only). 3. Fetches and rebases against `@{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. 4. Installs deps (pnpm preferred; npm fallback).
5. Rebases onto the selected commit (dev only). 5. Builds + builds the Control UI.
6. Installs deps (pnpm preferred; npm fallback). 6. Runs `clawdbot doctor` as the final “safe update” check.
7. Builds + builds the Control UI. 7. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
8. Runs `clawdbot doctor` as the final “safe update” check.
9. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
## `--update` shorthand ## `--update` shorthand
-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. - 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)). - **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) ### Model strength (security note)
Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts. Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts.
@@ -201,7 +187,6 @@ Recommendations:
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes. - **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists). - If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
- When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled. - When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled.
- For chat-only personal assistants with trusted input and no tools, smaller models are usually fine.
## Reasoning & verbose output in groups ## Reasoning & verbose output in groups
-9
View File
@@ -53,15 +53,6 @@ Almost always a Node/npm PATH issue. Start here:
- [Models](/cli/models) - [Models](/cli/models)
- [OAuth / auth concepts](/concepts/oauth) - [OAuth / auth concepts](/concepts/oauth)
### `/model` says `model not allowed`
This usually means `agents.defaults.models` is configured as an allowlist. When 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 ### When filing an issue
Paste a safe report: Paste a safe report:
+3 -6
View File
@@ -155,21 +155,18 @@ Quick diagnosis:
```bash ```bash
node -v node -v
npm -v npm -v
npm prefix -g npm bin -g
echo "$PATH" 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`): Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`):
```bash ```bash
# macOS / Linux export PATH="/path/from/npm/bin/-g:$PATH"
export PATH="$(npm prefix -g)/bin:$PATH"
``` ```
On Windows, add the output of `npm prefix -g` to your PATH.
Then open a new terminal (or `rehash` in zsh / `hash -r` in bash). Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
## Update / uninstall ## Update / uninstall
+8 -11
View File
@@ -19,36 +19,33 @@ Run:
```bash ```bash
node -v node -v
npm -v npm -v
npm prefix -g npm bin -g
echo "$PATH" 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 ## Fix: put npm’s global bin dir on PATH
1) Find your global npm prefix: 1) Find your global bin directory:
```bash ```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` - zsh: `~/.zshrc`
- bash: `~/.bashrc` - bash: `~/.bashrc`
Example (replace the path with your `npm prefix -g` output): Example (replace the path with your `npm bin -g` output):
```bash ```bash
# macOS / Linux export PATH="/path/from/npm/bin/-g:$PATH"
export PATH="/path/from/npm/prefix/bin:$PATH"
``` ```
Then open a **new terminal** (or run `rehash` in zsh / `hash -r` in bash). Then open a **new terminal** (or run `rehash` in zsh / `hash -r` in bash).
On Windows, add the output of `npm prefix -g` to your PATH.
## Fix: avoid `sudo npm install -g` / permission errors (Linux) ## Fix: avoid `sudo npm install -g` / permission errors (Linux)
If `npm install -g ...` fails with `EACCES`, switch npm’s global prefix to a user-writable directory: 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: You’ll have the fewest surprises if Node/npm are installed in a way that:
- keeps Node updated (22+) - 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: 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) - [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
- [Security and access control](#security-and-access-control) - [Security and access control](#security-and-access-control)
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms) - [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
- [Is prompt injection only a concern for public bots?](#is-prompt-injection-only-a-concern-for-public-bots)
- [Can I use cheaper models for personal assistant tasks?](#can-i-use-cheaper-models-for-personal-assistant-tasks)
- [I ran `/start` in Telegram but didn’t get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code) - [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) - [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) - [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. 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 ### 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 Pairing codes are sent **only** when an unknown sender messages the bot and
+7 -35
View File
@@ -29,22 +29,13 @@ function resolveExecutablePath(lobsterPathRaw: string | undefined) {
return lobsterPath; return lobsterPath;
} }
function isWindowsSpawnEINVAL(err: unknown) { async function runLobsterSubprocess(params: {
if (!err || typeof err !== "object") return false; execPath: string;
const code = (err as { code?: unknown }).code; argv: string[];
return code === "EINVAL"; cwd: string;
} timeoutMs: number;
maxStdoutBytes: number;
async function runLobsterSubprocessOnce( }) {
params: {
execPath: string;
argv: string[];
cwd: string;
timeoutMs: number;
maxStdoutBytes: number;
},
useShell: boolean,
) {
const { execPath, argv, cwd } = params; const { execPath, argv, cwd } = params;
const timeoutMs = Math.max(200, params.timeoutMs); const timeoutMs = Math.max(200, params.timeoutMs);
const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes); const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
@@ -60,8 +51,6 @@ async function runLobsterSubprocessOnce(
cwd, cwd,
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env, env,
shell: useShell,
windowsHide: useShell ? true : undefined,
}); });
let stdout = ""; 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 { function parseEnvelope(stdout: string): LobsterEnvelope {
let parsed: unknown; let parsed: unknown;
try { try {
-2
View File
@@ -301,8 +301,6 @@ importers:
extensions/imessage: {} extensions/imessage: {}
extensions/lobster: {}
extensions/matrix: extensions/matrix:
dependencies: dependencies:
'@matrix-org/matrix-sdk-crypto-nodejs': '@matrix-org/matrix-sdk-crypto-nodejs':
+32
View File
@@ -7,6 +7,7 @@ TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}"
DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}" DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}"
SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}" SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}"
ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX) ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX)
ENT_TMP_APP=$(mktemp -t clawdbot-entitlements-app.XXXXXX)
ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX) ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX)
ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX) ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX)
@@ -20,6 +21,7 @@ Env:
CODESIGN_TIMESTAMP=auto|on|off CODESIGN_TIMESTAMP=auto|on|off
DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround
SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit
ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1
HELP HELP
exit 0 exit 0
fi fi
@@ -180,13 +182,43 @@ cat > "$ENT_TMP_RUNTIME" <<'PLIST'
</plist> </plist>
PLIST PLIST
cat > "$ENT_TMP_APP" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.usernotifications.time-sensitive</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.personal-information.location</key>
<true/>
</dict>
</plist>
PLIST
if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP_BASE" >/dev/null 2>&1 || \ /usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP_BASE" >/dev/null 2>&1 || \
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP_BASE" /usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP_BASE"
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP" >/dev/null 2>&1 || \
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP"
echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)." echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)."
fi fi
# The time-sensitive entitlement is restricted and requires explicit enablement
# (and typically a matching provisioning profile). It is *not* safe to enable
# unconditionally for local debug packaging since AMFI will refuse to launch.
APP_ENTITLEMENTS="$ENT_TMP_APP_BASE" APP_ENTITLEMENTS="$ENT_TMP_APP_BASE"
if [[ "${ENABLE_TIME_SENSITIVE_NOTIFICATIONS:-}" == "1" ]]; then
APP_ENTITLEMENTS="$ENT_TMP_APP"
else
echo "Note: Time Sensitive Notifications entitlement disabled."
echo " To force it: ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1 scripts/codesign-mac-app.sh <app>"
fi
# clear extended attributes to avoid stale signatures # clear extended attributes to avoid stale signatures
xattr -cr "$APP_BUNDLE" 2>/dev/null || true xattr -cr "$APP_BUNDLE" 2>/dev/null || true
-1
View File
@@ -11,7 +11,6 @@ COPY src ./src
COPY scripts ./scripts COPY scripts ./scripts
COPY docs ./docs COPY docs ./docs
COPY skills ./skills COPY skills ./skills
COPY extensions/memory-core ./extensions/memory-core
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
RUN pnpm build RUN pnpm build
+37 -85
View File
@@ -51,27 +51,14 @@ TRASH
start_s="$(date +%s)" start_s="$(date +%s)"
while true; do while true; do
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
if grep -a -F -q "$needle" "$WIZARD_LOG_PATH"; then if NEEDLE="$needle_compact" node --input-type=module -e "
return 0
fi
if NEEDLE=\"$needle_compact\" node --input-type=module -e "
import fs from \"node:fs\"; import fs from \"node:fs\";
const file = process.env.WIZARD_LOG_PATH; const file = process.env.WIZARD_LOG_PATH;
const needle = process.env.NEEDLE ?? \"\"; const needle = process.env.NEEDLE ?? \"\";
let text = \"\"; let text = \"\";
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); } try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
if (text.length > 20000) text = text.slice(-20000); text = text.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\").replace(/\\s+/g, \"\");
const sanitize = (value) => value.replace(/[\\x00-\\x1f\\x7f]/g, \"\"); process.exit(text.includes(needle) ? 0 : 1);
const haystack = sanitize(text);
const safeNeedle = sanitize(needle);
const needsEscape = new Set([\"\\\\\", \"^\", \"$\", \".\", \"*\", \"+\", \"?\", \"(\", \")\", \"[\", \"]\", \"{\", \"}\", \"|\"]);
let escaped = \"\";
for (const ch of safeNeedle) {
escaped += needsEscape.has(ch) ? \"\\\\\" + ch : ch;
}
const pattern = escaped.split(\"\").join(\".*\");
const re = new RegExp(pattern, \"i\");
process.exit(re.test(haystack) ? 0 : 1);
"; then "; then
return 0 return 0
fi fi
@@ -93,35 +80,13 @@ TRASH
} }
wait_for_gateway() { wait_for_gateway() {
for _ in $(seq 1 20); do for _ in $(seq 1 10); do
if node --input-type=module -e " if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway-e2e.log; then
import net from 'node:net';
const socket = net.createConnection({ host: '127.0.0.1', port: 18789 });
const timeout = setTimeout(() => {
socket.destroy();
process.exit(1);
}, 500);
socket.on('connect', () => {
clearTimeout(timeout);
socket.end();
process.exit(0);
});
socket.on('error', () => {
clearTimeout(timeout);
process.exit(1);
});
" >/dev/null 2>&1; then
return 0 return 0
fi fi
if [ -f /tmp/gateway-e2e.log ] && grep -E -q "listening on ws://[^ ]+:18789" /tmp/gateway-e2e.log; then
if [ -n "${GATEWAY_PID:-}" ] && kill -0 "$GATEWAY_PID" 2>/dev/null; then
return 0
fi
fi
sleep 1 sleep 1
done done
echo "Gateway failed to start" cat /tmp/gateway-e2e.log
cat /tmp/gateway-e2e.log || true
return 1 return 1
} }
@@ -151,7 +116,7 @@ TRASH
WIZARD_LOG_PATH="$log_path" WIZARD_LOG_PATH="$log_path"
export WIZARD_LOG_PATH export WIZARD_LOG_PATH
# Run under script to keep an interactive TTY for clack prompts. # Run under script to keep an interactive TTY for clack prompts.
script -q -f -c "$command" "$log_path" < "$input_fifo" & script -q -c "$command" "$log_path" < "$input_fifo" &
wizard_pid=$! wizard_pid=$!
exec 3> "$input_fifo" exec 3> "$input_fifo"
@@ -164,18 +129,8 @@ TRASH
"$send_fn" "$send_fn"
if ! wait "$wizard_pid"; then
wizard_status=$?
exec 3>&-
rm -f "$input_fifo"
stop_gateway "$gw_pid"
echo "Wizard exited with status $wizard_status"
if [ -f "$log_path" ]; then
tail -n 160 "$log_path" || true
fi
exit "$wizard_status"
fi
exec 3>&- exec 3>&-
wait "$wizard_pid"
rm -f "$input_fifo" rm -f "$input_fifo"
stop_gateway "$gw_pid" stop_gateway "$gw_pid"
if [ -n "$validate_fn" ]; then if [ -n "$validate_fn" ]; then
@@ -221,18 +176,14 @@ TRASH
send_local_basic() { send_local_basic() {
# Risk acknowledgement (default is "No"). # Risk acknowledgement (default is "No").
wait_for_log "Continue?" 60
send $'"'"'y\r'"'"' 0.6 send $'"'"'y\r'"'"' 0.6
# Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI. # Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI.
if wait_for_log "Where will the Gateway run?" 20; then send $'"'"'\r'"'"' 0.5
send $'"'"'\r'"'"' 0.5
fi
select_skip_hooks select_skip_hooks
} }
send_reset_config_only() { send_reset_config_only() {
# Risk acknowledgement (default is "No"). # Risk acknowledgement (default is "No").
wait_for_log "Continue?" 40 || true
send $'"'"'y\r'"'"' 0.8 send $'"'"'y\r'"'"' 0.8
# Select reset flow for existing config. # Select reset flow for existing config.
wait_for_log "Config handling" 40 || true wait_for_log "Config handling" 40 || true
@@ -260,27 +211,19 @@ TRASH
send_skills_flow() { send_skills_flow() {
# Select skills section and skip optional installs. # 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 # Configure skills now? -> No
send $'"'"'n\r'"'"' 1.5 wait_for_log "Configure skills now?" 40 || true
send "" 1.0 send $'"'"'n\r'"'"' 0.8
wait_for_log "Configure complete." 40 || true
send "" 0.8
} }
run_case_local_basic() { run_case_local_basic() {
local home_dir local home_dir
home_dir="$(make_home local-basic)" home_dir="$(make_home local-basic)"
export HOME="$home_dir" run_wizard local-basic "$home_dir" send_local_basic validate_local_basic_log
mkdir -p "$HOME"
node dist/index.js onboard \
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--skip-channels \
--skip-skills \
--skip-daemon \
--skip-ui \
--skip-health
# Assert config + workspace scaffolding. # Assert config + workspace scaffolding.
workspace_dir="$HOME/clawd" workspace_dir="$HOME/clawd"
@@ -340,6 +283,25 @@ if (errors.length > 0) {
} }
NODE NODE
node dist/index.js gateway --port 18789 --bind loopback > /tmp/gateway.log 2>&1 &
GW_PID=$!
# Gate on gateway readiness, then run health.
for _ in $(seq 1 10); do
if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then
break
fi
sleep 1
done
if ! grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then
cat /tmp/gateway.log
exit 1
fi
node dist/index.js health --timeout 2000 || (cat /tmp/gateway.log && exit 1)
kill "$GW_PID"
wait "$GW_PID" || true
} }
run_case_remote_non_interactive() { run_case_remote_non_interactive() {
@@ -393,7 +355,7 @@ NODE
# Seed a remote config to exercise reset path. # Seed a remote config to exercise reset path.
cat > "$HOME/.clawdbot/clawdbot.json" <<'"'"'JSON'"'"' cat > "$HOME/.clawdbot/clawdbot.json" <<'"'"'JSON'"'"'
{ {
"agents": { "defaults": { "workspace": "/root/old" } }, "agent": { "workspace": "/root/old" },
"gateway": { "gateway": {
"mode": "remote", "mode": "remote",
"remote": { "url": "ws://old.example:18789", "token": "old-token" } "remote": { "url": "ws://old.example:18789", "token": "old-token" }
@@ -401,17 +363,7 @@ NODE
} }
JSON JSON
node dist/index.js onboard \ run_wizard reset-config "$home_dir" send_reset_config_only
--non-interactive \
--accept-risk \
--flow quickstart \
--mode local \
--reset \
--skip-channels \
--skip-skills \
--skip-daemon \
--skip-ui \
--skip-health
config_path="$HOME/.clawdbot/clawdbot.json" config_path="$HOME/.clawdbot/clawdbot.json"
assert_file "$config_path" assert_file "$config_path"
-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"); return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
} }
function isInstructionsRequiredError(raw: string): boolean {
return /instructions are required/i.test(raw);
}
function toInt(value: string | undefined, fallback: number): number { function toInt(value: string | undefined, fallback: number): number {
const trimmed = value?.trim(); const trimmed = value?.trim();
if (!trimmed) return fallback; if (!trimmed) return fallback;
@@ -447,15 +443,6 @@ describeLive("live models (profile keys)", () => {
logProgress(`${progressLabel}: skip (chatgpt usage limit)`); logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
break; break;
} }
if (
allowNotFoundSkip &&
model.provider === "openai-codex" &&
isInstructionsRequiredError(message)
) {
skipped.push({ model: id, reason: message });
logProgress(`${progressLabel}: skip (instructions required)`);
break;
}
logProgress(`${progressLabel}: failed`); logProgress(`${progressLabel}: failed`);
failures.push({ model: id, error: message }); failures.push({ model: id, error: message });
break; break;
-8
View File
@@ -180,11 +180,3 @@ export function decorateClawdProfile(
// ignore // ignore
} }
} }
export function ensureProfileCleanExit(userDataDir: string) {
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
const prefs = safeReadJson(preferencesPath) ?? {};
setDeep(prefs, ["exit_type"], "Normal");
setDeep(prefs, ["exited_cleanly"], true);
safeWriteJson(preferencesPath, prefs);
}
-13
View File
@@ -7,7 +7,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
decorateClawdProfile, decorateClawdProfile,
ensureProfileCleanExit,
findChromeExecutableMac, findChromeExecutableMac,
findChromeExecutableWindows, findChromeExecutableWindows,
isChromeReachable, 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 () => { it("is idempotent when rerun on an existing profile", async () => {
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-")); const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
try { try {
+2 -18
View File
@@ -13,11 +13,7 @@ import {
type BrowserExecutable, type BrowserExecutable,
resolveBrowserExecutableForPlatform, resolveBrowserExecutableForPlatform,
} from "./chrome.executables.js"; } from "./chrome.executables.js";
import { import { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js";
decorateClawdProfile,
ensureProfileCleanExit,
isProfileDecorated,
} from "./chrome.profile-decoration.js";
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js"; import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_PROFILE_NAME } from "./constants.js"; import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_PROFILE_NAME } from "./constants.js";
@@ -30,11 +26,7 @@ export {
findChromeExecutableWindows, findChromeExecutableWindows,
resolveBrowserExecutableForPlatform, resolveBrowserExecutableForPlatform,
} from "./chrome.executables.js"; } from "./chrome.executables.js";
export { export { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js";
decorateClawdProfile,
ensureProfileCleanExit,
isProfileDecorated,
} from "./chrome.profile-decoration.js";
function exists(filePath: string) { function exists(filePath: string) {
try { try {
@@ -186,8 +178,6 @@ export async function launchClawdChrome(
"--disable-background-networking", "--disable-background-networking",
"--disable-component-update", "--disable-component-update",
"--disable-features=Translate,MediaRouter", "--disable-features=Translate,MediaRouter",
"--disable-session-crashed-bubble",
"--hide-crash-restore-bubble",
"--password-store=basic", "--password-store=basic",
]; ];
@@ -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(); const proc = spawnOnce();
// Wait for CDP to come up. // Wait for CDP to come up.
const readyDeadline = Date.now() + 15_000; const readyDeadline = Date.now() + 15_000;
+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", "clean check": "Working directory is clean",
"upstream check": "Upstream branch exists", "upstream check": "Upstream branch exists",
"git fetch": "Fetching latest changes", "git fetch": "Fetching latest changes",
"git rebase": "Rebasing onto target commit", "git rebase": "Rebasing onto upstream",
"git rev-parse @{upstream}": "Resolving upstream commit",
"git rev-list": "Enumerating candidate commits",
"git clone": "Cloning git checkout", "git clone": "Cloning git checkout",
"preflight worktree": "Preparing preflight worktree",
"preflight cleanup": "Cleaning preflight worktree",
"deps install": "Installing dependencies", "deps install": "Installing dependencies",
build: "Building", build: "Building",
"ui:build": "Building UI", "ui:build": "Building UI",
@@ -23,33 +23,21 @@ describe("legacy config detection", () => {
expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention"); expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention");
} }
}); });
it("migrates routing.allowFrom to channels.whatsapp.allowFrom when whatsapp configured", async () => { it("migrates routing.allowFrom to channels.whatsapp.allowFrom", async () => {
vi.resetModules(); vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js"); const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({ const res = migrateLegacyConfig({
routing: { allowFrom: ["+15555550123"] }, routing: { allowFrom: ["+15555550123"] },
channels: { whatsapp: {} },
}); });
expect(res.changes).toContain("Moved routing.allowFrom → channels.whatsapp.allowFrom."); expect(res.changes).toContain("Moved routing.allowFrom → channels.whatsapp.allowFrom.");
expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]); expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
expect(res.config?.routing?.allowFrom).toBeUndefined(); expect(res.config?.routing?.allowFrom).toBeUndefined();
}); });
it("drops routing.allowFrom when whatsapp missing", 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: { allowFrom: ["+15555550123"] },
});
expect(res.changes).toContain("Removed routing.allowFrom (channels.whatsapp not configured).");
expect(res.config?.channels?.whatsapp).toBeUndefined();
expect(res.config?.routing?.allowFrom).toBeUndefined();
});
it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups when whatsapp configured", async () => {
vi.resetModules(); vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js"); const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({ const res = migrateLegacyConfig({
routing: { groupChat: { requireMention: false } }, routing: { groupChat: { requireMention: false } },
channels: { whatsapp: {} },
}); });
expect(res.changes).toContain( expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.', 'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
@@ -65,26 +53,6 @@ describe("legacy config detection", () => {
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false); expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined(); expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
}); });
it("migrates routing.groupChat.requireMention to telegram/imessage when whatsapp missing", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { groupChat: { requireMention: false } },
});
expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.',
);
expect(res.changes).toContain(
'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.',
);
expect(res.changes).not.toContain(
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
);
expect(res.config?.channels?.whatsapp).toBeUndefined();
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
});
it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => { it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
vi.resetModules(); vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js"); const { migrateLegacyConfig } = await import("./config.js");
+9 -18
View File
@@ -156,16 +156,11 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
const allowFrom = (routing as Record<string, unknown>).allowFrom; const allowFrom = (routing as Record<string, unknown>).allowFrom;
if (allowFrom === undefined) return; if (allowFrom === undefined) return;
const channels = getRecord(raw.channels); const channels = ensureRecord(raw, "channels");
const whatsapp = channels ? getRecord(channels.whatsapp) : null; const whatsapp =
if (!whatsapp) { channels.whatsapp && typeof channels.whatsapp === "object"
delete (routing as Record<string, unknown>).allowFrom; ? (channels.whatsapp as Record<string, unknown>)
if (Object.keys(routing as Record<string, unknown>).length === 0) { : {};
delete raw.routing;
}
changes.push("Removed routing.allowFrom (channels.whatsapp not configured).");
return;
}
if (whatsapp.allowFrom === undefined) { if (whatsapp.allowFrom === undefined) {
whatsapp.allowFrom = allowFrom; whatsapp.allowFrom = allowFrom;
@@ -178,8 +173,8 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
if (Object.keys(routing as Record<string, unknown>).length === 0) { if (Object.keys(routing as Record<string, unknown>).length === 0) {
delete raw.routing; delete raw.routing;
} }
channels!.whatsapp = whatsapp; channels.whatsapp = whatsapp;
raw.channels = channels!; raw.channels = channels;
}, },
}, },
{ {
@@ -198,11 +193,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
if (requireMention === undefined) return; if (requireMention === undefined) return;
const channels = ensureRecord(raw, "channels"); const channels = ensureRecord(raw, "channels");
const applyTo = ( const applyTo = (key: "whatsapp" | "telegram" | "imessage") => {
key: "whatsapp" | "telegram" | "imessage",
options?: { requireExisting?: boolean },
) => {
if (options?.requireExisting && !isRecord(channels[key])) return;
const section = const section =
channels[key] && typeof channels[key] === "object" channels[key] && typeof channels[key] === "object"
? (channels[key] as Record<string, unknown>) ? (channels[key] as Record<string, unknown>)
@@ -231,7 +222,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
} }
}; };
applyTo("whatsapp", { requireExisting: true }); applyTo("whatsapp");
applyTo("telegram"); applyTo("telegram");
applyTo("imessage"); applyTo("imessage");
@@ -113,30 +113,6 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in"); return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
} }
function isInstructionsRequiredError(error: string): boolean {
return /instructions are required/i.test(error);
}
function isOpenAIReasoningSequenceError(error: string): boolean {
const msg = error.toLowerCase();
return msg.includes("required following item") && msg.includes("reasoning");
}
function isToolNonceRefusal(error: string): boolean {
const msg = error.toLowerCase();
if (!msg.includes("nonce")) return false;
return (
msg.includes("token") ||
msg.includes("secret") ||
msg.includes("local file") ||
msg.includes("disclose") ||
msg.includes("can't help") ||
msg.includes("can’t help") ||
msg.includes("can't comply") ||
msg.includes("can’t comply")
);
}
function isMissingProfileError(error: string): boolean { function isMissingProfileError(error: string): boolean {
return /no credentials found for profile/i.test(error); return /no credentials found for profile/i.test(error);
} }
@@ -880,27 +856,6 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
logProgress(`${progressLabel}: skip (chatgpt usage limit)`); logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
break; break;
} }
if (model.provider === "openai-codex" && isInstructionsRequiredError(message)) {
skippedCount += 1;
logProgress(`${progressLabel}: skip (instructions required)`);
break;
}
if (
(model.provider === "openai" || model.provider === "openai-codex") &&
isOpenAIReasoningSequenceError(message)
) {
skippedCount += 1;
logProgress(`${progressLabel}: skip (openai reasoning sequence error)`);
break;
}
if (
(model.provider === "openai" || model.provider === "openai-codex") &&
isToolNonceRefusal(message)
) {
skippedCount += 1;
logProgress(`${progressLabel}: skip (tool probe refusal)`);
break;
}
if (isMissingProfileError(message)) { if (isMissingProfileError(message)) {
skippedCount += 1; skippedCount += 1;
logProgress(`${progressLabel}: skip (missing auth profile)`); logProgress(`${progressLabel}: skip (missing auth profile)`);
+4 -1
View File
@@ -253,7 +253,10 @@ describe("exec approvals default agent migration", () => {
}; };
const resolved = resolveExecApprovalsFromFile({ file }); const resolved = resolveExecApprovalsFromFile({ file });
expect(resolved.agent.ask).toBe("always"); expect(resolved.agent.ask).toBe("always");
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/main", "/bin/legacy"]); expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual([
"/bin/main",
"/bin/legacy",
]);
expect(resolved.file.agents?.default).toBeUndefined(); expect(resolved.file.agents?.default).toBeUndefined();
}); });
}); });
+1 -3
View File
@@ -74,9 +74,7 @@ describe("runGatewayUpdate", () => {
stdout: "origin/main", stdout: "origin/main",
}, },
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" }, [`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
[`git -C ${tempDir} rev-parse @{upstream}`]: { stdout: "upstream123" }, [`git -C ${tempDir} rebase @{upstream}`]: { code: 1, stderr: "conflict" },
[`git -C ${tempDir} rev-list --max-count=10 upstream123`]: { stdout: "upstream123\n" },
[`git -C ${tempDir} rebase upstream123`]: { code: 1, stderr: "conflict" },
[`git -C ${tempDir} rebase --abort`]: { stdout: "" }, [`git -C ${tempDir} rebase --abort`]: { stdout: "" },
}); });
+1 -147
View File
@@ -1,5 +1,4 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js"; import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
@@ -64,7 +63,6 @@ type UpdateRunnerOptions = {
const DEFAULT_TIMEOUT_MS = 20 * 60_000; const DEFAULT_TIMEOUT_MS = 20 * 60_000;
const MAX_LOG_CHARS = 8000; const MAX_LOG_CHARS = 8000;
const PREFLIGHT_MAX_COMMITS = 10;
const START_DIRS = ["cwd", "argv1", "process"]; const START_DIRS = ["cwd", "argv1", "process"];
function normalizeDir(value?: string | null) { function normalizeDir(value?: string | null) {
@@ -422,152 +420,8 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
); );
steps.push(fetchStep); steps.push(fetchStep);
const upstreamShaStep = await runStep(
step(
"git rev-parse @{upstream}",
["git", "-C", gitRoot, "rev-parse", "@{upstream}"],
gitRoot,
),
);
steps.push(upstreamShaStep);
const upstreamSha = upstreamShaStep.stdoutTail?.trim();
if (!upstreamShaStep.stdoutTail || !upstreamSha) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "no-upstream-sha",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const revListStep = await runStep(
step(
"git rev-list",
["git", "-C", gitRoot, "rev-list", `--max-count=${PREFLIGHT_MAX_COMMITS}`, upstreamSha],
gitRoot,
),
);
steps.push(revListStep);
if (revListStep.exitCode !== 0) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "preflight-revlist-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const candidates = (revListStep.stdoutTail ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (candidates.length === 0) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "preflight-no-candidates",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const manager = await detectPackageManager(gitRoot);
const preflightRoot = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-preflight-"));
const worktreeDir = path.join(preflightRoot, "worktree");
const worktreeStep = await runStep(
step(
"preflight worktree",
["git", "-C", gitRoot, "worktree", "add", "--detach", worktreeDir, upstreamSha],
gitRoot,
),
);
steps.push(worktreeStep);
if (worktreeStep.exitCode !== 0) {
await fs.rm(preflightRoot, { recursive: true, force: true }).catch(() => {});
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "preflight-worktree-failed",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
let selectedSha: string | null = null;
try {
for (const sha of candidates) {
const shortSha = sha.slice(0, 8);
const checkoutStep = await runStep(
step(
`preflight checkout (${shortSha})`,
["git", "-C", worktreeDir, "checkout", "--detach", sha],
worktreeDir,
),
);
steps.push(checkoutStep);
if (checkoutStep.exitCode !== 0) continue;
const depsStep = await runStep(
step(`preflight deps install (${shortSha})`, managerInstallArgs(manager), worktreeDir),
);
steps.push(depsStep);
if (depsStep.exitCode !== 0) continue;
const lintStep = await runStep(
step(`preflight lint (${shortSha})`, managerScriptArgs(manager, "lint"), worktreeDir),
);
steps.push(lintStep);
if (lintStep.exitCode !== 0) continue;
const buildStep = await runStep(
step(`preflight build (${shortSha})`, managerScriptArgs(manager, "build"), worktreeDir),
);
steps.push(buildStep);
if (buildStep.exitCode !== 0) continue;
selectedSha = sha;
break;
}
} finally {
const removeStep = await runStep(
step(
"preflight cleanup",
["git", "-C", gitRoot, "worktree", "remove", "--force", worktreeDir],
gitRoot,
),
);
steps.push(removeStep);
await runCommand(["git", "-C", gitRoot, "worktree", "prune"], {
cwd: gitRoot,
timeoutMs,
}).catch(() => null);
await fs.rm(preflightRoot, { recursive: true, force: true }).catch(() => {});
}
if (!selectedSha) {
return {
status: "error",
mode: "git",
root: gitRoot,
reason: "preflight-no-good-commit",
before: { sha: beforeSha, version: beforeVersion },
steps,
durationMs: Date.now() - startedAt,
};
}
const rebaseStep = await runStep( const rebaseStep = await runStep(
step("git rebase", ["git", "-C", gitRoot, "rebase", selectedSha], gitRoot), step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot),
); );
steps.push(rebaseStep); steps.push(rebaseStep);
if (rebaseStep.exitCode !== 0) { 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 { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
import { createTelegramBot } from "./bot.js"; import { createTelegramBot } from "./bot.js";
@@ -89,7 +89,6 @@ vi.mock("grammy", () => ({
const sequentializeMiddleware = vi.fn(); const sequentializeMiddleware = vi.fn();
const sequentializeSpy = vi.fn(() => sequentializeMiddleware); const sequentializeSpy = vi.fn(() => sequentializeMiddleware);
let _sequentializeKey: ((ctx: unknown) => string) | undefined; let _sequentializeKey: ((ctx: unknown) => string) | undefined;
let originalTz: string | undefined;
vi.mock("@grammyjs/runner", () => ({ vi.mock("@grammyjs/runner", () => ({
sequentialize: (keyFn: (ctx: unknown) => string) => { sequentialize: (keyFn: (ctx: unknown) => string) => {
_sequentializeKey = keyFn; _sequentializeKey = keyFn;
@@ -121,8 +120,6 @@ const getOnHandler = (event: string) => {
describe("createTelegramBot", () => { describe("createTelegramBot", () => {
beforeEach(() => { beforeEach(() => {
originalTz = process.env.TZ;
process.env.TZ = "UTC";
resetInboundDedupe(); resetInboundDedupe();
loadConfig.mockReturnValue({ loadConfig.mockReturnValue({
channels: { channels: {
@@ -141,92 +138,104 @@ describe("createTelegramBot", () => {
_sequentializeKey = undefined; _sequentializeKey = undefined;
}); });
afterEach(() => {
process.env.TZ = originalTz;
});
// groupPolicy tests // groupPolicy tests
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
onSpy.mockReset(); const originalTz = process.env.TZ;
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; process.env.TZ = "UTC";
replySpy.mockReset();
loadConfig.mockReturnValue({ try {
identity: { name: "Bert" }, onSpy.mockReset();
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
channels: { replySpy.mockReset();
telegram: {
groupPolicy: "open", loadConfig.mockReturnValue({
groups: { "*": { requireMention: true } }, identity: { name: "Bert" },
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
}, },
}, });
});
createTelegramBot({ token: "tok" }); createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({ await handler({
message: { message: {
chat: { id: 7, type: "group", title: "Test Group" }, chat: { id: 7, type: "group", title: "Test Group" },
text: "bert: introduce yourself", text: "bert: introduce yourself",
date: 1736380800, date: 1736380800,
message_id: 1, message_id: 1,
from: { id: 9, first_name: "Ada" }, from: { id: 9, first_name: "Ada" },
}, },
me: { username: "clawdbot_bot" }, me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }), getFile: async () => ({ download: async () => new Uint8Array() }),
}); });
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expect(payload.WasMentioned).toBe(true); expect(payload.WasMentioned).toBe(true);
expect(payload.SenderName).toBe("Ada"); expect(payload.SenderName).toBe("Ada");
expect(payload.SenderId).toBe("9"); expect(payload.SenderId).toBe("9");
expect(payload.Body).toMatch( expect(payload.Body).toMatch(
/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/, /^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
); );
} finally {
process.env.TZ = originalTz;
}
}); });
it("keeps group envelope headers stable (sender identity is separate)", async () => { it("keeps group envelope headers stable (sender identity is separate)", async () => {
onSpy.mockReset(); const originalTz = process.env.TZ;
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>; process.env.TZ = "UTC";
replySpy.mockReset();
loadConfig.mockReturnValue({ try {
channels: { onSpy.mockReset();
telegram: { const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
groupPolicy: "open", replySpy.mockReset();
groups: { "*": { requireMention: false } },
loadConfig.mockReturnValue({
channels: {
telegram: {
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
}, },
}, });
});
createTelegramBot({ token: "tok" }); createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>; const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({ await handler({
message: { message: {
chat: { id: 42, type: "group", title: "Ops" }, chat: { id: 42, type: "group", title: "Ops" },
text: "hello", text: "hello",
date: 1736380800, date: 1736380800,
message_id: 2, message_id: 2,
from: { from: {
id: 99, id: 99,
first_name: "Ada", first_name: "Ada",
last_name: "Lovelace", last_name: "Lovelace",
username: "ada", username: "ada",
},
}, },
}, me: { username: "clawdbot_bot" },
me: { username: "clawdbot_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }),
getFile: async () => ({ download: async () => new Uint8Array() }), });
});
expect(replySpy).toHaveBeenCalledTimes(1); expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0]; const payload = replySpy.mock.calls[0][0];
expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderName).toBe("Ada Lovelace");
expect(payload.SenderId).toBe("99"); expect(payload.SenderId).toBe("99");
expect(payload.SenderUsername).toBe("ada"); expect(payload.SenderUsername).toBe("ada");
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-09 00:00 [^\]]+\]/,
);
} finally {
process.env.TZ = originalTz;
}
}); });
it("reacts to mention-gated group messages when ackReaction is enabled", async () => { it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
onSpy.mockReset(); onSpy.mockReset();
+8 -6
View File
@@ -553,11 +553,12 @@ describe("createTelegramBot", () => {
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => { it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
const originalTz = process.env.TZ; const originalTz = process.env.TZ;
process.env.TZ = "UTC"; process.env.TZ = "UTC";
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
try { try {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({ loadConfig.mockReturnValue({
identity: { name: "Bert" }, identity: { name: "Bert" },
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
@@ -601,11 +602,12 @@ describe("createTelegramBot", () => {
it("includes sender identity in group envelope headers", async () => { it("includes sender identity in group envelope headers", async () => {
const originalTz = process.env.TZ; const originalTz = process.env.TZ;
process.env.TZ = "UTC"; process.env.TZ = "UTC";
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
try { try {
onSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
replySpy.mockReset();
loadConfig.mockReturnValue({ loadConfig.mockReturnValue({
channels: { channels: {
telegram: { telegram: {
+5 -1
View File
@@ -1,7 +1,7 @@
import { render } from "lit"; import { render } from "lit";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { analyzeConfigSchema, renderConfigForm } from "./views/config-form"; import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./views/config-form";
const rootSchema = { const rootSchema = {
type: "object", type: "object",
@@ -40,6 +40,10 @@ const rootSchema = {
}; };
describe("config form renderer", () => { describe("config form renderer", () => {
it("exposes section metadata", () => {
expect(SECTION_META.env.label).toBe("Environment Variables");
});
it("renders inputs and patches values", () => { it("renders inputs and patches values", () => {
const onPatch = vi.fn(); const onPatch = vi.fn();
const container = document.createElement("div"); const container = document.createElement("div");