Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c163c71b5 | ||
|
|
277881b52f |
No files matched your search
+1
-3
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Generated
-2
@@ -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':
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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 @@
|
|||||||
0ae29522de4c48c6b6407290be18b94d7244d4e0036738abd19d93148f2c8cd4
|
27d5aed982d9f110b44e85254877597e49efae61141de480b4e9f254c04131ce
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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)`);
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
+83
-74
@@ -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();
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in new issue
Block a user