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