diff --git a/.gitignore b/.gitignore index 85b83cb81..4e1754705 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ apps/ios/*.dSYM.zip # provisioning profiles (local) apps/ios/*.mobileprovision .env + +# Local untracked files +.local/ diff --git a/AGENTS.md b/AGENTS.md index 021a8a9c4..e1765e93b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,7 @@ - Add brief code comments for tricky or non-obvious logic. - Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`. - Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability. +- Naming: use **Clawdbot** for product/app/docs headings; use `clawdbot` for CLI command, package/binary, paths, and config keys. ## Testing Guidelines - Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements). @@ -63,6 +64,7 @@ ## Agent-Specific Notes - Vocabulary: "makeup" = "mac app". +- When answering questions, respond with high-confidence answers only: verify in code; do not guess. - Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.** - macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`. - If shared guardrails are available locally, review them; otherwise follow this repo's guidance. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a3fe94cf..ae65b97fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,13 @@ ## Unreleased +- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles. +- Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence. - Agent: enable adaptive context pruning by default for tool-result trimming. - Doctor: check config/state permissions and offer to tighten them. — thanks @steipete - Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete - Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c +- Doctor: run legacy state migrations in non-interactive mode without prompts. - Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino - Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos - Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot @@ -15,15 +18,19 @@ - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 - Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1 - Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess +- Auto-reply: preserve spacing when stripping inline directives. (#539) — thanks @joshp123 - Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj - macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini). - Control UI: logs tab opens at the newest entries (bottom). - Control UI: add Docs link, remove chat composer divider, and add New session button. +- Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos - Telegram: retry long-polling conflicts with backoff to avoid fatal exits. - Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos - WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415) +- Pairing: replies now include sender ids for Discord/Slack/Signal/iMessage/WhatsApp; pairing list labels them explicitly. +- Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist - Agent system prompt: avoid automatic self-updates unless explicitly requested. - Onboarding: tighten QuickStart hint copy for configuring later. - Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic. @@ -32,6 +39,15 @@ - Daemon runtime: remove Bun from selection options. - CLI: restore hidden `gateway-daemon` alias for legacy launchd configs. - Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf +- Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings. +- Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints. +- Providers/Doctor: add last inbound/outbound activity timestamps in `providers status` and extend `--probe` with Discord channel permission + Telegram group membership audits. +- Docs: add provider troubleshooting index (`/providers/troubleshooting`) and link it from the main troubleshooting guide. +- Telegram: include the user id in DM pairing messages and label it clearly in `clawdbot pairing list --provider telegram`. +- Apps: refresh iOS/Android/macOS app icons for Clawdbot branding. (#521) — thanks @fishfisher +- Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj +- Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy +- TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne ## 2026.1.8 diff --git a/README.md b/README.md index 5f1d95cdd..c9f19ce2e 100644 --- a/README.md +++ b/README.md @@ -455,14 +455,15 @@ AI/vibe-coded PRs welcome! 🤖 Thanks to all clawtributors:
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
--node `
-- `clawdbot nodes canvas snapshot --node `
-
-### Canvas A2UI
-
-Canvas A2UI is hosted by the **Gateway canvas host** at:
+Default A2UI host URL:
```
http://:18793/__clawdbot__/a2ui/
```
-The macOS app simply renders that page in the Canvas panel. The agent can drive it with JSONL **server→client protocol messages** (one JSON object per line):
+### A2UI commands (v0.8)
-- `clawdbot nodes canvas a2ui push --jsonl --node `
-- `clawdbot nodes canvas a2ui reset --node `
+Canvas currently accepts **A2UI v0.8** server→client messages:
-`push` expects a JSONL file where **each line is a single JSON object** (parsed and forwarded to the in-page A2UI renderer).
+- `beginRendering`
+- `surfaceUpdate`
+- `dataModelUpdate`
+- `deleteSurface`
-Minimal example (v0.8):
+`createSurface` (v0.9) is not supported.
+
+CLI example:
```bash
-cat > /tmp/a2ui-v0.8.jsonl <<'EOF'
-{"surfaceUpdate":{"surfaceId":"main","components":[{"id":"root","component":{"Column":{"children":{"explicitList":["title","content"]}}}},{"id":"title","component":{"Text":{"text":{"literalString":"Canvas (A2UI v0.8)"},"usageHint":"h1"}}},{"id":"content","component":{"Text":{"text":{"literalString":"If you can read this, `nodes canvas a2ui push` works."},"usageHint":"body"}}}]}}
+cat > /tmp/a2ui-v0.8.jsonl <<'EOFA2'
+{"surfaceUpdate":{"surfaceId":"main","components":[{"id":"root","component":{"Column":{"children":{"explicitList":["title","content"]}}}},{"id":"title","component":{"Text":{"text":{"literalString":"Canvas (A2UI v0.8)"},"usageHint":"h1"}}},{"id":"content","component":{"Text":{"text":{"literalString":"If you can read this, A2UI push works."},"usageHint":"body"}}}]}}
{"beginRendering":{"surfaceId":"main","root":"root"}}
-EOF
+EOFA2
clawdbot nodes canvas a2ui push --jsonl /tmp/a2ui-v0.8.jsonl --node
```
-Notes:
-- This does **not** support the A2UI v0.9 examples using `createSurface`.
-- A2UI **fails** if the Gateway canvas host is unreachable (no local fallback).
-- `nodes canvas a2ui push` validates JSONL (line numbers on errors) and rejects v0.9 payloads.
-- Quick smoke: `clawdbot nodes canvas a2ui push --node --text "Hello from A2UI"` renders a minimal v0.8 view.
+Quick smoke:
-## Triggering agent runs from Canvas (deep links)
+```bash
+clawdbot nodes canvas a2ui push --node --text "Hello from A2UI"
+```
+
+## Triggering agent runs from Canvas
+
+Canvas can trigger new agent runs via deep links:
-Canvas can trigger new agent runs via the macOS app deep-link scheme:
- `clawdbot://agent?...`
-This is intentionally separate from `clawdbot-canvas://…` (which is only for serving local Canvas files into the `WKWebView`).
+Example (in JS):
-Suggested patterns:
-- HTML: render links/buttons that navigate to `clawdbot://agent?message=...`.
-- JS: set `window.location.href = 'clawdbot://agent?...'` for “run this now” actions.
+```js
+window.location.href = "clawdbot://agent?message=Review%20this%20design";
+```
-Implementation note (important):
-- In `WKWebView`, intercept `clawdbot://…` navigations in `WKNavigationDelegate` and forward them to the app, e.g. by calling `DeepLinkHandler.shared.handle(url:)` and returning `.cancel` for the navigation.
+The app prompts for confirmation unless a valid key is provided.
-Safety:
-- Deep links (`clawdbot://agent?...`) are always enabled.
-- Without a `key` query param, the app will prompt for confirmation before invoking the agent.
-- With a valid `key`, the run is unattended (no prompt). For Canvas-originated actions, the app injects an internal key automatically.
+## Security notes
-## Security / guardrails
-
-Recommended defaults:
-- `WKWebsiteDataStore.nonPersistent()` for Canvas (ephemeral).
-- Navigation policy: allow only `clawdbot-canvas://…` (and optionally `about:blank`); open `http/https` externally.
-- Scheme handler must prevent directory traversal: resolved file paths must stay under `//`.
-- Disable or tightly scope any JS bridge; prefer query-string/bootstrap config over `window.webkit.messageHandlers` for sensitive data.
-
-## Debugging
-
-Suggested debugging hooks:
-- Enable Web Inspector for Canvas builds (same approach as WebChat).
-- Log scheme requests + resolution decisions to OSLog (subsystem `com.clawdbot`, category `Canvas`).
-- Provide a “copy canvas dir” action in debug settings to quickly reveal the session directory in Finder.
+- Canvas scheme blocks directory traversal; files must live under the session root.
+- Local Canvas content uses a custom scheme (no loopback server required).
+- External `http(s)` URLs are allowed only when explicitly navigated.
diff --git a/docs/platforms/mac/child-process.md b/docs/platforms/mac/child-process.md
index 37f0f7363..12836f8c9 100644
--- a/docs/platforms/mac/child-process.md
+++ b/docs/platforms/mac/child-process.md
@@ -1,72 +1,56 @@
---
-summary: "Running the gateway as a child process of the macOS app and why"
+summary: "Gateway lifecycle on macOS (launchd + attach-only)"
read_when:
- Integrating the mac app with the gateway lifecycle
---
-# Clawdbot gateway as a child process of the macOS app
+# Gateway lifecycle on macOS
-Date: 2025-12-06 · Status: draft · Owner: steipete
+The macOS app **manages the Gateway via launchd** by default. This gives you
+reliable auto‑start at login and restart on crashes.
-Note (2025-12-19): the current implementation prefers a **launchd LaunchAgent** that runs the **bundled bun-compiled gateway**. This doc remains as an alternative mode for tighter coupling to the UI.
+Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
+If you need tighter coupling to the UI, use **Attach‑only** and run the Gateway
+manually in a terminal.
-## Goal
-Run the Node-based Clawdbot/clawdbot gateway as a direct child of the LSUIElement app (instead of a launchd agent) while keeping all TCC-sensitive work inside the Swift app/broker layer and wiring the existing “Clawdbot Active” toggle to start/stop the child.
+## Default behavior (launchd)
-## When to prefer the child-process mode
-- You want gateway lifetime strictly coupled to the menu-bar app (dies when the app quits) and controlled by the “Clawdbot Active” toggle without touching launchd.
-- You’re okay giving up login persistence/auto-restart that launchd provides, or you’ll add your own backoff loop.
-- You want simpler log capture and supervision inside the app (no external plist or user-visible LaunchAgent).
+- The app installs a per‑user LaunchAgent labeled `com.clawdbot.gateway`.
+- When Local mode is enabled, the app ensures the LaunchAgent is loaded and
+ starts the Gateway if needed.
+- Logs are written to the launchd gateway log path (visible in Debug Settings).
-## Tradeoffs vs. launchd
-- **Pros:** tighter coupling to UI state; simpler surface (no plist install/bootout); easier to stream stdout/stderr; fewer moving parts for beta users.
-- **Cons:** no built-in KeepAlive/login auto-start; app crash kills gateway; you must build your own restart/backoff; Activity Monitor will show both processes under the app; still need correct TCC handling (see below).
-- **TCC:** behaviorally, child processes often inherit the parent app’s “responsible process” for TCC, but this is *not a contract*. Continue to route all protected actions through the Swift app/broker so prompts stay tied to the signed app bundle.
+Common commands:
-## TCC guardrails (must keep)
-- Screen Recording, Accessibility, mic, and speech prompts must originate from the signed Swift app/broker. The Node child should never call these APIs directly; route through the app’s node commands (via Gateway `node.invoke`) for:
- - `system.notify`
- - `system.run` (including `needsScreenRecording`)
- - `screen.record` / `camera.*`
- - PeekabooBridge UI automation (`peekaboo …`)
-- Usage strings (`NSMicrophoneUsageDescription`, `NSSpeechRecognitionUsageDescription`, etc.) stay in the app target’s Info.plist; a bare Node binary has none and would fail.
-- If you ever embed Node that *must* touch TCC, wrap that call in a tiny signed helper target inside the app bundle and have Node exec that helper instead of calling the API directly.
+```bash
+launchctl kickstart -k gui/$UID/com.clawdbot.gateway
+launchctl bootout gui/$UID/com.clawdbot.gateway
+```
-## Process manager design (Swift Subprocess)
-- Add a small `GatewayProcessManager` (Swift) that owns:
- - `execution: Execution?` from `Swift Subprocess` to track the child.
- - `start(config)` called when “Clawdbot Active” flips ON:
- - binary: host Node running the bundled gateway under `Clawdbot.app/Contents/Resources/Gateway/`
- - args: current clawdbot entrypoint and flags
- - cwd/env: point to `~/.clawdbot` as today; inject the expanded PATH so Homebrew Node resolves under launchd
- - output: stream stdout/stderr to `/tmp/clawdbot-gateway.log` (cap buffer via Subprocess OutputLimits)
- - restart: optional linear/backoff restart if exit was non-zero and Active is still true
- - `stop()` called when Active flips OFF or app terminates: cancel the execution and `waitUntilExit`.
-- Wire SwiftUI toggle:
-- ON: `GatewayProcessManager.start(...)`
-- OFF: `GatewayProcessManager.stop()` (no launchctl calls in this mode)
-- Keep the existing `LaunchdManager` around so we can switch back if needed; the toggle can choose between launchd or child mode with a flag if we want both.
+## Attach‑only (developer mode)
-## Packaging and signing
-- Bundle the gateway payload (dist + production node_modules) under `Contents/Resources/Gateway/`; rely on host Node ≥22 instead of embedding a runtime.
-- Codesign native addons and dylibs inside the bundle; no nested runtime binary to sign now.
-- Host runtime should not call TCC APIs directly; keep privileged work inside the app/broker.
+Attach‑only tells the app to **connect to an existing Gateway** without spawning
+one. This is ideal for local dev (hot‑reload, custom flags).
-## Logging and observability
-- Stream child stdout/stderr to `/tmp/clawdbot-gateway.log`; surface the last N lines in the Debug tab.
-- Emit a user notification (via existing NotificationManager) on crash/exit while Active is true.
-- Add a lightweight heartbeat from Node → app (e.g., ping over stdout) so the app can show status in the menu.
+Steps:
-## Failure/edge cases
-- App crash/quit kills the gateway. Decide if that is acceptable for the deployment tier; otherwise, stick with launchd for production and keep child-process for dev/experiments.
-- If the gateway exits repeatedly, back off (e.g., 1s/2s/5s/10s) and give up after N attempts with a menu warning.
-- Respect the existing pause semantics: when paused, the broker should return `ok=false, "clawdbot paused"`; the gateway should avoid calling privileged routes while paused.
+1) Start the Gateway yourself:
+ ```bash
+ pnpm gateway:watch
+ ```
+2) In the macOS app: Debug Settings → Gateway → **Attach only**.
-## Open questions / follow-ups
-- Do we need dual-mode (launchd for prod, child for dev)? If yes, gate via a setting or build flag.
-- Embedding a runtime is off the table for now; we rely on host Node for size/simplicity. Revisit only if host PATH drift becomes painful.
-- Do we want a tiny signed helper for rare TCC actions that cannot be brokered via the Swift app/broker?
+The UI should show “Using existing gateway …” once connected.
-## Decision snapshot (current recommendation)
-- Keep all TCC surfaces in the Swift app/broker (node commands + PeekabooBridgeHost).
-- Implement `GatewayProcessManager` with Swift Subprocess to start/stop the gateway on the “Clawdbot Active” toggle.
-- Maintain the launchd path as a fallback for uptime/login persistence until child-mode proves stable.
+## Remote mode
+
+Remote mode never starts a local Gateway. The app uses an SSH tunnel to the
+remote host and connects over that tunnel.
+
+## Why we prefer launchd
+
+- Auto‑start at login.
+- Built‑in restart/KeepAlive semantics.
+- Predictable logs and supervision.
+
+If a true child‑process mode is ever needed again, it should be documented as a
+separate, explicit dev‑only mode.
diff --git a/docs/platforms/mac/peekaboo.md b/docs/platforms/mac/peekaboo.md
index 50511bea6..bfd9017f5 100644
--- a/docs/platforms/mac/peekaboo.md
+++ b/docs/platforms/mac/peekaboo.md
@@ -1,170 +1,62 @@
---
-summary: "Plan for integrating Peekaboo automation into Clawdbot via PeekabooBridge (socket-based TCC broker)"
+summary: "PeekabooBridge integration for macOS UI automation"
read_when:
- Hosting PeekabooBridge in Clawdbot.app
- Integrating Peekaboo as a submodule
- Changing PeekabooBridge protocol/paths
---
-# Peekaboo Bridge in Clawdbot (macOS UI automation broker)
+# Peekaboo Bridge (macOS UI automation)
-## TL;DR
-- **Peekaboo removed its XPC helper** and now exposes privileged automation via a **UNIX domain socket bridge** (`PeekabooBridge` / `PeekabooBridgeHost`, socket name `bridge.sock`).
-- Clawdbot integrates by **optionally hosting the same bridge** inside **Clawdbot.app** (user-toggleable). The primary client is the **`peekaboo` CLI** (installed via npm); Clawdbot does not need its own `ui …` CLI surface.
-- For **visualizations**, we keep them in **Peekaboo.app** (best UX); Clawdbot stays a thin broker host. No visualizer toggle in Clawdbot.
+Clawdbot can host **PeekabooBridge** as a local, permission‑aware UI automation
+broker. This lets the `peekaboo` CLI drive UI automation while reusing the
+macOS app’s TCC permissions.
-Non-goals:
-- No auto-launching Peekaboo.app.
-- No onboarding deep links from the automation endpoint (Clawdbot onboarding already handles permissions).
-- No AI provider/agent runtime dependencies in Clawdbot (avoid pulling Tachikoma/MCP into the Clawdbot app/CLI).
+## What this is (and isn’t)
-## Big refactor (Dec 2025): XPC → Bridge
-Peekaboo’s privileged execution moved from “CLI → XPC helper” to “CLI → socket bridge host”. For Clawdbot this is a win:
-- It matches the existing “local socket + codesign checks” approach.
-- It lets us piggyback on **either** Peekaboo.app’s permissions **or** Clawdbot.app’s permissions (whichever is running).
-- It avoids “two apps with two TCC bubbles” unless needed.
+- **Host**: Clawdbot.app can act as a PeekabooBridge host.
+- **Client**: use the `peekaboo` CLI (no separate `clawdbot ui ...` surface).
+- **UI**: visual overlays stay in Peekaboo.app; Clawdbot is a thin broker host.
-Reference (Peekaboo submodule): `Peekaboo/docs/bridge-host.md`.
+## Enable the bridge
-## Architecture
-### Processes
-- **Bridge hosts** (provide TCC-backed automation):
- - **Peekaboo.app** (preferred; also provides visualizations + controls)
- - **Claude.app** (secondary; lets `peekaboo` reuse Claude Desktop’s granted permissions)
- - **Clawdbot.app** (secondary; “thin host” only)
-- **Bridge clients** (trigger single actions):
- - `peekaboo …` (preferred; humans + agents)
- - Optional: Clawdbot/Node shells out to `peekaboo` when it needs UI automation/capture
+In the macOS app:
+- Settings → **Enable Peekaboo Bridge**
-### Host discovery (client-side)
-Order is deliberate:
-1. Peekaboo.app host (full UX)
-2. Claude.app host (piggyback on Claude Desktop permissions)
-3. Clawdbot.app host (piggyback on Clawdbot permissions)
+When enabled, Clawdbot starts a local UNIX socket server. If disabled, the host
+is stopped and `peekaboo` will fall back to other available hosts.
-Socket paths (convention; exact paths must match Peekaboo):
-- Peekaboo: `~/Library/Application Support/Peekaboo/bridge.sock`
-- Claude: `~/Library/Application Support/Claude/bridge.sock`
-- Clawdbot: `~/Library/Application Support/clawdbot/bridge.sock`
+## Client discovery order
-No auto-launch: if a host isn’t reachable, the command fails with a clear error (start Peekaboo.app, Claude.app, or Clawdbot.app).
+Peekaboo clients typically try hosts in this order:
-Override (debugging): set `PEEKABOO_BRIDGE_SOCKET=/path/to/bridge.sock`.
+1. Peekaboo.app (full UX)
+2. Claude.app (if installed)
+3. Clawdbot.app (thin broker)
-### Protocol shape
-- **Single request per connection**: connect → write one JSON request → half-close → read one JSON response → close.
-- **Timeout**: 10 seconds end-to-end per action (client enforced; host should also enforce per-operation).
-- **Errors**: human-readable string by default; structured envelope in `--json`.
+Use `peekaboo bridge status --verbose` to see which host is active and which
+socket path is in use. You can override with:
-## Dependency strategy (submodule)
-Integrate Peekaboo via git submodule (nested submodules are OK).
+```bash
+export PEEKABOO_BRIDGE_SOCKET=/path/to/bridge.sock
+```
-Path in Clawdbot repo:
-- `./Peekaboo` (Swabble-style; keep stable so SwiftPM path deps don’t churn).
+## Security & permissions
-What Clawdbot should use:
-- **Client side**: `PeekabooBridge` (socket client + protocol models).
-- **Host side (Clawdbot.app)**: `PeekabooBridgeHost` + the minimal Peekaboo services needed to implement operations.
+- The bridge validates **caller code signatures**; TeamID `Y5PE65HELJ` is
+ allowed by default (Peekaboo’s signing team), plus the Clawdbot app’s TeamID.
+- Requests time out after ~10 seconds.
+- If required permissions are missing, the bridge returns a clear error message
+ rather than launching System Settings.
-What Clawdbot should *not* embed:
-- **Visualizer UI**: keep it in Peekaboo.app for now (toggle + controls live there).
-- **XPC**: don’t reintroduce helper targets; use the bridge.
+## Snapshot behavior (automation)
-## IPC / CLI surface
-### No `clawdbot ui …`
-We avoid a parallel “Clawdbot UI automation CLI”. Instead:
-- `peekaboo` is the user/agent-facing CLI surface for automation and capture.
-- Clawdbot.app can host PeekabooBridge as a **thin TCC broker** so Peekaboo can piggyback on Clawdbot permissions when Peekaboo.app isn’t running.
+Snapshots are stored in memory and expire automatically after a short window.
+If you need longer retention, re‑capture from the client.
-### Diagnostics
-Use Peekaboo’s built-in diagnostics to see which host would be used:
-- `peekaboo bridge status`
-- `peekaboo bridge status --verbose`
-- `peekaboo bridge status --json`
+## Troubleshooting
-### Output format
-Peekaboo commands default to human text output. Add `--json` for a structured envelope.
-
-### Timeouts
-Default timeout for UI actions: **10 seconds** end-to-end (client enforced; host should also enforce per-operation).
-
-## Coordinate model (multi-display)
-Requirement: coordinates are **per screen**, not global.
-
-Standardize for the CLI (agent-friendly): **top-left origin per screen**.
-
-Proposed request shape:
-- Requests accept `screenIndex` + `{x, y}` in that screen’s local coordinate space.
-- Clawdbot.app converts to global CG coordinates using `NSScreen.screens[screenIndex].frame.origin`.
-- Responses should echo both:
- - The resolved `screenIndex`
- - The local `{x, y}` and bounds
- - Optionally the global `{x, y}` for debugging
-
-Ordering: use `NSScreen.screens` ordering consistently (documented in the CLI help + JSON schema).
-
-## Targeting (per app/window)
-Expose window/app targeting in the UI surface (align with Peekaboo targeting):
-- frontmost
-- by app name / bundle id
-- by window title substring
-- by (app, index)
-
-Peekaboo CLI targeting (agent-friendly):
-- `--bundle-id ` for app targeting
-- `--window-index ` (0-based) for disambiguating within an app when capturing
-
-All “see/click/type/scroll/wait” requests should accept a target (default: frontmost).
-
-## “See” + click packs (Playwright-style)
-Behavior stays aligned with Peekaboo:
-- `peekaboo see` returns element IDs (e.g. `B1`, `T3`) with bounds/labels.
-- Follow-up actions reference those IDs without re-scanning.
-
-`peekaboo see` should:
-- capture (optionally targeted) window/screen
-- return a screenshot **file path** (default: temp directory)
-- return a list of elements (text or JSON)
-
-Snapshot lifecycle requirement:
-- Host apps are long-lived, so snapshot state should be **in-memory by default**.
-- Snapshot scoping: “implicit snapshot” is **per target bundle id** (reuse last snapshot for that app when snapshot id is omitted).
-
-Practical flow (agent-friendly):
-- `peekaboo list apps` / `peekaboo list windows` provide bundle-id context for targeting.
-- `peekaboo see --bundle-id X` updates the implicit snapshot for `X`.
-- `peekaboo click --bundle-id X --on B1` reuses the most recent snapshot for `X` when `--snapshot-id` is omitted.
-
-## Visualizer integration
-Keep visualizations in **Peekaboo.app** for now.
-- Clawdbot hosts the bridge, but does not render overlays.
-- Any “visualizer enabled/disabled” setting is controlled in Peekaboo.app.
-
-## Screenshots (legacy → Peekaboo takeover)
-Clawdbot should not grow a separate screenshot CLI surface.
-
-Migration plan:
-- Use `peekaboo capture …` / `peekaboo see …` (returns a file path, default temp directory).
-- Once Clawdbot’ legacy screenshot plumbing is replaced, remove it cleanly (no aliases).
-
-## Permissions behavior
-If required permissions are missing:
-- return `ok=false` with a short human error message (e.g., “Accessibility permission missing”)
-- do not try to open System Settings from the automation endpoint
-
-## Security (socket auth)
-Both hosts must enforce:
-- filesystem perms on the socket path (owner read/write only)
-- server-side caller validation:
- - require the caller’s code signature TeamID to be `Y5PE65HELJ`
- - optional bundle-id allowlist for tighter scoping
-
-Debug-only escape hatch (development convenience):
-- “allow same-UID callers” means: *skip codesign checks for clients running under the same Unix user*.
-- This must be **opt-in**, **DEBUG-only**, and guarded by an env var (Peekaboo uses `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1`).
-
-## Next integration steps (after this doc)
-1. Add Peekaboo as a git submodule (nested submodules OK).
-2. Host `PeekabooBridgeHost` inside Clawdbot.app behind a single setting (“Enable Peekaboo Bridge”, default on).
-3. Ensure Clawdbot hosts the bridge at `~/Library/Application Support/clawdbot/bridge.sock` and speaks the PeekabooBridge JSON protocol.
-4. Validate with `peekaboo bridge status --verbose` that Peekaboo can select Clawdbot as the fallback host (no auto-launch).
-5. Keep all protocol decisions aligned with Peekaboo (coordinate system, element IDs, snapshot scoping, error envelopes).
+- If `peekaboo` reports “bridge client is not authorized”, ensure the client is
+ properly signed or run the host with `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1`
+ in **debug** mode only.
+- If no hosts are found, open one of the host apps (Peekaboo.app or Clawdbot.app)
+ and confirm permissions are granted.
diff --git a/docs/platforms/mac/remote.md b/docs/platforms/mac/remote.md
index d89aac65c..26b9b72bc 100644
--- a/docs/platforms/mac/remote.md
+++ b/docs/platforms/mac/remote.md
@@ -5,7 +5,6 @@ read_when:
---
# Remote Clawdbot (macOS ⇄ remote host)
-Updated: 2025-12-08
This flow lets the macOS app act as a full remote control for a Clawdbot gateway running on another host (e.g. a Mac Studio). All features—health checks, Voice Wake forwarding, and Web Chat—reuse the same remote SSH configuration from *Settings → General*.
diff --git a/docs/platforms/mac/voicewake.md b/docs/platforms/mac/voicewake.md
index 44db6df75..d86651825 100644
--- a/docs/platforms/mac/voicewake.md
+++ b/docs/platforms/mac/voicewake.md
@@ -5,7 +5,6 @@ read_when:
---
# Voice Wake & Push-to-Talk
-Updated: 2026-01-08 · Owners: mac app
## Modes
- **Wake-word mode** (default): always-on Speech recognizer waits for trigger tokens (`swabbleTriggerWords`). On match it starts capture, shows the overlay with partial text, and auto-sends after silence.
diff --git a/docs/platforms/mac/webchat.md b/docs/platforms/mac/webchat.md
index a9e0abe95..d3df84c27 100644
--- a/docs/platforms/mac/webchat.md
+++ b/docs/platforms/mac/webchat.md
@@ -3,25 +3,37 @@ summary: "How the mac app embeds the gateway WebChat and how to debug it"
read_when:
- Debugging mac WebChat view or loopback port
---
-# Web Chat (macOS app)
+# WebChat (macOS app)
-The macOS menu bar app shows the WebChat UI as a native SwiftUI view and reuses the **primary Clawd session** (`main`, or `global` when scope is global).
+The macOS menu bar app embeds the WebChat UI as a native SwiftUI view. It
+connects to the Gateway and defaults to the **main session** for the selected
+agent (with a session switcher for other sessions).
- **Local mode**: connects directly to the local Gateway WebSocket.
-- **Remote mode**: forwards the Gateway WebSocket control port over SSH and uses that as the data plane.
+- **Remote mode**: forwards the Gateway control port over SSH and uses that
+ tunnel as the data plane.
## Launch & debugging
+
- Manual: Lobster menu → “Open Chat”.
-- Auto-open for testing: run `dist/Clawdbot.app/Contents/MacOS/Clawdbot --webchat` (or pass `--webchat` to the binary launched by launchd). The window opens on startup.
-- Logs: see [`./scripts/clawlog.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/clawlog.sh) (subsystem `com.clawdbot`, category `WebChatSwiftUI`).
+- Auto‑open for testing:
+ ```bash
+ dist/Clawdbot.app/Contents/MacOS/Clawdbot --webchat
+ ```
+- Logs: `./scripts/clawlog.sh` (subsystem `com.clawdbot`, category `WebChatSwiftUI`).
## How it’s wired
-- Implementation: [`apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift`](https://github.com/clawdbot/clawdbot/blob/main/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift) hosts `ClawdbotChatUI` and speaks to the Gateway over `GatewayConnection`.
-- Data plane: Gateway WebSocket methods `chat.history`, `chat.send`, `chat.abort`; events `chat`, `agent`, `presence`, `tick`, `health`.
-- Session: usually primary (`main`); multiple transports (WhatsApp/Telegram/Discord/Desktop) share the same key. The onboarding flow uses a dedicated `onboarding` session to keep first-run setup separate.
-## Security / surface area
+- Data plane: Gateway WS methods `chat.history`, `chat.send`, `chat.abort` and
+ events `chat`, `agent`, `presence`, `tick`, `health`.
+- Session: defaults to the primary session (`main`, or `global` when scope is
+ global). The UI can switch between sessions.
+- Onboarding uses a dedicated session to keep first‑run setup separate.
+
+## Security surface
+
- Remote mode forwards only the Gateway WebSocket control port over SSH.
## Known limitations
-- The UI is optimized for the primary session and typical “chat” usage (not a full browser-based sandbox surface).
+
+- The UI is optimized for chat sessions (not a full browser sandbox).
diff --git a/docs/platforms/mac/xpc.md b/docs/platforms/mac/xpc.md
index afe2e3977..2bf266fe3 100644
--- a/docs/platforms/mac/xpc.md
+++ b/docs/platforms/mac/xpc.md
@@ -3,7 +3,7 @@ summary: "macOS IPC architecture for Clawdbot app, gateway node bridge, and Peek
read_when:
- Editing IPC contracts or menu bar app IPC
---
-# Clawdbot macOS IPC architecture (Dec 2025)
+# Clawdbot macOS IPC architecture
**Current model:** there is **no local control socket** and no `clawdbot-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge.
@@ -21,10 +21,10 @@ read_when:
- UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol.
- Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution.
- Security: bridge hosts require TeamID `Y5PE65HELJ`; DEBUG-only same-UID escape hatch is guarded by `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (Peekaboo convention).
-- See: [`docs/mac/peekaboo.md`](/platforms/mac/peekaboo) for the Clawdbot plan and naming.
+- See: [`docs/mac/peekaboo.md`](/platforms/mac/peekaboo) for PeekabooBridge usage.
-### Mach/XPC (future direction)
-- Still optional for internal app services, but **not required** for automation now that node.invoke is the surface.
+### Mach/XPC
+- Not required for automation; `node.invoke` + PeekabooBridge cover current needs.
## Operational flows
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: Peter Steinberger (2ZAC4GM7GD)" scripts/restart-mac.sh`
@@ -37,4 +37,4 @@ read_when:
- Prefer requiring a TeamID match for all privileged surfaces.
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
- All communication remains local-only; no network sockets are exposed.
-- TCC prompts originate only from the GUI app bundle; run [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) so the signed bundle ID stays stable.
+- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md
index 33b775b9c..121a8dd93 100644
--- a/docs/platforms/macos.md
+++ b/docs/platforms/macos.md
@@ -1,123 +1,97 @@
---
-summary: "Spec for the Clawdbot macOS companion menu bar app (gateway + node broker)"
+summary: "Clawdbot macOS companion app (menu bar + gateway broker)"
read_when:
- Implementing macOS app features
- Changing gateway lifecycle or node bridging on macOS
---
# Clawdbot macOS Companion (menu bar + gateway broker)
-Author: steipete · Status: draft spec · Date: 2025-12-20
+The macOS app is the **menu‑bar companion** for Clawdbot. It owns permissions,
+manages the Gateway locally, and exposes macOS capabilities to the agent as a
+node.
-## Support snapshot
-- Core Gateway: supported (TypeScript on Node/Bun).
-- Companion app: macOS menu bar app with permissions + node bridge.
-- Install: [Getting Started](/start/getting-started) or [Install & updates](/install/updating).
-- Gateway: [Runbook](/gateway) + [Configuration](/gateway/configuration).
+## What it does
-## System control (launchd)
-If you run the bundled macOS app, it installs a per-user LaunchAgent labeled `com.clawdbot.gateway`.
-CLI-only installs can use `clawdbot onboard --install-daemon`, `clawdbot daemon install`, or `clawdbot configure` → **Gateway daemon**.
+- Shows native notifications and status in the menu bar.
+- Owns TCC prompts (Notifications, Accessibility, Screen Recording, Microphone,
+ Speech Recognition, Automation/AppleScript).
+- Runs or connects to the Gateway (local or remote).
+- Exposes macOS‑only tools (Canvas, Camera, Screen Recording, `system.run`).
+- Optionally hosts **PeekabooBridge** for UI automation.
+- Installs a helper CLI (`clawdbot`) into `/usr/local/bin` and
+ `/opt/homebrew/bin` on request.
+
+## Local vs remote mode
+
+- **Local** (default): the app ensures a local Gateway is running via launchd.
+- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
+ a local process.
+- **Attach‑only** (debug): the app connects to an already‑running local Gateway
+ and never spawns its own.
+
+## Launchd control
+
+The app manages a per‑user LaunchAgent labeled `com.clawdbot.gateway`.
```bash
launchctl kickstart -k gui/$UID/com.clawdbot.gateway
launchctl bootout gui/$UID/com.clawdbot.gateway
```
-`launchctl` only works if the LaunchAgent is installed; otherwise run `clawdbot daemon install` first.
+If the LaunchAgent isn’t installed, enable it from the app or run
+`clawdbot daemon install`.
-Details: [Gateway runbook](/gateway) and [Bundled bun Gateway](/platforms/mac/bun).
+## Node capabilities (mac)
-## Purpose
-- Single macOS menu-bar app named **Clawdbot** that:
- - Shows native notifications for Clawdbot/clawdbot events.
- - Owns TCC prompts (Notifications, Accessibility, Screen Recording, Automation/AppleScript, Microphone, Speech Recognition).
- - Runs (or connects to) the **Gateway** and exposes itself as a **node** so agents can reach macOS‑only features.
- - Hosts **PeekabooBridge** for UI automation (consumed by `peekaboo`; see [`docs/mac/peekaboo.md`](/platforms/mac/peekaboo)).
- - Installs a single CLI (`clawdbot`) by symlinking the bundled binary.
+The macOS app presents itself as a node. Common commands:
-## High-level design
-- SwiftPM package in `apps/macos/` (macOS 15+, Swift 6).
-- Targets:
- - `ClawdbotIPC` (shared Codable types + helpers for app‑internal actions).
- - `Clawdbot` (LSUIElement MenuBarExtra app; hosts Gateway + node bridge + PeekabooBridgeHost).
-- Bundle ID: `com.clawdbot.mac`.
-- Bundled runtime binaries live under `Contents/Resources/Relay/`:
- - `clawdbot` (bun‑compiled relay: CLI + gateway)
-- The app symlinks `clawdbot` into `/usr/local/bin` and `/opt/homebrew/bin`.
-
-## Gateway + node bridge
-- The mac app runs the Gateway in **local** mode (unless configured remote).
-- The gateway port is configurable via `gateway.port` or `CLAWDBOT_GATEWAY_PORT` (default 18789). The mac app reads that value for launchd, probes, and remote SSH tunnels.
-- The mac app connects to the bridge as a **node** and advertises capabilities/commands.
-- Agent‑facing actions are exposed via `node.invoke` (no local control socket).
-- The mac app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
-- If `gateway.mode` is unset but `gateway.remote.url` is set, the mac app treats it as remote mode.
-- Changing connection mode in the mac app writes `gateway.mode` (and `gateway.remote.url` in remote mode) back to the config file.
-
-### Node commands (mac)
-- Canvas: `canvas.present|navigate|eval|snapshot|a2ui.*`
-- Camera: `camera.snap|camera.clip`
+- Canvas: `canvas.present`, `canvas.navigate`, `canvas.eval`, `canvas.snapshot`, `canvas.a2ui.*`
+- Camera: `camera.snap`, `camera.clip`
- Screen: `screen.record`
-- System: `system.run` (shell) and `system.notify`
+- System: `system.run`, `system.notify`
-### Permission advertising
-- Nodes include a `permissions` map in hello/pairing.
-- The Gateway surfaces it via `node.list` / `node.describe` so agents can decide what to run.
+The node reports a `permissions` map so agents can decide what’s allowed.
-## CLI (`clawdbot`)
-- The **only** CLI is `clawdbot` (TS/bun). There is no `clawdbot-mac` helper.
-- For mac‑specific actions, the CLI uses `node.invoke`:
- - `clawdbot nodes canvas present|navigate|eval|snapshot|a2ui push|a2ui reset`
- - `clawdbot nodes run --node -- `
- - `clawdbot nodes notify --node --title ...`
+## Deep links
-## Onboarding
-- Install CLI (symlink) → Permissions checklist → Test notification → Done.
-- Remote mode skips local gateway/CLI steps.
-- Selecting Local auto-enables the bundled Gateway via launchd (unless “Attach only” debug mode is enabled).
-
-## Deep links (URL scheme)
-
-Clawdbot (the macOS app) registers a URL scheme for triggering local actions from anywhere (browser, Shortcuts, CLI, etc.).
-
-Scheme:
-- `clawdbot://…`
+The app registers the `clawdbot://` URL scheme for local actions.
### `clawdbot://agent`
-Triggers a Gateway `agent` request (same machinery as WebChat/agent runs).
-
-Example:
+Triggers a Gateway `agent` request.
```bash
open 'clawdbot://agent?message=Hello%20from%20deep%20link'
```
Query parameters:
-- `message` (required): the agent prompt (URL-encoded).
-- `sessionKey` (optional): explicit session key to use.
-- `thinking` (optional): thinking hint (e.g. `low`; omit for default).
-- `deliver` (optional): `true|false` (default: false).
-- `to` / `provider` (optional): forwarded to the Gateway `agent` method (only meaningful with `deliver=true`).
-- `timeoutSeconds` (optional): timeout hint forwarded to the Gateway.
-- `key` (optional): unattended mode key (see below).
+- `message` (required)
+- `sessionKey` (optional)
+- `thinking` (optional)
+- `deliver` / `to` / `provider` (optional)
+- `timeoutSeconds` (optional)
+- `key` (optional unattended mode key)
-Safety/guardrails:
-- Always enabled.
-- Without a `key` query param, the app will prompt for confirmation before invoking the agent.
-- With `key=`, Clawdbot runs without prompting (intended for personal automations).
- - The current key is shown in Debug Settings and stored locally in UserDefaults.
+Safety:
+- Without `key`, the app prompts for confirmation.
+- With a valid `key`, the run is unattended (intended for personal automations).
-Notes:
-- In local mode, Clawdbot will start the local Gateway if needed before issuing the request.
-- In remote mode, Clawdbot will use the configured remote tunnel/endpoint.
+## Onboarding flow (typical)
+
+1) Install and launch **Clawdbot.app**.
+2) Complete the permissions checklist (TCC prompts).
+3) Ensure **Local** mode is active and the Gateway is running.
+4) Install the CLI helper if you want terminal access.
## Build & dev workflow (native)
-- `cd apps/macos && swift build` (debug) / `swift build -c release`.
-- Run app for dev: `swift run Clawdbot` (or Xcode scheme).
-- Package app + CLI: [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) (builds bun CLI + gateway).
-- Tests: add Swift Testing suites under `apps/macos/Tests`.
-## Open questions / decisions
-- Should `system.run` support streaming stdout/stderr or keep buffered responses only?
-- Should we allow node‑side permission prompts, or always require explicit app UI action?
+- `cd apps/macos && swift build`
+- `swift run Clawdbot` (or Xcode)
+- Package app + CLI: `scripts/package-mac-app.sh`
+
+## Related docs
+
+- [Gateway runbook](/gateway)
+- [Bundled bun Gateway](/platforms/mac/bun)
+- [macOS permissions](/platforms/mac/permissions)
+- [Canvas](/platforms/mac/canvas)
diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md
index ce06086a4..c74253552 100644
--- a/docs/platforms/windows.md
+++ b/docs/platforms/windows.md
@@ -3,15 +3,16 @@ summary: "Windows (WSL2) support + companion app status"
read_when:
- Installing Clawdbot on Windows
- Looking for Windows companion app status
- - Planning platform coverage or contributions
---
# Windows (WSL2)
-Clawdbot core is supported on Windows **via WSL2** (Ubuntu recommended). The
+Clawdbot on Windows is recommended **via WSL2** (Ubuntu recommended). The
CLI + Gateway run inside Linux, which keeps the runtime consistent. Native
Windows installs are untested and more problematic.
-## Install
+Native Windows companion apps are planned.
+
+## Install (WSL2)
- [Getting Started](/start/getting-started) (use inside WSL)
- [Install & updates](/install/updating)
- Official WSL2 guide (Microsoft): https://learn.microsoft.com/windows/wsl/install
@@ -48,7 +49,7 @@ Repair/migrate:
clawdbot doctor
```
-## How to install this correctly
+## Step-by-step WSL2 install
### 1) Install WSL2 + Ubuntu
@@ -104,5 +105,5 @@ Full guide: [Getting Started](/start/getting-started)
## Windows companion app
-We do not have a Windows companion app yet. It is planned, and we would love
+We do not have a Windows companion app yet. Contributions are welcome if you want
contributions to make it happen.
diff --git a/docs/providers/discord.md b/docs/providers/discord.md
index fbf512dc0..d1b077eaf 100644
--- a/docs/providers/discord.md
+++ b/docs/providers/discord.md
@@ -5,7 +5,6 @@ read_when:
---
# Discord (Bot API)
-Updated: 2026-01-07
Status: ready for DM and guild text channels via the official Discord bot gateway.
@@ -138,6 +137,7 @@ Example “single server, only allow me, only allow #help”:
Notes:
- `requireMention: true` means the bot only replies when mentioned (recommended for shared channels).
- `routing.groupChat.mentionPatterns` also count as mentions for guild messages.
+- Multi-agent override: `routing.agents..mentionPatterns` takes precedence.
- If `channels` is present, any channel not listed is denied by default.
### 6) Verify it works
@@ -146,12 +146,14 @@ Notes:
3. If nothing happens: check **Troubleshooting** below.
### Troubleshooting
+- First: run `clawdbot doctor` and `clawdbot providers status --probe` (actionable warnings + quick audits).
- **“Used disallowed intents”**: enable **Message Content Intent** (and likely **Server Members Intent**) in the Developer Portal, then restart the gateway.
- **Bot connects but never replies in a guild channel**:
- Missing **Message Content Intent**, or
- The bot lacks channel permissions (View/Send/Read History), or
- Your config requires mentions and you didn’t mention it, or
- Your guild/channel allowlist denies the channel/user.
+- **Permission audits** (`providers status --probe`) only check numeric channel IDs. If you use slugs/names as `discord.guilds.*.channels` keys, the audit can’t verify permissions.
- **DMs don’t work**: `discord.dm.enabled=false`, `discord.dm.policy="disabled"`, or you haven’t been approved yet (`discord.dm.policy="pairing"`).
## Capabilities & limits
@@ -273,7 +275,7 @@ Reaction notifications use `guilds..reactionNotifications`:
| messages | enabled | Read/send/edit/delete |
| threads | enabled | Create/list/reply |
| pins | enabled | Pin/unpin/list |
-| search | enabled | Message search (preview spec) |
+| search | enabled | Message search (preview feature) |
| memberInfo | enabled | Member info |
| roleInfo | enabled | Role list |
| channelInfo | enabled | Channel info + list |
diff --git a/docs/providers/grammy.md b/docs/providers/grammy.md
index 1f48c31eb..e01bcedbd 100644
--- a/docs/providers/grammy.md
+++ b/docs/providers/grammy.md
@@ -5,7 +5,6 @@ read_when:
---
# grammY Integration (Telegram Bot API)
-Updated: 2026-01-07
# Why grammY
- TS-first Bot API client with built-in long-poll + webhook helpers, middleware, error handling, rate limiter.
diff --git a/docs/providers/imessage.md b/docs/providers/imessage.md
index f036ae629..991bd89b8 100644
--- a/docs/providers/imessage.md
+++ b/docs/providers/imessage.md
@@ -6,7 +6,6 @@ read_when:
---
# iMessage (imsg)
-Updated: 2026-01-08
Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio).
@@ -68,6 +67,7 @@ Groups:
- `imessage.groupPolicy = open | allowlist | disabled`.
- `imessage.groupAllowFrom` controls who can trigger in groups when `allowlist` is set.
- Mention gating uses `routing.groupChat.mentionPatterns` (iMessage has no native mention metadata).
+- Multi-agent override: `routing.agents..mentionPatterns` takes precedence.
## How it works (behavior)
- `imsg` streams message events; the gateway normalizes them into the shared provider envelope.
diff --git a/docs/providers/signal.md b/docs/providers/signal.md
index a3f081616..f906856e0 100644
--- a/docs/providers/signal.md
+++ b/docs/providers/signal.md
@@ -6,7 +6,6 @@ read_when:
---
# Signal (signal-cli)
-Updated: 2026-01-06
Status: external CLI integration. Gateway talks to `signal-cli` over HTTP JSON-RPC + SSE.
@@ -49,6 +48,7 @@ DMs:
- `clawdbot pairing list --provider signal`
- `clawdbot pairing approve --provider signal `
- Pairing is the default token exchange for Signal DMs. Details: [Pairing](/start/pairing)
+- UUID-only senders (from `sourceUuid`) are stored as `uuid:` in `signal.allowFrom`.
Groups:
- `signal.groupPolicy = open | allowlist | disabled`.
@@ -85,7 +85,7 @@ Provider options:
- `signal.ignoreStories`: ignore stories from the daemon.
- `signal.sendReadReceipts`: forward read receipts.
- `signal.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
-- `signal.allowFrom`: DM allowlist (E.164). `open` requires `"*"`.
+- `signal.allowFrom`: DM allowlist (E.164 or `uuid:`). `open` requires `"*"`.
- `signal.groupPolicy`: `open | allowlist | disabled` (default: open).
- `signal.groupAllowFrom`: group sender allowlist.
- `signal.textChunkLimit`: outbound chunk size (chars).
@@ -93,4 +93,5 @@ Provider options:
Related global options:
- `routing.groupChat.mentionPatterns` (Signal does not support native mentions).
+- Multi-agent override: `routing.agents..mentionPatterns` takes precedence.
- `messages.responsePrefix`.
diff --git a/docs/providers/slack.md b/docs/providers/slack.md
index d71e2e668..594d377b4 100644
--- a/docs/providers/slack.md
+++ b/docs/providers/slack.md
@@ -107,18 +107,16 @@ Slack's Conversations API is type-scoped: you only need the scopes for the
conversation types you actually touch (channels, groups, im, mpim). See
https://api.slack.com/docs/conversations-api for the overview.
-### Required by current code
+### Required scopes
- `chat:write` (send/update/delete messages via `chat.postMessage`)
https://api.slack.com/methods/chat.postMessage
- `im:write` (open DMs via `conversations.open` for user DMs)
https://api.slack.com/methods/conversations.open
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
- (`conversations.history` in [`src/slack/actions.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/slack/actions.ts))
https://api.slack.com/methods/conversations.history
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
- (`conversations.info` in [`src/slack/monitor.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/slack/monitor.ts))
https://api.slack.com/methods/conversations.info
-- `users:read` (`users.info` in [`src/slack/monitor.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/slack/monitor.ts) + [`src/slack/actions.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/slack/actions.ts))
+- `users:read` (user lookup)
https://api.slack.com/methods/users.info
- `reactions:read`, `reactions:write` (`reactions.get` / `reactions.add`)
https://api.slack.com/methods/reactions.get
@@ -251,6 +249,7 @@ Slack tool actions can be gated with `slack.actions.*`:
## Notes
- Mention gating is controlled via `slack.channels` (set `requireMention` to `true`); `routing.groupChat.mentionPatterns` also count as mentions.
+- Multi-agent override: `routing.agents..mentionPatterns` takes precedence.
- Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`).
- Bot-authored messages are ignored by default; enable via `slack.allowBots` or `slack.channels..allowBots`.
- For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions).
diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md
index a0c7b3e79..9e3261089 100644
--- a/docs/providers/telegram.md
+++ b/docs/providers/telegram.md
@@ -5,7 +5,6 @@ read_when:
---
# Telegram (Bot API)
-Updated: 2026-01-08
Status: production-ready for bot DMs + groups via grammY. Long-polling by default; webhook optional.
@@ -66,6 +65,7 @@ group messages, so use admin if you need full visibility.
## How it works (behavior)
- Inbound messages are normalized into the shared provider envelope with reply context and media placeholders.
- Group replies require a mention by default (native @mention or `routing.groupChat.mentionPatterns`).
+- Multi-agent override: `routing.agents..mentionPatterns` takes precedence.
- Replies always route back to the same Telegram chat.
- Long-polling uses grammY runner with per-chat sequencing; overall concurrency is capped by `agent.maxConcurrent`.
@@ -227,10 +227,12 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
## Troubleshooting
-**Bot doesn't respond to non-mention messages in group:**
-- Check if group is in `telegram.groups` with `requireMention: false`
-- Or use `"*": { "requireMention": false }` to enable for all groups
-- Test with `/activation always` command (requires config change to persist)
+**Bot doesn’t respond to non-mention messages in a group:**
+- If you set `telegram.groups.*.requireMention=false`, Telegram’s Bot API **privacy mode** must be disabled.
+ - BotFather: `/setprivacy` → **Disable** (then remove + re-add the bot to the group)
+- `clawdbot providers status` shows a warning when config expects unmentioned group messages.
+- `clawdbot providers status --probe` can additionally check membership for explicit numeric group IDs (it can’t audit wildcard `"*"` rules).
+- Quick test: `/activation always` (session-only; use config for persistence)
**Bot not seeing group messages at all:**
- If `telegram.groups` is set, the group must be listed or use `"*"`
@@ -279,5 +281,6 @@ Provider options:
Related global options:
- `routing.groupChat.mentionPatterns` (mention gating patterns).
+- `routing.agents..mentionPatterns` overrides for multi-agent setups.
- `commands.native`, `commands.text`, `commands.useAccessGroups` (command behavior).
- `messages.responsePrefix`, `messages.ackReaction`, `messages.ackReactionScope`.
diff --git a/docs/providers/troubleshooting.md b/docs/providers/troubleshooting.md
new file mode 100644
index 000000000..909948a45
--- /dev/null
+++ b/docs/providers/troubleshooting.md
@@ -0,0 +1,22 @@
+---
+summary: "Provider-specific troubleshooting shortcuts (Discord/Telegram/WhatsApp)"
+read_when:
+ - A provider connects but messages don’t flow
+ - Investigating provider misconfiguration (intents, permissions, privacy mode)
+---
+# Provider troubleshooting
+
+Start with:
+
+```bash
+clawdbot doctor
+clawdbot providers status --probe
+```
+
+`providers status --probe` prints warnings when it can detect common provider misconfigurations, and includes small live checks (credentials, some permissions/membership).
+
+## Providers
+- Discord: [/providers/discord#troubleshooting](/providers/discord#troubleshooting)
+- Telegram: [/providers/telegram#troubleshooting](/providers/telegram#troubleshooting)
+- WhatsApp: [/providers/whatsapp#troubleshooting-quick](/providers/whatsapp#troubleshooting-quick)
+
diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md
index f2750625f..57ab0e2d9 100644
--- a/docs/providers/whatsapp.md
+++ b/docs/providers/whatsapp.md
@@ -5,7 +5,6 @@ read_when:
---
# WhatsApp (web provider)
-Updated: 2026-01-07
Status: WhatsApp Web via Baileys only. Gateway owns the session(s).
@@ -19,11 +18,47 @@ Status: WhatsApp Web via Baileys only. Gateway owns the session(s).
- **CLI / macOS app** talk to the gateway; no direct Baileys use.
- **Active listener** is required for outbound sends; otherwise send fails fast.
-## Getting a phone number
+## Getting a phone number (two modes)
-WhatsApp requires a real mobile number for verification. VoIP and virtual numbers are usually blocked.
+WhatsApp requires a real mobile number for verification. VoIP and virtual numbers are usually blocked. There are two supported ways to run Clawdbot on WhatsApp:
-**Recommended approaches:**
+### Dedicated number (recommended)
+Use a **separate phone number** for Clawdbot. Best UX, clean routing, no self-chat quirks. Ideal setup: **spare/old Android phone + eSIM**. Leave it on Wi‑Fi and power, and link it via QR.
+
+**WhatsApp Business:** You can use WhatsApp Business on the same device with a different number. Great for keeping your personal WhatsApp separate — install WhatsApp Business and register the Clawdbot number there.
+
+**Sample config (dedicated number, single-user allowlist):**
+```json
+{
+ "whatsapp": {
+ "dmPolicy": "allowlist",
+ "allowFrom": ["+15551234567"]
+ }
+}
+```
+
+**Pairing mode (optional):**
+If you want pairing instead of allowlist, set `whatsapp.dmPolicy` to `pairing`. Unknown senders get a pairing code; approve with:
+`clawdbot pairing approve --provider whatsapp `
+
+### Personal number (fallback)
+Quick fallback: run Clawdbot on **your own number**. Message yourself (WhatsApp “Message yourself”) for testing so you don’t spam contacts. Expect to read verification codes on your main phone during setup and experiments. **Must enable self-chat mode.**
+
+**Sample config (personal number, self-chat):**
+```json
+{
+ "whatsapp": {
+ "selfChatMode": true,
+ "dmPolicy": "allowlist",
+ "allowFrom": ["+15551234567"]
+ },
+ "messages": {
+ "responsePrefix": "[clawdbot]"
+ }
+}
+```
+
+### Number sourcing tips
- **Local eSIM** from your country's mobile carrier (most reliable)
- Austria: [hot.at](https://www.hot.at)
- UK: [giffgaff](https://www.giffgaff.com) — free SIM, no contract
@@ -33,8 +68,6 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
**Tip:** The number only needs to receive one verification SMS. After that, WhatsApp Web sessions persist via `creds.json`.
-**WhatsApp Business:** You can use WhatsApp Business on the same phone with a different number. This is a great option if you want to keep your personal WhatsApp separate — just install WhatsApp Business and register it with Clawdbot's dedicated number.
-
## Why Not Twilio?
- Early Clawdbot builds supported Twilio’s WhatsApp Business integration.
- WhatsApp Business numbers are a poor fit for a personal assistant.
@@ -62,27 +95,13 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
- Open: requires `whatsapp.allowFrom` to include `"*"`.
- Self messages are always allowed; “self-chat mode” still requires `whatsapp.allowFrom` to include your own number.
-### Same-phone mode (personal number)
-If you run Clawdbot on your **personal WhatsApp number**, set:
-
-```json
-{
- "whatsapp": {
- "selfChatMode": true
- }
-}
-```
+### Personal-number mode (fallback)
+If you run Clawdbot on your **personal WhatsApp number**, enable `whatsapp.selfChatMode` (see sample above).
Behavior:
- Suppresses pairing replies for **outbound DMs** (prevents spamming contacts).
- Inbound unknown senders still follow `whatsapp.dmPolicy`.
-
-Recommended for personal numbers:
-- Set `whatsapp.dmPolicy="allowlist"` and add your number to `whatsapp.allowFrom`.
-- Set `messages.responsePrefix` (for example, `[clawdbot]`) so replies are clearly labeled.
-- **Group policy**: `whatsapp.groupPolicy` controls group handling (`open|disabled|allowlist`).
- - `allowlist` uses `whatsapp.groupAllowFrom` (fallback: explicit `whatsapp.allowFrom`).
-- **Self-chat mode**: avoids auto read receipts and ignores mention JIDs.
+- Self-chat mode avoids auto read receipts and ignores mention JIDs.
- Read receipts sent for non-self-chat DMs.
## Message normalization (what the model sees)
@@ -170,6 +189,7 @@ Recommended for personal numbers:
- `whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all)
- `whatsapp.actions.reactions` (gate WhatsApp tool reactions).
- `routing.groupChat.mentionPatterns`
+- Multi-agent override: `routing.agents..mentionPatterns` takes precedence.
- `routing.groupChat.historyLimit`
- `messages.messagePrefix` (inbound prefix)
- `messages.responsePrefix` (outbound prefix)
@@ -188,7 +208,15 @@ Recommended for personal numbers:
- Log file: `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` (configurable).
- Troubleshooting guide: [`docs/troubleshooting.md`](/gateway/troubleshooting).
-## Tests
-- [`src/web/auto-reply.test.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/web/auto-reply.test.ts) (mention gating, history injection, reply flow)
-- [`src/web/monitor-inbox.test.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/web/monitor-inbox.test.ts) (inbound parsing + reply context)
-- [`src/web/outbound.test.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/web/outbound.test.ts) (send mapping + media)
+## Troubleshooting (quick)
+
+**Not linked / QR login required**
+- Symptom: `providers status` shows `linked: false` or warns “Not linked”.
+- Fix: run `clawdbot providers login` on the gateway host and scan the QR (WhatsApp → Settings → Linked Devices).
+
+**Linked but disconnected / reconnect loop**
+- Symptom: `providers status` shows `running, disconnected` or warns “Linked but disconnected”.
+- Fix: `clawdbot doctor` (or restart the gateway). If it persists, relink via `providers login` and inspect `clawdbot logs --follow`.
+
+**Bun runtime**
+- WhatsApp uses Baileys; run the gateway with **Node** for WhatsApp. (See Getting Started runtime note.)
diff --git a/docs/start/faq.md b/docs/start/faq.md
index a02d226a7..e613463a7 100644
--- a/docs/start/faq.md
+++ b/docs/start/faq.md
@@ -185,6 +185,19 @@ Clawdbot reads env vars from the parent process (shell, launchd/systemd, CI, etc
Neither `.env` file overrides existing env vars.
+You can also define inline env vars in config (applied only if missing from the process env):
+
+```json5
+{
+ env: {
+ OPENROUTER_API_KEY: "sk-or-...",
+ vars: { GROQ_API_KEY: "gsk-..." }
+ }
+}
+```
+
+See [/environment](/environment) for full precedence and sources.
+
### “I started the Gateway via a daemon and my env vars disappeared.” What now?
Two common fixes:
@@ -507,7 +520,7 @@ Start the Gateway with `--verbose` to get more console detail. Then inspect the
### My skill generated an image/PDF, but nothing was sent
-Outbound attachments from the agent must include a `MEDIA:` line (on its own line). See [Clawd setup](/start/clawd) and [Agent send](/tools/agent-send).
+Outbound attachments from the agent must include a `MEDIA:` line (on its own line). See [Clawdbot assistant setup](/start/clawd) and [Agent send](/tools/agent-send).
CLI sending:
diff --git a/docs/start/hubs.md b/docs/start/hubs.md
index 18ddda1b4..4dea3e8f2 100644
--- a/docs/start/hubs.md
+++ b/docs/start/hubs.md
@@ -18,7 +18,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [FAQ](https://docs.clawd.bot/start/faq)
- [Configuration](https://docs.clawd.bot/gateway/configuration)
- [Configuration examples](https://docs.clawd.bot/gateway/configuration-examples)
-- [Clawd (personal assistant)](https://docs.clawd.bot/start/clawd)
+- [Clawdbot assistant (Clawd)](https://docs.clawd.bot/start/clawd)
- [Showcase](https://docs.clawd.bot/start/showcase)
- [Lore](https://docs.clawd.bot/start/lore)
@@ -100,7 +100,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [Models](https://docs.clawd.bot/concepts/models)
- [Sub-agents](https://docs.clawd.bot/tools/subagents)
- [Agent send CLI](https://docs.clawd.bot/tools/agent-send)
-- [Terminal UI](https://docs.clawd.bot/web/tui)
+- [Terminal UI](https://docs.clawd.bot/tui)
- [Browser control](https://docs.clawd.bot/tools/browser)
- [Browser (Linux troubleshooting)](https://docs.clawd.bot/tools/browser-linux-troubleshooting)
- [Polls](https://docs.clawd.bot/automation/poll)
@@ -125,7 +125,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [Linux](https://docs.clawd.bot/platforms/linux)
- [Web surfaces](https://docs.clawd.bot/web)
-## macOS companion app (internals)
+## macOS companion app (advanced)
- [macOS dev setup](https://docs.clawd.bot/platforms/mac/dev-setup)
- [macOS menu bar](https://docs.clawd.bot/platforms/mac/menu-bar)
@@ -144,7 +144,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [macOS bun gateway](https://docs.clawd.bot/platforms/mac/bun)
- [macOS XPC](https://docs.clawd.bot/platforms/mac/xpc)
- [macOS skills](https://docs.clawd.bot/platforms/mac/skills)
-- [macOS Peekaboo plan](https://docs.clawd.bot/platforms/mac/peekaboo)
+- [macOS Peekaboo](https://docs.clawd.bot/platforms/mac/peekaboo)
## Workspace + templates
@@ -160,13 +160,13 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [Templates: TOOLS](https://docs.clawd.bot/reference/templates/TOOLS)
- [Templates: USER](https://docs.clawd.bot/reference/templates/USER)
-## Experiments + proposals
+## Experiments (exploratory)
- [Onboarding config protocol](https://docs.clawd.bot/experiments/onboarding-config-protocol)
-- [Plan: cron hardening](https://docs.clawd.bot/experiments/plans/cron-add-hardening)
-- [Plan: group policy hardening](https://docs.clawd.bot/experiments/plans/group-policy-hardening)
+- [Cron hardening notes](https://docs.clawd.bot/experiments/plans/cron-add-hardening)
+- [Group policy hardening notes](https://docs.clawd.bot/experiments/plans/group-policy-hardening)
- [Research: memory](https://docs.clawd.bot/experiments/research/memory)
-- [Proposal: model config](https://docs.clawd.bot/experiments/proposals/model-config)
+- [Model config exploration](https://docs.clawd.bot/experiments/proposals/model-config)
## Testing + release
diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md
index 4ed664933..b996b4036 100644
--- a/docs/start/onboarding.md
+++ b/docs/start/onboarding.md
@@ -1,207 +1,102 @@
---
-summary: "Planned first-run onboarding flow for Clawdbot (local vs remote, OAuth auth, workspace bootstrap ritual)"
+summary: "First-run onboarding flow for Clawdbot (macOS app)"
read_when:
- Designing the macOS onboarding assistant
- - Implementing Anthropic/OpenAI auth or identity setup
+ - Implementing auth or identity setup
---
# Onboarding (macOS app)
-This doc describes the intended **first-run onboarding** for Clawdbot. The goal is a good “day 0” experience: pick where the Gateway runs, bind subscription auth (Anthropic or OpenAI) for the embedded agent runtime, and then let the **agent bootstrap itself** via a first-run ritual in the workspace.
+This doc describes the **current** first‑run onboarding flow. The goal is a
+smooth “day 0” experience: pick where the Gateway runs, connect auth, run the
+wizard, and let the agent bootstrap itself.
-## Page order (high level)
+## Page order (current)
-1) **Local vs Remote**
-2) **(Local only)** Connect subscription auth (Anthropic / OpenAI OAuth) — optional, but recommended
-3) **Connect Gmail (optional)** — run `clawdbot hooks gmail setup` to configure Pub/Sub hooks
-4) **Onboarding chat** — dedicated session where the agent introduces itself and guides setup
+1) Welcome + security notice
+2) **Gateway selection** (Local / Remote / Configure later)
+3) **Auth (Anthropic OAuth)** — local only
+4) **Setup Wizard** (Gateway‑driven)
+5) **Permissions** (TCC prompts)
+6) **CLI helper** (optional)
+7) **Onboarding chat** (dedicated session)
+8) Ready
## 1) Local vs Remote
-First question: where does the **Gateway** run?
+Where does the **Gateway** run?
-- **Local (this Mac):** onboarding can run OAuth flows and write OAuth credentials locally.
-- **Remote (over SSH/tailnet):** onboarding must not run OAuth locally, because credentials must exist on the **gateway host**.
+- **Local (this Mac):** onboarding can run OAuth flows and write credentials
+ locally.
+- **Remote (over SSH/Tailnet):** onboarding does **not** run OAuth locally;
+ credentials must exist on the gateway host.
+- **Configure later:** skip setup and leave the app unconfigured.
Gateway auth tip:
-- If you only use Clawdbot on this Mac (loopback gateway), keep auth **Off**.
-- Use **Token** for multi-machine access or non-loopback binds.
+- If you only use Clawdbot locally (loopback), auth can be **Off**.
+- Use a **token** for multi‑machine access or non‑loopback binds.
-Implementation note (2025-12-19): in local mode, the macOS app bundles the Gateway and enables it via a per-user launchd LaunchAgent (no global npm install/Node requirement for the user).
+## 2) Local-only auth (Anthropic OAuth)
-## 2) Local-only: Connect subscription auth (Anthropic / OpenAI OAuth)
+The macOS app supports Anthropic OAuth (Claude Pro/Max). The flow:
-This is the “bind Clawdbot to subscription auth” step. It is explicitly the **Anthropic (Claude Pro/Max)** or **OpenAI (ChatGPT/Codex)** OAuth flow, not a generic “login”.
+- Opens the browser for OAuth (PKCE)
+- Asks the user to paste the `code#state` value
+- Writes credentials to `~/.clawdbot/credentials/oauth.json`
-More detail: [/concepts/oauth](/concepts/oauth)
+Other providers (OpenAI, custom APIs) are configured via environment variables
+or config files for now.
-### Recommended: OAuth (Anthropic)
+## 3) Setup Wizard (Gateway‑driven)
-The macOS app should:
-- Start the Anthropic OAuth (PKCE) flow in the user’s browser.
-- Ask the user to paste the `code#state` value.
-- Exchange it for tokens and write credentials to:
- - `~/.clawdbot/credentials/oauth.json` (file mode `0600`, directory mode `0700`)
+The app can run the same setup wizard as the CLI. This keeps onboarding in sync
+with Gateway‑side behavior and avoids duplicating logic in SwiftUI.
-Why this location matters: it’s the Clawdbot-owned OAuth store.
-Clawdbot also imports `oauth.json` into the agent auth profile store (`~/.clawdbot/agents//agent/auth-profiles.json`) on first use.
+## 4) Permissions
-### Recommended: OAuth (OpenAI Codex)
+Onboarding requests TCC permissions needed for:
-The macOS app should:
-- Start the OpenAI Codex OAuth (PKCE) flow in the user’s browser.
-- Auto-capture the callback on `http://127.0.0.1:1455/auth/callback` when possible.
-- If the callback fails, prompt the user to paste the redirect URL or code.
-- Store credentials in `~/.clawdbot/credentials/oauth.json` (same OAuth store as Anthropic).
-- Set `agent.model` to `openai-codex/gpt-5.2` when the model is unset or `openai/*`.
+- Notifications
+- Accessibility
+- Screen Recording
+- Microphone / Speech Recognition
+- Automation (AppleScript)
-### Alternative: API key (instructions only)
+## 5) CLI helper (optional)
-Offer an “API key” option, but for now it is **instructions only**:
-- Get an Anthropic API key.
-- Provide it to Clawdbot via your preferred mechanism (env/config).
+The app can symlink the bundled `clawdbot` CLI into `/usr/local/bin` and
+`/opt/homebrew/bin` so terminal workflows work out of the box.
-Note: environment variables are often confusing when the Gateway is launched by a GUI app (launchd environment != your shell).
+## 6) Onboarding chat (dedicated session)
-### Model safety rule
+After setup, the app opens a dedicated onboarding chat session so the agent can
+introduce itself and guide next steps. This keeps first‑run guidance separate
+from your normal conversation.
-Clawdbot should **always pass** `--model` when invoking the embedded agent (don’t rely on defaults).
+## Agent bootstrap ritual
-Example (CLI):
+On the first agent run, Clawdbot bootstraps a workspace (default `~/clawd`):
-```bash
-clawdbot agent --mode rpc --model anthropic/claude-opus-4-5 ""
-```
+- Seeds `AGENTS.md`, `BOOTSTRAP.md`, `IDENTITY.md`, `USER.md`
+- Runs a short Q&A ritual (one question at a time)
+- Writes identity + preferences to `IDENTITY.md`, `USER.md`, `SOUL.md`
+- Removes `BOOTSTRAP.md` when finished so it only runs once
-If the user skips auth, onboarding should be clear: the agent likely won’t respond until auth is configured.
+## Optional: Gmail hooks (manual)
-## 4) Onboarding chat (dedicated session)
-
-The onboarding flow now embeds the SwiftUI chat view directly. It uses a **special session key**
-(`onboarding`) so the “newborn agent” ritual stays separate from the main chat.
-
-This onboarding chat is where the agent:
-- does the BOOTSTRAP.md identity ritual (one question at a time)
-- visits **soul.md** with the user and writes `SOUL.md` (values, tone, boundaries)
-- asks how the user wants to talk (web-only / Telegram / WhatsApp)
-- guides linking steps (including showing a QR inline for WhatsApp via the `whatsapp_login` tool)
-
-If the workspace bootstrap is already complete (BOOTSTRAP.md removed), the onboarding chat step is skipped.
-
-## 2.5) Optional: Connect Gmail
-
-The macOS onboarding includes an optional Gmail step. It runs:
+Gmail Pub/Sub setup is currently a manual step. Use:
```bash
clawdbot hooks gmail setup --account you@gmail.com
```
-This writes the full `hooks.gmail` config, installs `gcloud` / `gog` / `tailscale`
-via Homebrew if needed, and configures the Pub/Sub push endpoint. After setup,
-restart the gateway so the internal Gmail watcher starts.
+See [/automation/gmail-pubsub](/automation/gmail-pubsub) for details.
-Once setup is complete, the user can switch to the normal chat (`main`) via the menu bar panel.
+## Remote mode notes
-## 5) Agent bootstrap ritual (outside onboarding)
+When the Gateway runs on another machine, credentials and workspace files live
+**on that host**. If you need OAuth in remote mode, create:
-We no longer collect identity in the onboarding wizard. Instead, the **first agent run** performs a playful bootstrap ritual using files in the workspace:
+- `~/.clawdbot/credentials/oauth.json`
+- `~/.clawdbot/agents//agent/auth-profiles.json`
-- Workspace is created implicitly (default `~/clawd`, configurable via `agent.workspace`) when local is selected,
- but only if the folder is empty or already contains `AGENTS.md`.
-- Files are seeded: `AGENTS.md`, `BOOTSTRAP.md`, `IDENTITY.md`, `USER.md`.
-- `BOOTSTRAP.md` tells the agent to keep it conversational:
- - open with a cute hello
- - ask **one question at a time** (no multi-question bombardment)
- - offer a small set of suggestions where helpful (name, creature, emoji)
- - wait for the user’s reply before asking the next question
-- The agent writes results to:
- - `IDENTITY.md` (agent name, vibe/creature, emoji)
- - `USER.md` (who the user is + how they want to be addressed)
- - `SOUL.md` (identity, tone, boundaries — crafted from the soul.md prompt)
- - `~/.clawdbot/clawdbot.json` (structured identity defaults)
-- After the ritual, the agent **deletes `BOOTSTRAP.md`** so it only runs once.
-
-Identity data still feeds the same defaults as before:
-
-- outbound prefix emoji (`messages.responsePrefix`)
-- group mention patterns / wake words
-- default session intro (“You are Samantha…”)
-- macOS UI labels
-
-## 6) Workspace notes (no explicit onboarding step)
-
-The workspace is created automatically as part of agent bootstrap (no dedicated onboarding screen).
-
-Recommendation: treat the workspace as the agent’s “memory” and make it a git repo (ideally private) so identity + memories are backed up:
-
-```bash
-cd ~/clawd
-git init
-git add AGENTS.md
-git commit -m "Add agent workspace"
-```
-
-Daily memory lives under `memory/` in the workspace:
-- one file per day: `memory/YYYY-MM-DD.md`
-- read today + yesterday on session start
-- keep it short (durable facts, preferences, decisions; avoid secrets)
-
-## Remote mode note (why OAuth is hidden)
-
-If the Gateway runs on another machine, OAuth credentials must be created/stored on that host (where the agent runtime runs).
-
-For now, remote onboarding should:
-- explain why OAuth isn't shown
-- point the user at the credential location (`~/.clawdbot/credentials/oauth.json`) and the auth profile store (`~/.clawdbot/agents//agent/auth-profiles.json`) on the gateway host
-- mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files)
-
-### Manual credential setup
-
-On the gateway host, create `~/.clawdbot/credentials/oauth.json` with this exact format:
-
-```json
-{
- "anthropic": { "type": "oauth", "access": "sk-ant-oat01-...", "refresh": "sk-ant-ort01-...", "expires": 1767304352803 },
- "openai-codex": { "type": "oauth", "access": "eyJhbGciOi...", "refresh": "oai-refresh-...", "expires": 1767304352803, "accountId": "acct_..." }
-}
-```
-
-Set permissions: `chmod 600 ~/.clawdbot/credentials/oauth.json`
-
-**Note:** Clawdbot can import from legacy pi-coding-agent paths (`~/.pi/agent/oauth.json`, etc.), but Claude Code/Codex CLI credentials live in different files.
-
-### Using Claude Code + Codex CLI credentials (direct)
-
-If these CLIs are installed on the **gateway host** and you’ve already signed in, Clawdbot auto-syncs their OAuth tokens into the per-agent auth profile store (`~/.clawdbot/agents//agent/auth-profiles.json`) on load:
-
-- **Claude Code**: reads `~/.claude/.credentials.json` → profile `anthropic:claude-cli`
-- **Codex CLI**: reads `~/.codex/auth.json` → profile `openai-codex:codex-cli`
-
-Verification:
-
-```bash
-clawdbot providers list
-```
-
-### Fallback: convert Claude Code credentials into `oauth.json`
-
-If you don’t want to install Claude Code on the gateway host, you can still seed the legacy import file:
-
-```bash
-cat ~/.claude/.credentials.json | jq '{
- anthropic: {
- type: "oauth",
- access: .claudeAiOauth.accessToken,
- refresh: .claudeAiOauth.refreshToken,
- expires: .claudeAiOauth.expiresAt
- }
-}' > ~/.clawdbot/credentials/oauth.json
-chmod 600 ~/.clawdbot/credentials/oauth.json
-```
-
-## Workspace backup (recommended)
-
-We suggest creating a **private GitHub repository** to back up the agent
-workspace. The agent is really good at keeping a git repo in shape, and GitHub
-is the perfect place for it. Keep it **private**.
-
-Setup steps: https://docs.clawd.bot/concepts/agent-workspace
+on the gateway host.
diff --git a/docs/start/pairing.md b/docs/start/pairing.md
index 949e5ed80..35cc9088d 100644
--- a/docs/start/pairing.md
+++ b/docs/start/pairing.md
@@ -43,10 +43,6 @@ Stored under `~/.clawdbot/credentials/`:
Treat these as sensitive (they gate access to your assistant).
-### Source of truth (code)
-
-- DM pairing storage + code generation: [`src/pairing/pairing-store.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/pairing/pairing-store.ts)
-- CLI commands: [`src/cli/pairing-cli.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/pairing-cli.ts)
## 2) Node pairing (iOS/Android nodes joining the gateway)
@@ -70,11 +66,6 @@ Stored under `~/.clawdbot/nodes/`:
Full protocol + design notes: [Gateway pairing](/gateway/pairing)
-### Source of truth (code)
-
-- Node pairing store (pending/paired + token issuance): [`src/infra/node-pairing.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/infra/node-pairing.ts)
-- Gateway methods/events (`node.pair.*`): [`src/gateway/server-methods/nodes.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/gateway/server-methods/nodes.ts)
-- CLI: [`src/cli/nodes-cli.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/nodes-cli.ts)
## Related docs
diff --git a/docs/start/setup.md b/docs/start/setup.md
index e92b1a12a..22dc9e172 100644
--- a/docs/start/setup.md
+++ b/docs/start/setup.md
@@ -55,7 +55,7 @@ clawdbot providers login
clawdbot health
```
-If onboarding is still WIP/broken on your build:
+If onboarding is not available in your build:
- Run `clawdbot setup`, then `clawdbot providers login`, then start the Gateway manually (`clawdbot gateway`).
## Bleeding edge workflow (Gateway in a terminal)
@@ -77,7 +77,7 @@ pnpm install
pnpm gateway:watch
```
-`gateway:watch` runs `src/entry.ts gateway --force` and reloads on [`src/**/*.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/**/*.ts) changes.
+`gateway:watch` runs the gateway in watch mode and reloads on TypeScript changes.
### 2) Point the macOS app at your running Gateway
@@ -128,5 +128,5 @@ user service (no lingering needed). See [`docs/gateway.md`](/gateway) for the sy
- [`docs/gateway.md`](/gateway) (Gateway runbook; flags, supervision, ports)
- [`docs/configuration.md`](/gateway/configuration) (config schema + examples)
- [`docs/discord.md`](/providers/discord) and [`docs/telegram.md`](/providers/telegram) (reply tags + replyToMode settings)
-- [`docs/clawd.md`](/start/clawd) (personal assistant setup)
+- [`docs/clawd.md`](/start/clawd) (Clawdbot assistant setup)
- [`docs/macos.md`](/platforms/macos) (macOS app behavior; gateway lifecycle + “Attach only”)
diff --git a/docs/start/showcase.md b/docs/start/showcase.md
index b2239171b..8efce06b8 100644
--- a/docs/start/showcase.md
+++ b/docs/start/showcase.md
@@ -1,6 +1,7 @@
---
title: "Showcase"
description: "Real-world Clawdbot projects from the community"
+summary: "Community-built projects and integrations powered by Clawdbot"
---
# Showcase
@@ -43,10 +44,28 @@ Real projects from the community. See what people are building with Clawdbot.
**@davekiss** • `telegram` `website` `migration` `astro`
-
+
Rebuilt entire personal site via Telegram while watching Netflix — Notion → Astro, 18 posts migrated, DNS to Cloudflare. Never opened a laptop.
+
+ **@attol8** • `automation` `api` `skill`
+
+ Searches job listings, matches against CV keywords, and returns relevant opportunities with links. Built in 30 minutes using JSearch API.
+
+
+
+ **@bheem1798** • `finance` `browser` `automation`
+
+ Logs into TradingView via browser automation, screenshots charts, and performs technical analysis on demand. No API needed—just browser control.
+
+
+
+ **@henrymascot** • `slack` `automation` `support`
+
+ Watches company Slack channel, responds helpfully, and forwards notifications to Telegram. Autonomously fixed a production bug in a deployed app without being asked.
+
+
## 🧠 Knowledge & Memory
diff --git a/docs/start/wizard.md b/docs/start/wizard.md
index d3c355a1b..0e1cb6e90 100644
--- a/docs/start/wizard.md
+++ b/docs/start/wizard.md
@@ -70,7 +70,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- Full reset (also removes workspace)
2) **Model/Auth**
- - **Anthropic OAuth (Claude CLI)**: if `~/.claude/.credentials.json` exists, the wizard can reuse it.
+ - **Anthropic OAuth (Claude CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
- **Anthropic OAuth (recommended)**: browser flow; paste the `code#state`.
- **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
- **OpenAI Codex OAuth**: browser flow; paste the `code#state`.
diff --git a/docs/tools/agent-send.md b/docs/tools/agent-send.md
index 2443716f8..b01848ecb 100644
--- a/docs/tools/agent-send.md
+++ b/docs/tools/agent-send.md
@@ -1,21 +1,44 @@
---
-summary: "Design notes for a direct `clawdbot agent` CLI subcommand without WhatsApp delivery"
+summary: "Direct `clawdbot agent` CLI runs (with optional delivery)"
read_when:
- Adding or modifying the agent CLI entrypoint
---
-# `clawdbot agent` (direct-to-agent invocation)
+# `clawdbot agent` (direct agent runs)
-`clawdbot agent` lets you talk to the **embedded** agent runtime directly (no chat send unless you opt in), while reusing the same session store and thinking/verbose persistence as inbound auto-replies.
+`clawdbot agent` runs a single agent turn without needing an inbound chat message.
+By default it goes **through the Gateway**; add `--local` to force the embedded
+runtime on the current machine.
## Behavior
+
- Required: `--message `
- Session selection:
- - If `--session-id` is given, reuse it.
- - Else if `--to ` is given, derive the session key from `session.scope` (direct chats collapse to `main`, or `global` when scope is global).
-- Runs the embedded Pi agent (configured via `agent`).
-- Thinking/verbose:
- - Flags `--thinking ` and `--verbose ` persist into the session store.
+ - `--to ` derives the session key (normal direct-chat routing), **or**
+ - `--session-id ` reuses an existing session by id
+- Runs the same embedded agent runtime as normal inbound replies.
+- Thinking/verbose flags persist into the session store.
- Output:
- - Default: prints text (and `MEDIA:` lines) to stdout.
- - `--json`: prints structured payloads + meta.
-- Optional: `--deliver` sends the reply back to the selected provider (`whatsapp`, `telegram`, `discord`, `signal`, `imessage`).
+ - default: prints reply text (plus `MEDIA:` lines)
+ - `--json`: prints structured payload + metadata
+- Optional delivery back to a provider with `--deliver` + `--provider`.
+
+If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
+
+## Examples
+
+```bash
+clawdbot agent --to +15555550123 --message "status update"
+clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
+clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
+clawdbot agent --to +15555550123 --message "Summon reply" --deliver
+```
+
+## Flags
+
+- `--local`: run locally (requires provider keys in your shell)
+- `--deliver`: send the reply to the chosen provider (requires `--to`)
+- `--provider`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`)
+- `--thinking `: persist thinking level
+- `--verbose `: persist verbose level
+- `--timeout `: override agent timeout
+- `--json`: output structured JSON
diff --git a/docs/tools/bash.md b/docs/tools/bash.md
index 73106a1e5..3c2aef6bc 100644
--- a/docs/tools/bash.md
+++ b/docs/tools/bash.md
@@ -17,8 +17,9 @@ Background sessions are scoped per agent; `process` only sees sessions from the
- `yieldMs` (default 10000): auto-background after delay
- `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill on expiry
-- `elevated` (bool): run on host if elevated mode is enabled/allowed
+- `elevated` (bool): run on host if elevated mode is enabled/allowed (only changes behavior when the agent is sandboxed)
- Need a real TTY? Use the tmux skill.
+Note: `elevated` is ignored when sandboxing is off (bash already runs on the host).
## Examples
diff --git a/docs/tools/browser.md b/docs/tools/browser.md
index aaf698498..5c31fc3bb 100644
--- a/docs/tools/browser.md
+++ b/docs/tools/browser.md
@@ -1,254 +1,142 @@
---
-summary: "Spec: integrated browser control server + action commands"
+summary: "Integrated browser control server + action commands"
read_when:
- Adding agent-controlled browser automation
- Debugging why clawd is interfering with your own Chrome
- Implementing browser settings + lifecycle in the macOS app
---
-# Browser (integrated) — clawd-managed Chrome
+# Browser (clawd-managed)
-Status: draft spec · Date: 2025-12-20
+Clawdbot can run a **dedicated Chrome/Chromium profile** that the agent controls.
+It is isolated from your personal browser and is managed through a small local
+control server.
-Goal: give the **clawd** persona its own browser that is:
-- Visually distinct (lobster-orange, profile labeled "clawd").
-- Fully agent-manageable (start/stop, list tabs, focus/close tabs, open URLs, screenshot).
-- Non-interfering with the user's own browser (separate profile + dedicated ports).
+## What you get
-This doc covers the macOS app/gateway side. It intentionally does not mandate
-Playwright vs Puppeteer; the key is the **contract** and the **separation guarantees**.
+- A separate browser profile named **clawd** (orange accent by default).
+- Deterministic tab control (list/open/focus/close).
+- Agent actions (click/type/drag/select), snapshots, screenshots, PDFs.
+- Optional multi-profile support (`clawd`, `work`, `remote`, ...).
-## User-facing settings
+This browser is **not** your daily driver. It is a safe, isolated surface for
+agent automation and verification.
-Add a dedicated settings section (preferably under **Skills** or its own "Browser" tab):
+## Quick start
-- **Enable clawd browser** (`default: on`)
- - When off: no browser is launched, and browser tools return "disabled".
-- **Browser control URL** (`default: http://127.0.0.1:18791`)
- - Interpreted as the base URL of the local/remote browser-control server.
- - If the URL host is not loopback, Clawdbot must **not** attempt to launch a local
- browser; it only connects.
-- **CDP URL** (`default: controlUrl + 1`)
- - Base URL for Chrome DevTools Protocol (e.g. `http://127.0.0.1:18792`).
- - Set this to a non-loopback host to attach the local control server to a remote
- Chrome/Chromium CDP endpoint (SSH/Tailscale tunnel recommended).
- - If the CDP URL host is non-loopback, clawd does **not** auto-launch a local browser.
- - If you tunnel a remote CDP to `localhost`, set **Attach to existing only** to
- avoid accidentally launching a local browser.
-- **Accent color** (`default: #FF4500`, "lobster-orange")
- - Used to theme the clawd browser profile (best-effort) and to tint UI indicators
- in Clawdbot.
+```bash
+clawdbot browser status
+clawdbot browser start
+clawdbot browser open https://example.com
+clawdbot browser snapshot
+```
-Optional (advanced, can be hidden behind Debug initially):
-- **Use headless browser** (`default: off`)
-- **Attach to existing only** (`default: off`) — if on, never launch; only connect if
- already running.
-- **Browser executable path** (override, optional)
-- **No sandbox** (`default: off`) — adds `--no-sandbox` + `--disable-setuid-sandbox`
+If you get “Browser disabled”, enable it in config (see below) and restart the
+Gateway.
-### Port convention
+## Configuration
-Clawdbot already uses:
-- Gateway WebSocket: `18789`
-- Bridge (voice/node): `18790`
+Browser settings live in `~/.clawdbot/clawdbot.json`.
-For the clawd browser-control server, use "family" ports:
-- Browser control HTTP API: `18791` (bridge + 1)
-- Browser CDP/debugging port: `18792` (control + 1)
-- Canvas host HTTP: `18793` by default, mounted at `/__clawdbot__/canvas/`
-
-The user usually only configures the **control URL** (port `18791`). CDP is an
-internal detail.
-
-## Browser isolation guarantees (non-negotiable)
-
-1) **Dedicated user data dir**
- - Never attach to or reuse the user's default Chrome profile.
- - Store clawd browser state under an app-owned directory, e.g.:
- - `~/Library/Application Support/Clawdbot/browser/clawd/` (mac app)
- - or `~/.clawdbot/browser/clawd/` (gateway/CLI)
-
-2) **Dedicated ports**
- - Never use `9222` (reserved for ad-hoc dev workflows; avoids colliding with
- `agent-tools/browser-tools`).
- - Default ports are `18791/18792` unless overridden.
-
-3) **Named tab/page management**
- - The agent must be able to enumerate and target tabs deterministically (by
- stable `targetId` or equivalent), not "last tab".
-
-## Browser selection (macOS + Linux)
-
-On startup (when enabled + local URL), Clawdbot chooses the browser executable
-in this order:
-1) **Google Chrome Canary** (if installed)
-2) **Chromium** (if installed)
-3) **Google Chrome** (fallback)
-
-Linux:
-- Looks for `google-chrome` / `chromium` in common system paths.
-- Use **Browser executable path** to force a specific binary.
-
-Implementation detail:
-- macOS: detection is by existence of the `.app` bundle under `/Applications`
- (and optionally `~/Applications`), then using the resolved executable path.
-- Linux: common `/usr/bin`/`/snap/bin` paths.
-
-Rationale:
-- Canary/Chromium are easy to visually distinguish from the user's daily driver.
-- Chrome fallback ensures the feature works on a stock machine.
-
-## Visual differentiation ("lobster-orange")
-
-The clawd browser should be obviously different at a glance:
-- Profile name: **clawd**
-- Profile color: **#FF4500**
-
-Preferred behavior:
-- Seed/patch the profile's preferences on first launch so the color + name persist.
-
-Fallback behavior:
-- If preferences patching is not reliable, open with the dedicated profile and let
- the user set the profile color/name once via Chrome UI; it must persist because
- the `userDataDir` is persistent.
-
-## Control server contract (vNext)
-
-Expose a small local HTTP API (and/or gateway RPC surface) so the agent can manage
-state without touching the user's Chrome.
-
-Basics:
-- `GET /` status payload (enabled/running/pid/cdpPort/etc)
-- `POST /start` start browser
-- `POST /stop` stop browser
-- `GET /tabs` list tabs
-- `POST /tabs/open` open a new tab
-- `POST /tabs/focus` focus a tab by id/prefix
-- `DELETE /tabs/:targetId` close a tab by id/prefix
-
-Inspection:
-- `POST /screenshot` `{ targetId?, fullPage?, ref?, element?, type? }`
-- `GET /snapshot` `?format=aria|ai&targetId?&limit?`
-- `GET /console` `?level?&targetId?`
-- `POST /pdf` `{ targetId? }`
-
-Actions:
-- `POST /navigate`
-- `POST /act` `{ kind, targetId?, ... }` where `kind` is one of:
- - `click`, `type`, `press`, `hover`, `drag`, `select`, `fill`, `wait`, `resize`, `close`, `evaluate`
-
-Hooks (arming):
-- `POST /hooks/file-chooser` `{ targetId?, paths, timeoutMs? }`
-- `POST /hooks/dialog` `{ targetId?, accept, promptText?, timeoutMs? }`
-
-### "Is it open or closed?"
-
-"Open" means:
-- the control server is reachable at the configured URL **and**
-- it reports a live browser connection.
-
-"Closed" means:
-- control server not reachable, or server reports no browser.
-
-Clawdbot should treat "open/closed" as a health check (fast path), not by scanning
-global Chrome processes (avoid false positives).
-
-## Multi-profile support
-
-Clawdbot supports multiple named browser profiles, each with:
-- Dedicated CDP port (auto-allocated from 18800-18899) **or** a per-profile CDP URL
-- Persistent user data directory (`~/.clawdbot/browser//user-data/`)
-- Unique color for visual distinction
-
-### Configuration
-
-```json
+```json5
{
- "browser": {
- "enabled": true,
- "defaultProfile": "clawd",
- "profiles": {
- "clawd": { "cdpPort": 18800, "color": "#FF4500" },
- "work": { "cdpPort": 18801, "color": "#0066CC" },
- "remote": { "cdpUrl": "http://10.0.0.42:9222", "color": "#00AA00" }
+ browser: {
+ enabled: true, // default: true
+ controlUrl: "http://127.0.0.1:18791",
+ cdpUrl: "http://127.0.0.1:18792", // defaults to controlUrl + 1
+ defaultProfile: "clawd",
+ color: "#FF4500",
+ headless: false,
+ noSandbox: false,
+ attachOnly: false,
+ executablePath: "/Applications/Chromium.app/Contents/MacOS/Chromium",
+ profiles: {
+ clawd: { cdpPort: 18800, color: "#FF4500" },
+ work: { cdpPort: 18801, color: "#0066CC" },
+ remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }
}
}
}
```
-### Profile actions
+Notes:
+- `controlUrl` defaults to `http://127.0.0.1:18791`.
+- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
+ the default browser ports shift to stay in the same “family” (control = gateway + 2).
+- `cdpUrl` defaults to `controlUrl + 1` when unset.
+- `attachOnly: true` means “never launch Chrome; only attach if it is already running.”
-- `GET /profiles` — list all profiles with status
-- `POST /profiles/create` `{ name, color?, cdpUrl? }` — create new profile (auto-allocates port if no `cdpUrl`)
-- `DELETE /profiles/:name` — delete profile (stops browser + removes user data for local profiles)
-- `POST /reset-profile?profile=` — kill orphan process on profile's port (local profiles only)
+## Local vs remote control
-### Profile parameter
+- **Local control (default):** `controlUrl` is loopback (`127.0.0.1`/`localhost`).
+ The Gateway starts the control server and can launch Chrome.
+- **Remote control:** `controlUrl` is non-loopback. The Gateway **does not** start
+ a local server; it assumes you are pointing at an existing server elsewhere.
+- **Remote CDP:** set `browser.profiles..cdpUrl` (or `browser.cdpUrl`) to
+ attach to a remote Chrome. In this case, Clawdbot will not launch a local browser.
-All existing endpoints accept optional `?profile=` query parameter:
-- `GET /?profile=work` — status for work profile
-- `POST /start?profile=work` — start work profile browser
-- `GET /tabs?profile=work` — list tabs for work profile
-- `POST /tabs/open?profile=work` — open tab in work profile
-- etc.
+## Profiles (multi-browser)
-When `profile` is omitted, uses `browser.defaultProfile` (defaults to "clawd").
+Clawdbot supports multiple named profiles. Each profile has its own:
+- user data directory
+- CDP port (local) or CDP URL (remote)
+- accent color
-### Agent browser tool
+Defaults:
+- The `clawd` profile is auto-created if missing.
+- Local CDP ports allocate from **18800–18899** by default.
+- Deleting a profile moves its local data directory to Trash.
-The `browser` tool accepts an optional `profile` parameter for all actions:
+All control endpoints accept `?profile=`; the CLI uses `--browser-profile`.
-```json
-{
- "action": "open",
- "targetUrl": "https://example.com",
- "profile": "work"
-}
-```
+## Isolation guarantees
-This routes the operation to the specified profile's browser instance. Omitting
-`profile` uses the default profile.
+- **Dedicated user data dir**: never touches your personal Chrome profile.
+- **Dedicated ports**: avoids `9222` to prevent collisions with dev workflows.
+- **Deterministic tab control**: target tabs by `targetId`, not “last tab”.
-### Profile naming rules
+## Browser selection
-- Lowercase alphanumeric characters and hyphens only
-- Must start with a letter or number (not a hyphen)
-- Maximum 64 characters
-- Examples: `clawd`, `work`, `my-project-1`
+When launching locally, Clawdbot picks the first available:
+1. Chrome Canary
+2. Chromium
+3. Chrome
-### Port allocation
+You can override with `browser.executablePath`.
-Ports are allocated from range 18800-18899 (~100 profiles max). This is far more
-than practical use — memory and CPU exhaustion occur well before port exhaustion.
-Ports are allocated once at profile creation and persisted permanently.
-Remote profiles are attach-only and do **not** use the local port range.
-## Interaction with the agent (clawd)
+Platforms:
+- macOS: checks `/Applications` and `~/Applications`.
+- Linux: looks for `google-chrome`, `chromium`, etc.
+- Windows: checks common install locations.
-The agent should use browser tools only when:
-- enabled in settings
-- control URL is configured
+## Control API (optional)
-If disabled, tools must fail fast with a friendly error ("Browser disabled in settings").
+If you want to integrate directly, the browser control server exposes a small
+HTTP API:
-The agent should not assume tabs are ephemeral. It should:
-- call `browser.tabs.list` to discover existing tabs first
-- reuse an existing tab when appropriate (e.g. a persistent "main" tab)
-- avoid opening duplicate tabs unless asked
+- Status/start/stop: `GET /`, `POST /start`, `POST /stop`
+- Tabs: `GET /tabs`, `POST /tabs/open`, `POST /tabs/focus`, `DELETE /tabs/:targetId`
+- Snapshot/screenshot: `GET /snapshot`, `POST /screenshot`
+- Actions: `POST /navigate`, `POST /act`
+- Hooks: `POST /hooks/file-chooser`, `POST /hooks/dialog`
+- Debugging: `GET /console`, `POST /pdf`
-## CLI quick reference (one example each)
+All endpoints accept `?profile=`.
-All commands accept `--browser-profile ` to target a specific profile (default: `clawd`).
+### Playwright requirement
+
+Some features (navigate/act/ai snapshot, element screenshots, PDF) require
+Playwright. In embedded gateway builds, Playwright may be unavailable; those
+endpoints return a clear 501 error. ARIA snapshots and basic screenshots still work.
+
+## CLI quick reference
+
+All commands accept `--browser-profile ` to target a specific profile.
-Profile management:
-- `clawdbot browser profiles`
-- `clawdbot browser create-profile --name work`
-- `clawdbot browser create-profile --name remote --cdp-url http://10.0.0.42:9222`
-- `clawdbot browser delete-profile --name work`
Basics:
- `clawdbot browser status`
- `clawdbot browser start`
- `clawdbot browser stop`
-- `clawdbot browser reset-profile`
- `clawdbot browser tabs`
- `clawdbot browser open https://example.com`
- `clawdbot browser focus abcd1234`
@@ -260,6 +148,8 @@ Inspection:
- `clawdbot browser screenshot --ref 12`
- `clawdbot browser snapshot`
- `clawdbot browser snapshot --format aria --limit 200`
+- `clawdbot browser console --level error`
+- `clawdbot browser pdf`
Actions:
- `clawdbot browser navigate https://example.com`
@@ -271,39 +161,27 @@ Actions:
- `clawdbot browser drag 10 11`
- `clawdbot browser select 9 OptionA OptionB`
- `clawdbot browser upload /tmp/file.pdf`
-- `clawdbot browser fill --fields '[{\"ref\":\"1\",\"value\":\"Ada\"}]'`
+- `clawdbot browser fill --fields '[{"ref":"1","type":"text","value":"Ada"}]'`
- `clawdbot browser dialog --accept`
- `clawdbot browser wait --text "Done"`
- `clawdbot browser evaluate --fn '(el) => el.textContent' --ref 7`
-- `clawdbot browser evaluate --fn "document.querySelector('.my-class').click()"`
-- `clawdbot browser console --level error`
-- `clawdbot browser pdf`
Notes:
-- `upload` and `dialog` are **arming** calls; run them before the click/press that triggers the chooser/dialog.
-- `upload` can take a `ref` to auto-click after arming (useful for single-step file uploads).
-- `upload` can also take `inputRef` (aria ref) or `element` (CSS selector) to set `` directly without waiting for a file chooser.
-- The arm default timeout is **2 minutes** (clamped to max 2 minutes); pass `timeoutMs` if you need shorter.
-- `snapshot` defaults to `ai`; `aria` returns an accessibility tree for debugging.
-- `click`/`type` require `ref` from `snapshot --format ai`; use `evaluate` for rare CSS selector one-offs.
-- Avoid `wait` by default; use it only in exceptional cases when there is no reliable UI state to wait on.
+- `upload` and `dialog` are **arming** calls; run them before the click/press
+ that triggers the chooser/dialog.
+- `upload` can also set file inputs directly via `--input-ref` or `--element`.
+- `snapshot` defaults to `ai` when available; use `--format aria` for the
+ accessibility tree.
+- `click`/`type` require a `ref` from `snapshot` (CSS selectors are intentionally
+ not supported for actions).
-## Security & privacy notes
+## Security & privacy
-- The clawd browser profile is app-owned; it may contain logged-in sessions.
- Treat it as sensitive data.
-- The control server must bind to loopback only by default (`127.0.0.1`) unless the
- user explicitly configures a non-loopback URL.
-- Never reuse or copy the user's default Chrome profile.
-- Remote CDP endpoints should be tunneled or protected; CDP is highly privileged.
-
-## Non-goals (for the first cut)
-
-- Cross-device "sync" of tabs between Mac and Pi.
-- Sharing the user's logged-in Chrome sessions automatically.
-- General-purpose web scraping; this is primarily for "close-the-loop" verification
- and interaction.
+- The clawd browser profile may contain logged-in sessions; treat it as sensitive.
+- Keep control URLs loopback-only unless you intentionally expose the server.
+- Remote CDP endpoints are powerful; tunnel and protect them.
## Troubleshooting
-For Linux-specific issues (especially Ubuntu with snap Chromium), see [browser-linux-troubleshooting](/tools/browser-linux-troubleshooting).
+For Linux-specific issues (especially snap Chromium), see
+[Browser troubleshooting](/tools/browser-linux-troubleshooting).
diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md
index a88d5ea9c..482341f54 100644
--- a/docs/tools/elevated.md
+++ b/docs/tools/elevated.md
@@ -7,9 +7,27 @@ read_when:
## What it does
- Elevated mode allows the bash tool to run with elevated privileges when the feature is available and the sender is approved.
+- **Optional for sandboxed agents**: elevated only changes behavior when the agent is running in a sandbox. If the agent already runs unsandboxed, elevated is effectively a no-op.
- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`.
- Only `on|off` are accepted; anything else returns a hint and does not change state.
+## What it controls (and what it doesn’t)
+- **Global availability gate**: `agent.elevated` is global (not per-agent). If disabled or sender not allowlisted, elevated is unavailable everywhere.
+- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
+- **Inline directive**: `/elevated on` inside a message applies to that message only.
+- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned.
+- **Host execution**: elevated runs `bash` on the host (bypasses sandbox).
+- **Unsandboxed agents**: when there is no sandbox to bypass, elevated does not change where `bash` runs.
+- **Tool policy still applies**: if `bash` is denied by tool policy, elevated cannot be used.
+
+Note:
+- Sandbox on: `/elevated on` runs that `bash` command on the host.
+- Sandbox off: `/elevated on` does not change execution (already on host).
+
+## When elevated matters
+- Only impacts `bash` when the agent is running sandboxed (it drops the sandbox for that command).
+- For unsandboxed agents, elevated does not change execution; it only affects gating, logging, and status.
+
## Resolution order
1. Inline directive on the message (applies only to that message).
2. Session override (set by sending a directive-only message).
diff --git a/docs/tools/index.md b/docs/tools/index.md
index b29712258..45ae122ee 100644
--- a/docs/tools/index.md
+++ b/docs/tools/index.md
@@ -1,8 +1,8 @@
---
-summary: "Agent tool surface for Clawdbot (browser, canvas, nodes, cron) replacing clawdbot-* skills"
+summary: "Agent tool surface for Clawdbot (browser, canvas, nodes, cron) replacing legacy `clawdbot-*` skills"
read_when:
- Adding or modifying agent tools
- - Retiring or changing clawdbot-* skills
+ - Retiring or changing `clawdbot-*` skills
---
# Tools (Clawdbot)
@@ -36,13 +36,15 @@ Core parameters:
- `yieldMs` (auto-background after timeout, default 10000)
- `background` (immediate background)
- `timeout` (seconds; kills the process if exceeded, default 1800)
-- `elevated` (bool; run on host if elevated mode is enabled/allowed)
+- `elevated` (bool; run on host if elevated mode is enabled/allowed; only changes behavior when the agent is sandboxed)
- Need a real TTY? Use the tmux skill.
Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded.
- Use `process` to poll/log/write/kill/clear background sessions.
- If `process` is disallowed, `bash` runs synchronously and ignores `yieldMs`/`background`.
+- `elevated` is gated by `agent.elevated` (global sender allowlist) and runs on the host.
+- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
### `process`
Manage background bash sessions.
@@ -233,7 +235,7 @@ Notes:
- `reactions` returns per-emoji user lists (limited to 100 per reaction).
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
- `discord.actions.*` gates Discord tool actions; `roles` + `moderation` default to `false`.
-- `searchMessages` follows the Discord preview spec (limit max 25, channel/author filters accept arrays).
+- `searchMessages` follows the Discord preview feature constraints (limit max 25, channel/author filters accept arrays).
- The tool is only exposed when the current provider is Discord.
### `whatsapp`
@@ -293,25 +295,12 @@ Node targeting:
- Respect user consent for camera/screen capture.
- Use `status/describe` to ensure permissions before invoking media commands.
-## How the model sees tools (pi-mono internals)
+## How tools are presented to the agent
-Tools are exposed to the model in **two parallel channels**:
+Tools are exposed in two parallel channels:
-1) **System prompt text**: a human-readable list + guidelines.
-2) **Provider tool schema**: the actual function/tool declarations sent to the model API.
+1) **System prompt text**: a human-readable list + guidance.
+2) **Tool schema**: the structured function definitions sent to the model API.
-In pi-mono:
-- System prompt builder: [`packages/coding-agent/src/core/system-prompt.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/system-prompt.ts)
- - Builds the `Available tools:` list from `toolDescriptions`.
- - Appends skills and project context.
-- Tool schemas passed to providers:
- - OpenAI: [`packages/ai/src/providers/openai-responses.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/openai-responses.ts) (`convertTools`)
- - Anthropic: [`packages/ai/src/providers/anthropic.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/anthropic.ts) (`convertTools`)
- - Gemini: [`packages/ai/src/providers/google-shared.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/providers/google-shared.ts) (`convertTools`)
-- Tool execution loop:
- - Agent loop: [`packages/ai/src/agent/agent-loop.ts`](https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/agent/agent-loop.ts)
- - Validates tool arguments and executes tools, then appends `toolResult` messages.
-
-In Clawdbot:
-- System prompt append: [`src/agents/system-prompt.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/agents/system-prompt.ts)
-- Tool list injected via `createClawdbotCodingTools()` in [`src/agents/pi-tools.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/agents/pi-tools.ts)
+That means the agent sees both “what tools exist” and “how to call them.” If a tool
+doesn’t appear in the system prompt or the schema, the model cannot call it.
diff --git a/docs/tools/reactions.md b/docs/tools/reactions.md
index ad33976e8..21fe37e0a 100644
--- a/docs/tools/reactions.md
+++ b/docs/tools/reactions.md
@@ -1,3 +1,8 @@
+---
+summary: "Reaction semantics shared across providers"
+read_when:
+ - Working on reactions in any provider
+---
# Reaction tooling
Shared reaction semantics across providers:
diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md
index 3653bbd22..fa815cbfe 100644
--- a/docs/tools/slash-commands.md
+++ b/docs/tools/slash-commands.md
@@ -7,7 +7,9 @@ read_when:
# Slash commands
Commands are handled by the Gateway. Send them as a **standalone** message that starts with `/`.
-Inline text like `hello /status` is ignored.
+Inline text like `hello /status` is ignored for commands.
+
+Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even when inline and are stripped from the message before the model sees it.
## Config
diff --git a/docs/tui.md b/docs/tui.md
new file mode 100644
index 000000000..c164ec129
--- /dev/null
+++ b/docs/tui.md
@@ -0,0 +1,117 @@
+---
+summary: "Terminal UI (TUI): connect to the Gateway from any machine"
+read_when:
+ - You want a beginner-friendly walkthrough of the TUI
+ - You need the complete list of TUI features, commands, and shortcuts
+---
+# TUI (Terminal UI)
+
+## Quick start
+1) Start the Gateway.
+```bash
+clawdbot gateway
+```
+2) Open the TUI.
+```bash
+clawdbot tui
+```
+3) Type a message and press Enter.
+
+Remote Gateway:
+```bash
+clawdbot tui --url ws://: --token
+```
+Use `--password` if your Gateway uses password auth.
+
+## What you see
+- Header: connection URL, current agent, current session.
+- Chat log: user messages, assistant replies, system notices, tool cards.
+- Status line: connection/run state (connecting, running, streaming, idle, error).
+- Footer: connection state + agent + session + model + think/verbose/reasoning + token counts + deliver.
+- Input: text editor with autocomplete.
+
+## Mental model: agents + sessions
+- Agents are unique slugs (e.g. `main`, `research`). The Gateway exposes the list.
+- Sessions belong to the current agent.
+- Session keys are stored as `agent::`.
+ - If you type `/session main`, the TUI expands it to `agent::main`.
+ - If you type `/session agent:other:main`, you switch to that agent session explicitly.
+- Session scope:
+ - `per-sender` (default): each agent has many sessions.
+ - `global`: the TUI always uses the `global` session (the picker may be empty).
+- The current agent + session are always visible in the footer.
+
+## Sending + delivery
+- Messages are sent to the Gateway; delivery to providers is off by default.
+- Turn delivery on:
+ - `/deliver on`
+ - or the Settings panel
+ - or start with `clawdbot tui --deliver`
+
+## Pickers + overlays
+- Model picker: list available models and set the session override.
+- Agent picker: choose a different agent.
+- Session picker: shows only sessions for the current agent.
+- Settings: toggle deliver, tool output expansion, and thinking visibility.
+
+## Keyboard shortcuts
+- Enter: send message
+- Esc: abort active run
+- Ctrl+C: clear input (press twice to exit)
+- Ctrl+D: exit
+- Ctrl+L: model picker
+- Ctrl+G: agent picker
+- Ctrl+P: session picker
+- Ctrl+O: toggle tool output expansion
+- Ctrl+T: toggle thinking visibility (reloads history)
+
+## Slash commands
+Core:
+- `/help`
+- `/status`
+- `/agent ` (or `/agents`)
+- `/session ` (or `/sessions`)
+- `/model ` (or `/models`)
+
+Session controls:
+- `/think `
+- `/verbose `
+- `/reasoning `
+- `/elevated ` (alias: `/elev`)
+- `/activation `
+- `/deliver `
+
+Session lifecycle:
+- `/new` or `/reset` (reset the session)
+- `/abort` (abort the active run)
+- `/settings`
+- `/exit`
+
+## Tool output
+- Tool calls show as cards with args + results.
+- Ctrl+O toggles between collapsed/expanded views.
+- While tools run, partial updates stream into the same card.
+
+## History + streaming
+- On connect, the TUI loads the latest history (default 200 messages).
+- Streaming responses update in place until finalized.
+- The TUI also listens to agent tool events for richer tool cards.
+
+## Connection details
+- The TUI registers with the Gateway as `mode: "tui"`.
+- Reconnects show a system message; event gaps are surfaced in the log.
+
+## Options
+- `--url `: Gateway WebSocket URL (defaults to config or `ws://127.0.0.1:`)
+- `--token `: Gateway token (if required)
+- `--password `: Gateway password (if required)
+- `--session `: Session key (default: `main`, or `global` when scope is global)
+- `--deliver`: Deliver assistant replies to the provider (default off)
+- `--thinking `: Override thinking level for sends
+- `--timeout-ms `: Agent timeout (default 30000)
+- `--history-limit `: History entries to load (default 200)
+
+## Troubleshooting
+- `disconnected`: ensure the Gateway is running and your `--url/--token/--password` are correct.
+- No agents in picker: check `clawdbot agents list` and your routing config.
+- Empty session picker: you might be in global scope or have no sessions yet.
diff --git a/docs/web/tui.md b/docs/web/tui.md
index df87d081d..b4daa0e5a 100644
--- a/docs/web/tui.md
+++ b/docs/web/tui.md
@@ -6,7 +6,6 @@ read_when:
---
# TUI (Gateway chat client)
-Updated: 2026-01-07
## What it is
- A terminal UI that connects to the Gateway WebSocket and speaks the same chat APIs as WebChat.
@@ -40,6 +39,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
- Ctrl+C: clear input (press twice to exit)
- Ctrl+D: exit
- Ctrl+L: model picker
+- Ctrl+G: agent picker
- Ctrl+P: session picker
- Ctrl+O: toggle tool output expansion
- Ctrl+T: toggle thinking visibility
@@ -47,6 +47,7 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
## Slash commands
- `/help`
- `/status`
+- `/agent ` (or `/agents`)
- `/session ` (or `/sessions`)
- `/model ` (or `/model list`, `/models`)
- `/think `
@@ -65,8 +66,3 @@ Use SSH tunneling or Tailscale to reach the Gateway WS.
## Notes
- The TUI shows Gateway chat deltas (`event: chat`) and agent tool events.
- It registers as a Gateway client with `mode: "tui"` for presence and debugging.
-
-## Files
-- CLI: [`src/cli/tui-cli.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/tui-cli.ts)
-- Runner: [`src/tui/tui.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/tui/tui.ts)
-- Gateway client: [`src/tui/gateway-chat.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/tui/gateway-chat.ts)
diff --git a/docs/web/webchat.md b/docs/web/webchat.md
index e656f1188..112b8d609 100644
--- a/docs/web/webchat.md
+++ b/docs/web/webchat.md
@@ -5,7 +5,6 @@ read_when:
---
# WebChat (Gateway WebSocket UI)
-Updated: 2026-01-06
Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts
index 9a4a14b72..c0348c0e7 100644
--- a/src/agents/auth-profiles.ts
+++ b/src/agents/auth-profiles.ts
@@ -1,3 +1,4 @@
+import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
@@ -276,10 +277,23 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
}
/**
- * Read Anthropic OAuth credentials from Claude CLI's credential file.
- * Claude CLI stores credentials at ~/.claude/.credentials.json
+ * Read Anthropic OAuth credentials from Claude CLI's keychain entry (macOS)
+ * or credential file (Linux/Windows).
+ *
+ * On macOS, Claude Code stores credentials in keychain "Claude Code-credentials".
+ * On Linux/Windows, it uses ~/.claude/.credentials.json
*/
-function readClaudeCliCredentials(): OAuthCredential | null {
+function readClaudeCliCredentials(options?: {
+ allowKeychainPrompt?: boolean;
+}): OAuthCredential | null {
+ if (process.platform === "darwin" && options?.allowKeychainPrompt !== false) {
+ const keychainCreds = readClaudeCliKeychainCredentials();
+ if (keychainCreds) {
+ log.info("read anthropic credentials from claude cli keychain");
+ return keychainCreds;
+ }
+ }
+
const credPath = path.join(
resolveUserPath("~"),
CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH,
@@ -308,6 +322,41 @@ function readClaudeCliCredentials(): OAuthCredential | null {
};
}
+/**
+ * Read Claude Code credentials from macOS keychain.
+ * Uses the `security` CLI to access keychain without native dependencies.
+ */
+function readClaudeCliKeychainCredentials(): OAuthCredential | null {
+ try {
+ const result = execSync(
+ 'security find-generic-password -s "Claude Code-credentials" -w',
+ { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
+ );
+
+ const data = JSON.parse(result.trim());
+ const claudeOauth = data?.claudeAiOauth;
+ if (!claudeOauth || typeof claudeOauth !== "object") return null;
+
+ const accessToken = claudeOauth.accessToken;
+ const refreshToken = claudeOauth.refreshToken;
+ const expiresAt = claudeOauth.expiresAt;
+
+ if (typeof accessToken !== "string" || !accessToken) return null;
+ if (typeof refreshToken !== "string" || !refreshToken) return null;
+ if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
+
+ return {
+ type: "oauth",
+ provider: "anthropic",
+ access: accessToken,
+ refresh: refreshToken,
+ expires: expiresAt,
+ };
+ } catch {
+ return null;
+ }
+}
+
/**
* Read OpenAI Codex OAuth credentials from Codex CLI's auth file.
* Codex CLI stores credentials at ~/.codex/auth.json
@@ -374,12 +423,15 @@ function shallowEqualOAuthCredentials(
*
* Returns true if any credentials were updated.
*/
-function syncExternalCliCredentials(store: AuthProfileStore): boolean {
+function syncExternalCliCredentials(
+ store: AuthProfileStore,
+ options?: { allowKeychainPrompt?: boolean },
+): boolean {
let mutated = false;
const now = Date.now();
// Sync from Claude CLI
- const claudeCreds = readClaudeCliCredentials();
+ const claudeCreds = readClaudeCliCredentials(options);
if (claudeCreds) {
const existing = store.profiles[CLAUDE_CLI_PROFILE_ID];
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
@@ -486,13 +538,16 @@ export function loadAuthProfileStore(): AuthProfileStore {
return store;
}
-export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
+export function ensureAuthProfileStore(
+ agentDir?: string,
+ options?: { allowKeychainPrompt?: boolean },
+): AuthProfileStore {
const authPath = resolveAuthStorePath(agentDir);
const raw = loadJsonFile(authPath);
const asStore = coerceAuthStore(raw);
if (asStore) {
// Sync from external CLI tools on every load
- const synced = syncExternalCliCredentials(asStore);
+ const synced = syncExternalCliCredentials(asStore, options);
if (synced) {
saveJsonFile(authPath, asStore);
}
@@ -532,7 +587,7 @@ export function ensureAuthProfileStore(agentDir?: string): AuthProfileStore {
}
const mergedOAuth = mergeOAuthFileIntoStore(store);
- const syncedCli = syncExternalCliCredentials(store);
+ const syncedCli = syncExternalCliCredentials(store, options);
const shouldWrite = legacy !== null || mergedOAuth || syncedCli;
if (shouldWrite) {
saveJsonFile(authPath, store);
diff --git a/src/agents/bash-tools.test.ts b/src/agents/bash-tools.test.ts
index 4a94ce46d..825a4cc20 100644
--- a/src/agents/bash-tools.test.ts
+++ b/src/agents/bash-tools.test.ts
@@ -10,6 +10,7 @@ import { sanitizeBinaryOutput } from "./shell-utils.js";
const isWin = process.platform === "win32";
const shortDelayCmd = isWin ? "ping -n 2 127.0.0.1 > nul" : "sleep 0.05";
+const yieldDelayCmd = isWin ? "ping -n 3 127.0.0.1 > nul" : "sleep 0.2";
const longDelayCmd = isWin ? "ping -n 4 127.0.0.1 > nul" : "sleep 2";
const joinCommands = (commands: string[]) =>
commands.join(isWin ? " & " : "; ");
@@ -51,7 +52,7 @@ beforeEach(() => {
describe("bash tool backgrounding", () => {
it("backgrounds after yield and can be polled", async () => {
const result = await bashTool.execute("call1", {
- command: echoAfterDelay("done"),
+ command: joinCommands([yieldDelayCmd, "echo done"]),
yieldMs: 10,
});
diff --git a/src/agents/pi-embedded-subscribe.test.ts b/src/agents/pi-embedded-subscribe.test.ts
index 18c785f6c..6c6a8de51 100644
--- a/src/agents/pi-embedded-subscribe.test.ts
+++ b/src/agents/pi-embedded-subscribe.test.ts
@@ -1180,6 +1180,76 @@ describe("subscribeEmbeddedPiSession", () => {
expect(onToolResult).toHaveBeenCalledTimes(1);
});
+ it("includes browser action metadata in tool summaries", () => {
+ let handler: ((evt: unknown) => void) | undefined;
+ const session: StubSession = {
+ subscribe: (fn) => {
+ handler = fn;
+ return () => {};
+ },
+ };
+
+ const onToolResult = vi.fn();
+
+ subscribeEmbeddedPiSession({
+ session: session as unknown as Parameters<
+ typeof subscribeEmbeddedPiSession
+ >[0]["session"],
+ runId: "run-browser-tool",
+ verboseLevel: "on",
+ onToolResult,
+ });
+
+ handler?.({
+ type: "tool_execution_start",
+ toolName: "browser",
+ toolCallId: "tool-browser-1",
+ args: { action: "snapshot", targetUrl: "https://example.com" },
+ });
+
+ expect(onToolResult).toHaveBeenCalledTimes(1);
+ const payload = onToolResult.mock.calls[0][0];
+ expect(payload.text).toContain("🌐");
+ expect(payload.text).toContain("browser");
+ expect(payload.text).toContain("snapshot");
+ expect(payload.text).toContain("https://example.com");
+ });
+
+ it("includes canvas action metadata in tool summaries", () => {
+ let handler: ((evt: unknown) => void) | undefined;
+ const session: StubSession = {
+ subscribe: (fn) => {
+ handler = fn;
+ return () => {};
+ },
+ };
+
+ const onToolResult = vi.fn();
+
+ subscribeEmbeddedPiSession({
+ session: session as unknown as Parameters<
+ typeof subscribeEmbeddedPiSession
+ >[0]["session"],
+ runId: "run-canvas-tool",
+ verboseLevel: "on",
+ onToolResult,
+ });
+
+ handler?.({
+ type: "tool_execution_start",
+ toolName: "canvas",
+ toolCallId: "tool-canvas-1",
+ args: { action: "a2ui_push", jsonlPath: "/tmp/a2ui.jsonl" },
+ });
+
+ expect(onToolResult).toHaveBeenCalledTimes(1);
+ const payload = onToolResult.mock.calls[0][0];
+ expect(payload.text).toContain("🖼️");
+ expect(payload.text).toContain("canvas");
+ expect(payload.text).toContain("A2UI push");
+ expect(payload.text).toContain("/tmp/a2ui.jsonl");
+ });
+
it("skips tool summaries when shouldEmitToolResult is false", () => {
let handler: ((evt: unknown) => void) | undefined;
const session: StubSession = {
diff --git a/src/agents/sandbox.ts b/src/agents/sandbox.ts
index 4792c6379..53cc9c1c8 100644
--- a/src/agents/sandbox.ts
+++ b/src/agents/sandbox.ts
@@ -19,6 +19,7 @@ import { STATE_DIR_CLAWDBOT } from "../config/config.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { resolveAgentIdFromSessionKey } from "./agent-scope.js";
+import { syncSkillsToWorkspace } from "./skills.js";
import {
DEFAULT_AGENT_WORKSPACE_DIR,
DEFAULT_AGENTS_FILENAME,
@@ -1048,6 +1049,19 @@ export async function resolveSandboxContext(params: {
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
);
+ if (cfg.workspaceAccess === "none") {
+ try {
+ await syncSkillsToWorkspace({
+ sourceWorkspaceDir: agentWorkspaceDir,
+ targetWorkspaceDir: sandboxWorkspaceDir,
+ config: params.config,
+ });
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : JSON.stringify(error);
+ defaultRuntime.error?.(`Sandbox skill sync failed: ${message}`);
+ }
+ }
} else {
await fs.mkdir(workspaceDir, { recursive: true });
}
@@ -1109,6 +1123,19 @@ export async function ensureSandboxWorkspaceForSession(params: {
agentWorkspaceDir,
params.config?.agent?.skipBootstrap,
);
+ if (cfg.workspaceAccess === "none") {
+ try {
+ await syncSkillsToWorkspace({
+ sourceWorkspaceDir: agentWorkspaceDir,
+ targetWorkspaceDir: sandboxWorkspaceDir,
+ config: params.config,
+ });
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : JSON.stringify(error);
+ defaultRuntime.error?.(`Sandbox skill sync failed: ${message}`);
+ }
+ }
} else {
await fs.mkdir(workspaceDir, { recursive: true });
}
diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts
index 9178bf971..b1ec8b60c 100644
--- a/src/agents/skills.test.ts
+++ b/src/agents/skills.test.ts
@@ -10,6 +10,7 @@ import {
buildWorkspaceSkillSnapshot,
buildWorkspaceSkillsPrompt,
loadWorkspaceSkillEntries,
+ syncSkillsToWorkspace,
} from "./skills.js";
import { buildWorkspaceSkillStatus } from "./skills-status.js";
@@ -130,6 +131,60 @@ describe("buildWorkspaceSkillsPrompt", () => {
expect(prompt).toContain(path.join(skillDir, "SKILL.md"));
});
+ it("syncs merged skills into a target workspace", async () => {
+ const sourceWorkspace = await fs.mkdtemp(
+ path.join(os.tmpdir(), "clawdbot-"),
+ );
+ const targetWorkspace = await fs.mkdtemp(
+ path.join(os.tmpdir(), "clawdbot-"),
+ );
+ const extraDir = path.join(sourceWorkspace, ".extra");
+ const bundledDir = path.join(sourceWorkspace, ".bundled");
+ const managedDir = path.join(sourceWorkspace, ".managed");
+
+ await writeSkill({
+ dir: path.join(extraDir, "demo-skill"),
+ name: "demo-skill",
+ description: "Extra version",
+ });
+ await writeSkill({
+ dir: path.join(bundledDir, "demo-skill"),
+ name: "demo-skill",
+ description: "Bundled version",
+ });
+ await writeSkill({
+ dir: path.join(managedDir, "demo-skill"),
+ name: "demo-skill",
+ description: "Managed version",
+ });
+ await writeSkill({
+ dir: path.join(sourceWorkspace, "skills", "demo-skill"),
+ name: "demo-skill",
+ description: "Workspace version",
+ });
+
+ await syncSkillsToWorkspace({
+ sourceWorkspaceDir: sourceWorkspace,
+ targetWorkspaceDir: targetWorkspace,
+ config: { skills: { load: { extraDirs: [extraDir] } } },
+ bundledSkillsDir: bundledDir,
+ managedSkillsDir: managedDir,
+ });
+
+ const prompt = buildWorkspaceSkillsPrompt(targetWorkspace, {
+ bundledSkillsDir: path.join(targetWorkspace, ".bundled"),
+ managedSkillsDir: path.join(targetWorkspace, ".managed"),
+ });
+
+ expect(prompt).toContain("Workspace version");
+ expect(prompt).not.toContain("Managed version");
+ expect(prompt).not.toContain("Bundled version");
+ expect(prompt).not.toContain("Extra version");
+ expect(prompt).toContain(
+ path.join(targetWorkspace, "skills", "demo-skill", "SKILL.md"),
+ );
+ });
+
it("filters skills based on env/config gates", async () => {
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-"));
const skillDir = path.join(workspaceDir, "skills", "nano-banana-pro");
diff --git a/src/agents/skills.ts b/src/agents/skills.ts
index e323da6b9..405fd6beb 100644
--- a/src/agents/skills.ts
+++ b/src/agents/skills.ts
@@ -11,6 +11,8 @@ import {
import type { ClawdbotConfig, SkillConfig } from "../config/config.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
+const fsp = fs.promises;
+
export type SkillInstallSpec = {
id?: string;
kind: "brew" | "node" | "go" | "uv";
@@ -619,6 +621,41 @@ export function loadWorkspaceSkillEntries(
return loadSkillEntries(workspaceDir, opts);
}
+export async function syncSkillsToWorkspace(params: {
+ sourceWorkspaceDir: string;
+ targetWorkspaceDir: string;
+ config?: ClawdbotConfig;
+ managedSkillsDir?: string;
+ bundledSkillsDir?: string;
+}) {
+ const sourceDir = resolveUserPath(params.sourceWorkspaceDir);
+ const targetDir = resolveUserPath(params.targetWorkspaceDir);
+ if (sourceDir === targetDir) return;
+ const targetSkillsDir = path.join(targetDir, "skills");
+
+ const entries = loadSkillEntries(sourceDir, {
+ config: params.config,
+ managedSkillsDir: params.managedSkillsDir,
+ bundledSkillsDir: params.bundledSkillsDir,
+ });
+
+ await fsp.rm(targetSkillsDir, { recursive: true, force: true });
+ await fsp.mkdir(targetSkillsDir, { recursive: true });
+
+ for (const entry of entries) {
+ const dest = path.join(targetSkillsDir, entry.skill.name);
+ try {
+ await fsp.cp(entry.skill.baseDir, dest, { recursive: true, force: true });
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : JSON.stringify(error);
+ console.warn(
+ `[skills] Failed to copy ${entry.skill.name} to sandbox: ${message}`,
+ );
+ }
+ }
+}
+
export function filterWorkspaceSkillEntries(
entries: SkillEntry[],
config?: ClawdbotConfig,
diff --git a/src/auto-reply/chunk.test.ts b/src/auto-reply/chunk.test.ts
index 335576b3c..ddd3478f2 100644
--- a/src/auto-reply/chunk.test.ts
+++ b/src/auto-reply/chunk.test.ts
@@ -25,6 +25,45 @@ function expectFencesBalanced(chunks: string[]) {
}
}
+type ChunkCase = {
+ name: string;
+ text: string;
+ limit: number;
+ expected: string[];
+};
+
+function runChunkCases(
+ chunker: (text: string, limit: number) => string[],
+ cases: ChunkCase[],
+) {
+ for (const { name, text, limit, expected } of cases) {
+ it(name, () => {
+ expect(chunker(text, limit)).toEqual(expected);
+ });
+ }
+}
+
+const parentheticalCases: ChunkCase[] = [
+ {
+ name: "keeps parenthetical phrases together",
+ text: "Heads up now (Though now I'm curious)ok",
+ limit: 35,
+ expected: ["Heads up now", "(Though now I'm curious)ok"],
+ },
+ {
+ name: "handles nested parentheses",
+ text: "Hello (outer (inner) end) world",
+ limit: 26,
+ expected: ["Hello (outer (inner) end)", "world"],
+ },
+ {
+ name: "ignores unmatched closing parentheses",
+ text: "Hello) world (ok)",
+ limit: 12,
+ expected: ["Hello)", "world (ok)"],
+ },
+];
+
describe("chunkText", () => {
it("keeps multi-line text in one chunk when under limit", () => {
const text = "Line one\n\nLine two\n\nLine three";
@@ -68,11 +107,7 @@ describe("chunkText", () => {
expect(chunks).toEqual(["Supercalif", "ragilistic", "expialidoc", "ious"]);
});
- it("keeps parenthetical phrases together", () => {
- const text = "Heads up now (Though now I'm curious)ok";
- const chunks = chunkText(text, 35);
- expect(chunks).toEqual(["Heads up now", "(Though now I'm curious)ok"]);
- });
+ runChunkCases(chunkText, [parentheticalCases[0]]);
});
describe("resolveTextChunkLimit", () => {
@@ -191,17 +226,7 @@ describe("chunkMarkdownText", () => {
}
});
- it("keeps parenthetical phrases together", () => {
- const text = "Heads up now (Though now I'm curious)ok";
- const chunks = chunkMarkdownText(text, 35);
- expect(chunks).toEqual(["Heads up now", "(Though now I'm curious)ok"]);
- });
-
- it("handles nested parentheses", () => {
- const text = "Hello (outer (inner) end) world";
- const chunks = chunkMarkdownText(text, 26);
- expect(chunks).toEqual(["Hello (outer (inner) end)", "world"]);
- });
+ runChunkCases(chunkMarkdownText, parentheticalCases);
it("hard-breaks when a parenthetical exceeds the limit", () => {
const text = `(${"a".repeat(80)})`;
@@ -209,10 +234,4 @@ describe("chunkMarkdownText", () => {
expect(chunks[0]?.length).toBe(20);
expect(chunks.join("")).toBe(text);
});
-
- it("ignores unmatched closing parentheses", () => {
- const text = "Hello) world (ok)";
- const chunks = chunkMarkdownText(text, 12);
- expect(chunks).toEqual(["Hello)", "world (ok)"]);
- });
});
diff --git a/src/auto-reply/chunk.ts b/src/auto-reply/chunk.ts
index b64bbe5bf..44ab80c76 100644
--- a/src/auto-reply/chunk.ts
+++ b/src/auto-reply/chunk.ts
@@ -91,23 +91,7 @@ export function chunkText(text: string, limit: number): string[] {
const window = remaining.slice(0, limit);
// 1) Prefer a newline break inside the window (outside parentheses).
- let lastNewline = -1;
- let lastWhitespace = -1;
- let depth = 0;
- for (let i = 0; i < window.length; i++) {
- const char = window[i];
- if (char === "(") {
- depth += 1;
- continue;
- }
- if (char === ")" && depth > 0) {
- depth -= 1;
- continue;
- }
- if (depth !== 0) continue;
- if (char === "\n") lastNewline = i;
- else if (/\s/.test(char)) lastWhitespace = i;
- }
+ const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(window);
// 2) Otherwise prefer the last whitespace (word boundary) inside the window.
let breakIdx = lastNewline > 0 ? lastNewline : lastWhitespace;
@@ -243,12 +227,26 @@ function pickSafeBreakIndex(
window: string,
spans: ReturnType,
): number {
+ const { lastNewline, lastWhitespace } = scanParenAwareBreakpoints(
+ window,
+ (index) => isSafeFenceBreak(spans, index),
+ );
+
+ if (lastNewline > 0) return lastNewline;
+ if (lastWhitespace > 0) return lastWhitespace;
+ return -1;
+}
+
+function scanParenAwareBreakpoints(
+ window: string,
+ isAllowed: (index: number) => boolean = () => true,
+): { lastNewline: number; lastWhitespace: number } {
let lastNewline = -1;
let lastWhitespace = -1;
let depth = 0;
for (let i = 0; i < window.length; i++) {
- if (!isSafeFenceBreak(spans, i)) continue;
+ if (!isAllowed(i)) continue;
const char = window[i];
if (char === "(") {
depth += 1;
@@ -263,7 +261,5 @@ function pickSafeBreakIndex(
else if (/\s/.test(char)) lastWhitespace = i;
}
- if (lastNewline > 0) return lastNewline;
- if (lastWhitespace > 0) return lastWhitespace;
- return -1;
+ return { lastNewline, lastWhitespace };
}
diff --git a/src/auto-reply/model.test.ts b/src/auto-reply/model.test.ts
index 6737349aa..626a60d36 100644
--- a/src/auto-reply/model.test.ts
+++ b/src/auto-reply/model.test.ts
@@ -107,6 +107,12 @@ describe("extractModelDirective", () => {
});
describe("edge cases", () => {
+ it("preserves spacing when /model is followed by a path segment", () => {
+ const result = extractModelDirective("thats not /model gpt-5/tmp/hello");
+ expect(result.hasDirective).toBe(true);
+ expect(result.cleaned).toBe("thats not /hello");
+ });
+
it("handles alias with special regex characters", () => {
const result = extractModelDirective("/test.alias", {
aliases: ["test.alias"],
diff --git a/src/auto-reply/model.ts b/src/auto-reply/model.ts
index 814e258e7..163381376 100644
--- a/src/auto-reply/model.ts
+++ b/src/auto-reply/model.ts
@@ -42,7 +42,7 @@ export function extractModelDirective(
}
const cleaned = match
- ? body.replace(match[0], "").replace(/\s+/g, " ").trim()
+ ? body.replace(match[0], " ").replace(/\s+/g, " ").trim()
: body.trim();
return {
diff --git a/src/auto-reply/reply.directive.parse.test.ts b/src/auto-reply/reply.directive.parse.test.ts
index b4bf49009..e3a2fecfb 100644
--- a/src/auto-reply/reply.directive.parse.test.ts
+++ b/src/auto-reply/reply.directive.parse.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
+import { extractStatusDirective } from "./reply/directives.js";
import {
extractElevatedDirective,
extractQueueDirective,
@@ -119,6 +120,30 @@ describe("directive parsing", () => {
expect(res.cleaned).toBe("please now");
});
+ it("preserves spacing when stripping think directives before paths", () => {
+ const res = extractThinkDirective("thats not /think high/tmp/hello");
+ expect(res.hasDirective).toBe(true);
+ expect(res.cleaned).toBe("thats not /tmp/hello");
+ });
+
+ it("preserves spacing when stripping verbose directives before paths", () => {
+ const res = extractVerboseDirective("thats not /verbose on/tmp/hello");
+ expect(res.hasDirective).toBe(true);
+ expect(res.cleaned).toBe("thats not /tmp/hello");
+ });
+
+ it("preserves spacing when stripping reasoning directives before paths", () => {
+ const res = extractReasoningDirective("thats not /reasoning on/tmp/hello");
+ expect(res.hasDirective).toBe(true);
+ expect(res.cleaned).toBe("thats not /tmp/hello");
+ });
+
+ it("preserves spacing when stripping status directives before paths", () => {
+ const res = extractStatusDirective("thats not /status:/tmp/hello");
+ expect(res.hasDirective).toBe(true);
+ expect(res.cleaned).toBe("thats not /tmp/hello");
+ });
+
it("parses queue options and modes", () => {
const res = extractQueueDirective(
"please /queue steer+backlog debounce:2s cap:5 drop:summarize now",
diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts
index 860c02c34..fb7ad374d 100644
--- a/src/auto-reply/reply.triggers.test.ts
+++ b/src/auto-reply/reply.triggers.test.ts
@@ -340,6 +340,132 @@ describe("trigger handling", () => {
});
});
+ it("ignores elevated directive in groups when not mentioned", async () => {
+ await withTempHome(async (home) => {
+ vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
+ payloads: [{ text: "ok" }],
+ meta: {
+ durationMs: 1,
+ agentMeta: { sessionId: "s", provider: "p", model: "m" },
+ },
+ });
+ const cfg = {
+ agent: {
+ model: "anthropic/claude-opus-4-5",
+ workspace: join(home, "clawd"),
+ elevated: {
+ allowFrom: { whatsapp: ["+1000"] },
+ },
+ },
+ whatsapp: {
+ allowFrom: ["+1000"],
+ groups: { "*": { requireMention: false } },
+ },
+ session: { store: join(home, "sessions.json") },
+ };
+
+ const res = await getReplyFromConfig(
+ {
+ Body: "/elevated on",
+ From: "group:123@g.us",
+ To: "whatsapp:+2000",
+ Provider: "whatsapp",
+ SenderE164: "+1000",
+ ChatType: "group",
+ WasMentioned: false,
+ },
+ {},
+ cfg,
+ );
+ const text = Array.isArray(res) ? res[0]?.text : res?.text;
+ expect(text).toBe("ok");
+ expect(text).not.toContain("Elevated mode enabled");
+ });
+ });
+
+ it("allows elevated directive in groups when mentioned", async () => {
+ await withTempHome(async (home) => {
+ const cfg = {
+ agent: {
+ model: "anthropic/claude-opus-4-5",
+ workspace: join(home, "clawd"),
+ elevated: {
+ allowFrom: { whatsapp: ["+1000"] },
+ },
+ },
+ whatsapp: {
+ allowFrom: ["+1000"],
+ groups: { "*": { requireMention: true } },
+ },
+ session: { store: join(home, "sessions.json") },
+ };
+
+ const res = await getReplyFromConfig(
+ {
+ Body: "/elevated on",
+ From: "group:123@g.us",
+ To: "whatsapp:+2000",
+ Provider: "whatsapp",
+ SenderE164: "+1000",
+ ChatType: "group",
+ WasMentioned: true,
+ },
+ {},
+ cfg,
+ );
+ const text = Array.isArray(res) ? res[0]?.text : res?.text;
+ expect(text).toContain("Elevated mode enabled");
+
+ const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
+ const store = JSON.parse(storeRaw) as Record<
+ string,
+ { elevatedLevel?: string }
+ >;
+ expect(store["agent:main:whatsapp:group:123@g.us"]?.elevatedLevel).toBe(
+ "on",
+ );
+ });
+ });
+
+ it("allows elevated directive in direct chats without mentions", async () => {
+ await withTempHome(async (home) => {
+ const cfg = {
+ agent: {
+ model: "anthropic/claude-opus-4-5",
+ workspace: join(home, "clawd"),
+ elevated: {
+ allowFrom: { whatsapp: ["+1000"] },
+ },
+ },
+ whatsapp: {
+ allowFrom: ["+1000"],
+ },
+ session: { store: join(home, "sessions.json") },
+ };
+
+ const res = await getReplyFromConfig(
+ {
+ Body: "/elevated on",
+ From: "+1000",
+ To: "+2000",
+ Provider: "whatsapp",
+ SenderE164: "+1000",
+ },
+ {},
+ cfg,
+ );
+ const text = Array.isArray(res) ? res[0]?.text : res?.text;
+ expect(text).toContain("Elevated mode enabled");
+
+ const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
+ const store = JSON.parse(storeRaw) as Record<
+ string,
+ { elevatedLevel?: string }
+ >;
+ expect(store[MAIN_SESSION_KEY]?.elevatedLevel).toBe("on");
+ });
+ });
+
it("ignores inline elevated directive for unapproved sender", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts
index 7c7b06539..5248fb5c6 100644
--- a/src/auto-reply/reply.ts
+++ b/src/auto-reply/reply.ts
@@ -329,8 +329,10 @@ export async function getReplyFromConfig(
.map((entry) => entry.alias?.trim())
.filter((alias): alias is string => Boolean(alias))
.filter((alias) => !reservedCommands.has(alias.toLowerCase()));
+ const disableElevatedInGroup = isGroup && ctx.WasMentioned !== true;
let parsedDirectives = parseInlineDirectives(rawBody, {
modelAliases: configuredAliases,
+ disableElevated: disableElevatedInGroup,
});
const hasDirective =
parsedDirectives.hasThinkDirective ||
@@ -342,7 +344,9 @@ export async function getReplyFromConfig(
parsedDirectives.hasQueueDirective;
if (hasDirective) {
const stripped = stripStructuralPrefixes(parsedDirectives.cleaned);
- const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
+ const noMentions = isGroup
+ ? stripMentions(stripped, ctx, cfg, agentId)
+ : stripped;
if (noMentions.trim().length > 0) {
parsedDirectives = clearInlineDirectives(parsedDirectives.cleaned);
}
@@ -467,6 +471,7 @@ export async function getReplyFromConfig(
cleanedBody: directives.cleaned,
ctx,
cfg,
+ agentId,
isGroup,
})
) {
@@ -549,6 +554,7 @@ export async function getReplyFromConfig(
const command = buildCommandContext({
ctx,
cfg,
+ agentId,
sessionKey,
isGroup,
triggerBodyNormalized,
@@ -579,6 +585,7 @@ export async function getReplyFromConfig(
ctx,
cfg,
command,
+ agentId,
directives,
sessionEntry,
sessionStore,
diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts
index a55851001..ac5ab76df 100644
--- a/src/auto-reply/reply/commands.ts
+++ b/src/auto-reply/reply/commands.ts
@@ -126,11 +126,12 @@ function extractCompactInstructions(params: {
rawBody?: string;
ctx: MsgContext;
cfg: ClawdbotConfig;
+ agentId?: string;
isGroup: boolean;
}): string | undefined {
const raw = stripStructuralPrefixes(params.rawBody ?? "");
const stripped = params.isGroup
- ? stripMentions(raw, params.ctx, params.cfg)
+ ? stripMentions(raw, params.ctx, params.cfg, params.agentId)
: raw;
const trimmed = stripped.trim();
if (!trimmed) return undefined;
@@ -145,12 +146,14 @@ function extractCompactInstructions(params: {
export function buildCommandContext(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
+ agentId?: string;
sessionKey?: string;
isGroup: boolean;
triggerBodyNormalized: string;
commandAuthorized: boolean;
}): CommandContext {
- const { ctx, cfg, sessionKey, isGroup, triggerBodyNormalized } = params;
+ const { ctx, cfg, agentId, sessionKey, isGroup, triggerBodyNormalized } =
+ params;
const auth = resolveCommandAuthorization({
ctx,
cfg,
@@ -162,7 +165,9 @@ export function buildCommandContext(params: {
sessionKey ?? (auth.from || undefined) ?? (auth.to || undefined);
const rawBodyNormalized = triggerBodyNormalized;
const commandBodyNormalized = normalizeCommandBody(
- isGroup ? stripMentions(rawBodyNormalized, ctx, cfg) : rawBodyNormalized,
+ isGroup
+ ? stripMentions(rawBodyNormalized, ctx, cfg, agentId)
+ : rawBodyNormalized,
);
return {
@@ -207,6 +212,7 @@ export async function handleCommands(params: {
ctx: MsgContext;
cfg: ClawdbotConfig;
command: CommandContext;
+ agentId?: string;
directives: InlineDirectives;
sessionEntry?: SessionEntry;
sessionStore?: Record;
@@ -542,6 +548,7 @@ export async function handleCommands(params: {
rawBody: ctx.Body,
ctx,
cfg,
+ agentId: params.agentId,
isGroup,
});
const result = await compactEmbeddedPiSession({
diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts
index 0b6e3ecf6..cdc2fc8ac 100644
--- a/src/auto-reply/reply/directive-handling.ts
+++ b/src/auto-reply/reply/directive-handling.ts
@@ -184,7 +184,7 @@ export type InlineDirectives = {
export function parseInlineDirectives(
body: string,
- options?: { modelAliases?: string[] },
+ options?: { modelAliases?: string[]; disableElevated?: boolean },
): InlineDirectives {
const {
cleaned: thinkCleaned,
@@ -209,7 +209,14 @@ export function parseInlineDirectives(
elevatedLevel,
rawLevel: rawElevatedLevel,
hasDirective: hasElevatedDirective,
- } = extractElevatedDirective(reasoningCleaned);
+ } = options?.disableElevated
+ ? {
+ cleaned: reasoningCleaned,
+ elevatedLevel: undefined,
+ rawLevel: undefined,
+ hasDirective: false,
+ }
+ : extractElevatedDirective(reasoningCleaned);
const { cleaned: statusCleaned, hasDirective: hasStatusDirective } =
extractStatusDirective(elevatedCleaned);
const {
@@ -272,9 +279,10 @@ export function isDirectiveOnly(params: {
cleanedBody: string;
ctx: MsgContext;
cfg: ClawdbotConfig;
+ agentId?: string;
isGroup: boolean;
}): boolean {
- const { directives, cleanedBody, ctx, cfg, isGroup } = params;
+ const { directives, cleanedBody, ctx, cfg, agentId, isGroup } = params;
if (
!directives.hasThinkDirective &&
!directives.hasVerboseDirective &&
@@ -285,7 +293,9 @@ export function isDirectiveOnly(params: {
)
return false;
const stripped = stripStructuralPrefixes(cleanedBody ?? "");
- const noMentions = isGroup ? stripMentions(stripped, ctx, cfg) : stripped;
+ const noMentions = isGroup
+ ? stripMentions(stripped, ctx, cfg, agentId)
+ : stripped;
return noMentions.length === 0;
}
diff --git a/src/auto-reply/reply/directives.ts b/src/auto-reply/reply/directives.ts
index b0ddb1a43..d04180411 100644
--- a/src/auto-reply/reply/directives.ts
+++ b/src/auto-reply/reply/directives.ts
@@ -56,6 +56,7 @@ const extractLevelDirective = (
const level = normalize(rawLevel);
const cleaned = body
.slice(0, match.start)
+ .concat(" ")
.concat(body.slice(match.end))
.replace(/\s+/g, " ")
.trim();
@@ -76,7 +77,7 @@ const extractSimpleDirective = (
new RegExp(`(?:^|\\s)\\/(?:${namePattern})(?=$|\\s|:)(?:\\s*:\\s*)?`, "i"),
);
const cleaned = match
- ? body.replace(match[0], "").replace(/\s+/g, " ").trim()
+ ? body.replace(match[0], " ").replace(/\s+/g, " ").trim()
: body.trim();
return {
cleaned,
diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts
index 34639c5aa..7d218f305 100644
--- a/src/auto-reply/reply/mentions.test.ts
+++ b/src/auto-reply/reply/mentions.test.ts
@@ -27,4 +27,20 @@ describe("mention helpers", () => {
});
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
});
+
+ it("uses per-agent mention patterns when configured", () => {
+ const regexes = buildMentionRegexes(
+ {
+ routing: {
+ groupChat: { mentionPatterns: ["\\bglobal\\b"] },
+ agents: {
+ work: { mentionPatterns: ["\\bworkbot\\b"] },
+ },
+ },
+ },
+ "work",
+ );
+ expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
+ expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
+ });
});
diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts
index d9edcfa0f..6403776e0 100644
--- a/src/auto-reply/reply/mentions.ts
+++ b/src/auto-reply/reply/mentions.ts
@@ -1,8 +1,23 @@
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
-export function buildMentionRegexes(cfg: ClawdbotConfig | undefined): RegExp[] {
- const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? [];
+function resolveMentionPatterns(
+ cfg: ClawdbotConfig | undefined,
+ agentId?: string,
+): string[] {
+ if (!cfg) return [];
+ const agentConfig = agentId ? cfg.routing?.agents?.[agentId] : undefined;
+ if (agentConfig && Object.hasOwn(agentConfig, "mentionPatterns")) {
+ return agentConfig.mentionPatterns ?? [];
+ }
+ return cfg.routing?.groupChat?.mentionPatterns ?? [];
+}
+
+export function buildMentionRegexes(
+ cfg: ClawdbotConfig | undefined,
+ agentId?: string,
+): RegExp[] {
+ const patterns = resolveMentionPatterns(cfg, agentId);
return patterns
.map((pattern) => {
try {
@@ -48,9 +63,10 @@ export function stripMentions(
text: string,
ctx: MsgContext,
cfg: ClawdbotConfig | undefined,
+ agentId?: string,
): string {
let result = text;
- const patterns = cfg?.routing?.groupChat?.mentionPatterns ?? [];
+ const patterns = resolveMentionPatterns(cfg, agentId);
for (const p of patterns) {
try {
const re = new RegExp(p, "gi");
diff --git a/src/auto-reply/reply/queue.ts b/src/auto-reply/reply/queue.ts
index 86ae083a8..0b486fe57 100644
--- a/src/auto-reply/reply/queue.ts
+++ b/src/auto-reply/reply/queue.ts
@@ -271,8 +271,9 @@ export function extractQueueDirective(body?: string): {
const argsStart = start + "/queue".length;
const args = body.slice(argsStart);
const parsed = parseQueueDirectiveArgs(args);
- const cleanedRaw =
- body.slice(0, start) + body.slice(argsStart + parsed.consumed);
+ const cleanedRaw = `${body.slice(0, start)} ${body.slice(
+ argsStart + parsed.consumed,
+ )}`;
const cleaned = cleanedRaw.replace(/\s+/g, " ").trim();
return {
cleaned,
diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts
index 0b141d82a..65744c62e 100644
--- a/src/auto-reply/reply/session.ts
+++ b/src/auto-reply/reply/session.ts
@@ -136,7 +136,7 @@ export async function initSessionState(params: {
// web inbox before we get here. They prevented reset triggers like "/new"
// from matching, so strip structural wrappers when checking for resets.
const strippedForReset = isGroup
- ? stripMentions(triggerBodyNormalized, ctx, cfg)
+ ? stripMentions(triggerBodyNormalized, ctx, cfg, agentId)
: triggerBodyNormalized;
for (const trigger of resetTriggers) {
if (!trigger) continue;
diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts
index f2652f1d0..908c4fb7a 100644
--- a/src/cli/daemon-cli.ts
+++ b/src/cli/daemon-cli.ts
@@ -32,9 +32,11 @@ import {
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import { findLegacyGatewayServices } from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
+import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
import type { ServiceConfigAudit } from "../daemon/service-audit.js";
import { auditGatewayServiceConfig } from "../daemon/service-audit.js";
+import { buildServiceEnvironment } from "../daemon/service-env.js";
import { callGateway } from "../gateway/call.js";
import { resolveGatewayBindHost } from "../gateway/net.js";
import {
@@ -807,25 +809,27 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
+ const nodePath = await resolvePreferredNodePath({
+ env: process.env,
+ runtime: runtimeRaw,
+ });
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: runtimeRaw,
+ nodePath,
});
- const environment: Record = {
- PATH: process.env.PATH,
- CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
- CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
- CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
- CLAWDBOT_GATEWAY_PORT: String(port),
- CLAWDBOT_GATEWAY_TOKEN:
+ const environment = buildServiceEnvironment({
+ env: process.env,
+ port,
+ token:
opts.token ||
cfg.gateway?.auth?.token ||
process.env.CLAWDBOT_GATEWAY_TOKEN,
- CLAWDBOT_LAUNCHD_LABEL:
+ launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
- };
+ });
try {
await service.install({
diff --git a/src/cli/pairing-cli.test.ts b/src/cli/pairing-cli.test.ts
new file mode 100644
index 000000000..887ada6b0
--- /dev/null
+++ b/src/cli/pairing-cli.test.ts
@@ -0,0 +1,87 @@
+import { Command } from "commander";
+import { describe, expect, it, vi } from "vitest";
+
+const listProviderPairingRequests = vi.fn();
+
+vi.mock("../pairing/pairing-store.js", () => ({
+ listProviderPairingRequests,
+ approveProviderPairingCode: vi.fn(),
+}));
+
+vi.mock("../telegram/send.js", () => ({
+ sendMessageTelegram: vi.fn(),
+}));
+
+vi.mock("../discord/send.js", () => ({
+ sendMessageDiscord: vi.fn(),
+}));
+
+vi.mock("../slack/send.js", () => ({
+ sendMessageSlack: vi.fn(),
+}));
+
+vi.mock("../signal/send.js", () => ({
+ sendMessageSignal: vi.fn(),
+}));
+
+vi.mock("../imessage/send.js", () => ({
+ sendMessageIMessage: vi.fn(),
+}));
+
+vi.mock("../config/config.js", () => ({
+ loadConfig: vi.fn().mockReturnValue({}),
+}));
+
+vi.mock("../telegram/token.js", () => ({
+ resolveTelegramToken: vi.fn().mockReturnValue({ token: "t" }),
+}));
+
+describe("pairing cli", () => {
+ it("labels Telegram ids as telegramUserId", async () => {
+ const { registerPairingCli } = await import("./pairing-cli.js");
+ listProviderPairingRequests.mockResolvedValueOnce([
+ {
+ id: "123",
+ code: "ABC123",
+ createdAt: "2026-01-08T00:00:00Z",
+ lastSeenAt: "2026-01-08T00:00:00Z",
+ meta: { username: "peter" },
+ },
+ ]);
+
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
+ const program = new Command();
+ program.name("test");
+ registerPairingCli(program);
+ await program.parseAsync(["pairing", "list", "--provider", "telegram"], {
+ from: "user",
+ });
+ expect(log).toHaveBeenCalledWith(
+ expect.stringContaining("telegramUserId=123"),
+ );
+ });
+
+ it("labels Discord ids as discordUserId", async () => {
+ const { registerPairingCli } = await import("./pairing-cli.js");
+ listProviderPairingRequests.mockResolvedValueOnce([
+ {
+ id: "999",
+ code: "DEF456",
+ createdAt: "2026-01-08T00:00:00Z",
+ lastSeenAt: "2026-01-08T00:00:00Z",
+ meta: { tag: "Ada#0001" },
+ },
+ ]);
+
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
+ const program = new Command();
+ program.name("test");
+ registerPairingCli(program);
+ await program.parseAsync(["pairing", "list", "--provider", "discord"], {
+ from: "user",
+ });
+ expect(log).toHaveBeenCalledWith(
+ expect.stringContaining("discordUserId=999"),
+ );
+ });
+});
diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts
index d8b4d3cb6..d671dc76c 100644
--- a/src/cli/pairing-cli.ts
+++ b/src/cli/pairing-cli.ts
@@ -3,6 +3,7 @@ import type { Command } from "commander";
import { loadConfig } from "../config/config.js";
import { sendMessageDiscord } from "../discord/send.js";
import { sendMessageIMessage } from "../imessage/send.js";
+import { PROVIDER_ID_LABELS } from "../pairing/pairing-labels.js";
import {
approveProviderPairingCode,
listProviderPairingRequests,
@@ -93,8 +94,9 @@ export function registerPairingCli(program: Command) {
}
for (const r of requests) {
const meta = r.meta ? JSON.stringify(r.meta) : "";
+ const idLabel = PROVIDER_ID_LABELS[provider];
console.log(
- `${r.code} id=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`,
+ `${r.code} ${idLabel}=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`,
);
}
});
diff --git a/src/commands/agents.ts b/src/commands/agents.ts
index 9430bf5f8..81de133ab 100644
--- a/src/commands/agents.ts
+++ b/src/commands/agents.ts
@@ -955,12 +955,15 @@ export async function agentsAddCommand(
initialValue: false,
});
if (wantsAuth) {
- const authStore = ensureAuthProfileStore(agentDir);
+ const authStore = ensureAuthProfileStore(agentDir, {
+ allowKeychainPrompt: false,
+ });
const authChoice = (await prompter.select({
message: "Model/auth choice",
options: buildAuthChoiceOptions({
store: authStore,
includeSkip: true,
+ includeClaudeCliIfMissing: true,
}),
})) as AuthChoice;
diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts
new file mode 100644
index 000000000..e0e95cbe7
--- /dev/null
+++ b/src/commands/auth-choice-options.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ type AuthProfileStore,
+ CLAUDE_CLI_PROFILE_ID,
+} from "../agents/auth-profiles.js";
+import { buildAuthChoiceOptions } from "./auth-choice-options.js";
+
+describe("buildAuthChoiceOptions", () => {
+ it("includes Claude CLI option on macOS even when missing", () => {
+ const store: AuthProfileStore = { version: 1, profiles: {} };
+ const options = buildAuthChoiceOptions({
+ store,
+ includeSkip: false,
+ includeClaudeCliIfMissing: true,
+ platform: "darwin",
+ });
+
+ const claudeCli = options.find((opt) => opt.value === "claude-cli");
+ expect(claudeCli).toBeDefined();
+ expect(claudeCli?.hint).toBe("requires Keychain access");
+ });
+
+ it("skips missing Claude CLI option off macOS", () => {
+ const store: AuthProfileStore = { version: 1, profiles: {} };
+ const options = buildAuthChoiceOptions({
+ store,
+ includeSkip: false,
+ includeClaudeCliIfMissing: true,
+ platform: "linux",
+ });
+
+ expect(options.find((opt) => opt.value === "claude-cli")).toBeUndefined();
+ });
+
+ it("uses token hint when Claude CLI credentials exist", () => {
+ const store: AuthProfileStore = {
+ version: 1,
+ profiles: {
+ [CLAUDE_CLI_PROFILE_ID]: {
+ type: "oauth",
+ provider: "anthropic",
+ access: "token",
+ refresh: "refresh",
+ expires: Date.now() + 60 * 60 * 1000,
+ },
+ },
+ };
+
+ const options = buildAuthChoiceOptions({
+ store,
+ includeSkip: false,
+ includeClaudeCliIfMissing: true,
+ platform: "darwin",
+ });
+
+ const claudeCli = options.find((opt) => opt.value === "claude-cli");
+ expect(claudeCli?.hint).toContain("token ok");
+ });
+});
diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts
index 4feacf9f2..0355eb2c7 100644
--- a/src/commands/auth-choice-options.ts
+++ b/src/commands/auth-choice-options.ts
@@ -45,8 +45,11 @@ function formatOAuthHint(
export function buildAuthChoiceOptions(params: {
store: AuthProfileStore;
includeSkip: boolean;
+ includeClaudeCliIfMissing?: boolean;
+ platform?: NodeJS.Platform;
}): AuthChoiceOption[] {
const options: AuthChoiceOption[] = [];
+ const platform = params.platform ?? process.platform;
const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID];
if (codexCli?.type === "oauth") {
@@ -64,6 +67,12 @@ export function buildAuthChoiceOptions(params: {
label: "Anthropic OAuth (Claude CLI)",
hint: formatOAuthHint(claudeCli.expires),
});
+ } else if (params.includeClaudeCliIfMissing && platform === "darwin") {
+ options.push({
+ value: "claude-cli",
+ label: "Anthropic OAuth (Claude CLI)",
+ hint: "requires Keychain access",
+ });
}
options.push({ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" });
diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts
index 195bcf50b..36c4d0fe8 100644
--- a/src/commands/auth-choice.ts
+++ b/src/commands/auth-choice.ts
@@ -168,10 +168,39 @@ export async function applyAuthChoice(params: {
);
}
} else if (params.authChoice === "claude-cli") {
- const store = ensureAuthProfileStore(params.agentDir);
- if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
+ const store = ensureAuthProfileStore(params.agentDir, {
+ allowKeychainPrompt: false,
+ });
+ const hasClaudeCli = Boolean(store.profiles[CLAUDE_CLI_PROFILE_ID]);
+ if (!hasClaudeCli && process.platform === "darwin") {
await params.prompter.note(
- "No Claude CLI credentials found at ~/.claude/.credentials.json.",
+ [
+ "macOS will show a Keychain prompt next.",
+ 'Choose "Always Allow" so the launchd gateway can start without prompts.',
+ 'If you choose "Allow" or "Deny", each restart will block on a Keychain alert.',
+ ].join("\n"),
+ "Claude CLI Keychain",
+ );
+ const proceed = await params.prompter.confirm({
+ message: "Check Keychain for Claude CLI credentials now?",
+ initialValue: true,
+ });
+ if (!proceed) {
+ return { config: nextConfig, agentModelOverride };
+ }
+ }
+
+ const storeWithKeychain = hasClaudeCli
+ ? store
+ : ensureAuthProfileStore(params.agentDir, {
+ allowKeychainPrompt: true,
+ });
+
+ if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) {
+ await params.prompter.note(
+ process.platform === "darwin"
+ ? 'No Claude CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.'
+ : "No Claude CLI credentials found at ~/.claude/.credentials.json.",
"Claude CLI OAuth",
);
return { config: nextConfig, agentModelOverride };
diff --git a/src/commands/configure.ts b/src/commands/configure.ts
index 549e3d95d..eab3a7934 100644
--- a/src/commands/configure.ts
+++ b/src/commands/configure.ts
@@ -31,7 +31,9 @@ import {
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
+import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
+import { buildServiceEnvironment } from "../daemon/service-env.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -284,8 +286,11 @@ async function promptAuthConfig(
await select({
message: "Model/auth choice",
options: buildAuthChoiceOptions({
- store: ensureAuthProfileStore(),
+ store: ensureAuthProfileStore(undefined, {
+ allowKeychainPrompt: false,
+ }),
includeSkip: true,
+ includeClaudeCliIfMissing: true,
}),
}),
runtime,
@@ -611,18 +616,24 @@ async function maybeInstallDaemon(params: {
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
+ const nodePath = await resolvePreferredNodePath({
+ env: process.env,
+ runtime: daemonRuntime,
+ });
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port: params.port,
dev: devMode,
runtime: daemonRuntime,
+ nodePath,
});
- const environment: Record = {
- PATH: process.env.PATH,
- CLAWDBOT_GATEWAY_TOKEN: params.gatewayToken,
- CLAWDBOT_LAUNCHD_LABEL:
+ const environment = buildServiceEnvironment({
+ env: process.env,
+ port: params.port,
+ token: params.gatewayToken,
+ launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
- };
+ });
await service.install({
env: process.env,
stdout: process.stdout,
diff --git a/src/commands/daemon-runtime.ts b/src/commands/daemon-runtime.ts
index 15c7f492c..354588034 100644
--- a/src/commands/daemon-runtime.ts
+++ b/src/commands/daemon-runtime.ts
@@ -10,7 +10,7 @@ export const GATEWAY_DAEMON_RUNTIME_OPTIONS: Array<{
{
value: "node",
label: "Node (recommended)",
- hint: "Required for WhatsApp (Baileys WebSocket). Bun can corrupt memory on reconnect.",
+ hint: "Required for WhatsApp + Telegram. Bun can corrupt memory on reconnect.",
},
];
diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts
index 9087c6a19..51d869562 100644
--- a/src/commands/doctor-gateway-services.ts
+++ b/src/commands/doctor-gateway-services.ts
@@ -14,8 +14,16 @@ import {
uninstallLegacyGatewayServices,
} from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
+import {
+ resolvePreferredNodePath,
+ resolveSystemNodePath,
+} from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
-import { auditGatewayServiceConfig } from "../daemon/service-audit.js";
+import {
+ auditGatewayServiceConfig,
+ needsNodeRuntimeMigration,
+} from "../daemon/service-audit.js";
+import { buildServiceEnvironment } from "../daemon/service-env.js";
import type { RuntimeEnv } from "../runtime.js";
import {
DEFAULT_GATEWAY_DAEMON_RUNTIME,
@@ -103,19 +111,24 @@ export async function maybeMigrateLegacyGatewayService(
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
+ const nodePath = await resolvePreferredNodePath({
+ env: process.env,
+ runtime: daemonRuntime,
+ });
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntime,
+ nodePath,
});
- const environment: Record = {
- PATH: process.env.PATH,
- CLAWDBOT_GATEWAY_TOKEN:
- cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
- CLAWDBOT_LAUNCHD_LABEL:
+ const environment = buildServiceEnvironment({
+ env: process.env,
+ port,
+ token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
+ launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
- };
+ });
await service.install({
env: process.env,
stdout: process.stdout,
@@ -170,9 +183,6 @@ export async function maybeRepairGatewayServiceConfig(
const aggressiveIssues = audit.issues.filter(
(issue) => issue.level === "aggressive",
);
- const _recommendedIssues = audit.issues.filter(
- (issue) => issue.level !== "aggressive",
- );
const needsAggressive = aggressiveIssues.length > 0;
if (needsAggressive && !prompter.shouldForce) {
@@ -194,6 +204,17 @@ export async function maybeRepairGatewayServiceConfig(
});
if (!repair) return;
+ const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues);
+ const systemNodePath = needsNodeRuntime
+ ? await resolveSystemNodePath(process.env)
+ : null;
+ if (needsNodeRuntime && !systemNodePath) {
+ note(
+ "System Node 22+ not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
+ "Gateway runtime",
+ );
+ }
+
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
@@ -203,19 +224,16 @@ export async function maybeRepairGatewayServiceConfig(
await resolveGatewayProgramArguments({
port,
dev: devMode,
- runtime: runtimeChoice,
+ runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
+ nodePath: systemNodePath ?? undefined,
});
- const environment: Record = {
- PATH: process.env.PATH,
- CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
- CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
- CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
- CLAWDBOT_GATEWAY_PORT: String(port),
- CLAWDBOT_GATEWAY_TOKEN:
- cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
- CLAWDBOT_LAUNCHD_LABEL:
+ const environment = buildServiceEnvironment({
+ env: process.env,
+ port,
+ token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
+ launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
- };
+ });
try {
await service.install({
diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts
index 395ee993c..1ba4f2c5e 100644
--- a/src/commands/doctor.test.ts
+++ b/src/commands/doctor.test.ts
@@ -94,6 +94,7 @@ const serviceIsLoaded = vi.fn().mockResolvedValue(false);
const serviceStop = vi.fn().mockResolvedValue(undefined);
const serviceRestart = vi.fn().mockResolvedValue(undefined);
const serviceUninstall = vi.fn().mockResolvedValue(undefined);
+const callGateway = vi.fn().mockRejectedValue(new Error("gateway closed"));
vi.mock("@clack/prompts", () => ({
confirm,
@@ -133,6 +134,14 @@ vi.mock("../daemon/program-args.js", () => ({
resolveGatewayProgramArguments,
}));
+vi.mock("../gateway/call.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ callGateway,
+ };
+});
+
vi.mock("../process/exec.js", () => ({
runExec,
runCommandWithTimeout,
diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts
index a415609a7..70514c279 100644
--- a/src/commands/doctor.ts
+++ b/src/commands/doctor.ts
@@ -12,9 +12,12 @@ import {
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
+import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
-import { buildGatewayConnectionDetails } from "../gateway/call.js";
+import { buildServiceEnvironment } from "../daemon/service-env.js";
+import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
+import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js";
@@ -237,6 +240,30 @@ export async function doctorCommand(
}
}
+ if (healthOk) {
+ try {
+ const status = await callGateway>({
+ method: "providers.status",
+ params: { probe: true, timeoutMs: 5000 },
+ timeoutMs: 6000,
+ });
+ const issues = collectProvidersStatusIssues(status);
+ if (issues.length > 0) {
+ note(
+ issues
+ .map(
+ (issue) =>
+ `- ${issue.provider} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
+ )
+ .join("\n"),
+ "Provider warnings",
+ );
+ }
+ } catch {
+ // ignore: doctor already reported gateway health
+ }
+ }
+
if (!healthOk) {
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
@@ -280,25 +307,27 @@ export async function doctorCommand(
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
+ const nodePath = await resolvePreferredNodePath({
+ env: process.env,
+ runtime: daemonRuntime,
+ });
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntime,
+ nodePath,
});
- const environment: Record = {
- PATH: process.env.PATH,
- CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
- CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
- CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
- CLAWDBOT_GATEWAY_PORT: String(port),
- CLAWDBOT_GATEWAY_TOKEN:
+ const environment = buildServiceEnvironment({
+ env: process.env,
+ port,
+ token:
cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
- CLAWDBOT_LAUNCHD_LABEL:
+ launchdLabel:
process.platform === "darwin"
? GATEWAY_LAUNCH_AGENT_LABEL
: undefined,
- };
+ });
await service.install({
env: process.env,
stdout: process.stdout,
diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts
index 7e3821fa1..382317506 100644
--- a/src/commands/onboard-non-interactive.ts
+++ b/src/commands/onboard-non-interactive.ts
@@ -13,7 +13,9 @@ import {
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
+import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
import { resolveGatewayService } from "../daemon/service.js";
+import { buildServiceEnvironment } from "../daemon/service-env.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js";
@@ -118,10 +120,14 @@ export async function runNonInteractiveOnboarding(
mode: "api_key",
});
} else if (authChoice === "claude-cli") {
- const store = ensureAuthProfileStore();
+ const store = ensureAuthProfileStore(undefined, {
+ allowKeychainPrompt: false,
+ });
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
runtime.error(
- "No Claude CLI credentials found at ~/.claude/.credentials.json",
+ process.platform === "darwin"
+ ? 'No Claude CLI credentials found. Run interactive onboarding to approve Keychain access for "Claude Code-credentials".'
+ : "No Claude CLI credentials found at ~/.claude/.credentials.json",
);
runtime.exit(1);
return;
@@ -272,18 +278,24 @@ export async function runNonInteractiveOnboarding(
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
+ const nodePath = await resolvePreferredNodePath({
+ env: process.env,
+ runtime: daemonRuntimeRaw,
+ });
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: daemonRuntimeRaw,
+ nodePath,
});
- const environment: Record = {
- PATH: process.env.PATH,
- CLAWDBOT_GATEWAY_TOKEN: gatewayToken,
- CLAWDBOT_LAUNCHD_LABEL:
+ const environment = buildServiceEnvironment({
+ env: process.env,
+ port,
+ token: gatewayToken,
+ launchdLabel:
process.platform === "darwin" ? GATEWAY_LAUNCH_AGENT_LABEL : undefined,
- };
+ });
await service.install({
env: process.env,
stdout: process.stdout,
diff --git a/src/commands/providers.test.ts b/src/commands/providers.test.ts
index f48eeaf11..a1ef4cfa2 100644
--- a/src/commands/providers.test.ts
+++ b/src/commands/providers.test.ts
@@ -323,4 +323,112 @@ describe("providers command", () => {
expect(whatsappIndex).toBeGreaterThan(-1);
expect(telegramIndex).toBeLessThan(whatsappIndex);
});
+
+ it("surfaces Discord privileged intent issues in providers status output", () => {
+ const lines = formatGatewayProvidersStatusLines({
+ discordAccounts: [
+ {
+ accountId: "default",
+ enabled: true,
+ configured: true,
+ application: { intents: { messageContent: "limited" } },
+ },
+ ],
+ });
+ expect(lines.join("\n")).toMatch(/Warnings:/);
+ expect(lines.join("\n")).toMatch(/Message Content Intent is limited/i);
+ expect(lines.join("\n")).toMatch(/Run: clawdbot doctor/);
+ });
+
+ it("surfaces Discord permission audit issues in providers status output", () => {
+ const lines = formatGatewayProvidersStatusLines({
+ discordAccounts: [
+ {
+ accountId: "default",
+ enabled: true,
+ configured: true,
+ audit: {
+ unresolvedChannels: 1,
+ channels: [
+ {
+ channelId: "111",
+ ok: false,
+ missing: ["ViewChannel", "SendMessages"],
+ },
+ ],
+ },
+ },
+ ],
+ });
+ expect(lines.join("\n")).toMatch(/Warnings:/);
+ expect(lines.join("\n")).toMatch(/permission audit/i);
+ expect(lines.join("\n")).toMatch(/Channel 111/i);
+ });
+
+ it("surfaces Telegram privacy-mode hints when allowUnmentionedGroups is enabled", () => {
+ const lines = formatGatewayProvidersStatusLines({
+ telegramAccounts: [
+ {
+ accountId: "default",
+ enabled: true,
+ configured: true,
+ allowUnmentionedGroups: true,
+ },
+ ],
+ });
+ expect(lines.join("\n")).toMatch(/Warnings:/);
+ expect(lines.join("\n")).toMatch(/Telegram Bot API privacy mode/i);
+ });
+
+ it("surfaces Telegram group membership audit issues in providers status output", () => {
+ const lines = formatGatewayProvidersStatusLines({
+ telegramAccounts: [
+ {
+ accountId: "default",
+ enabled: true,
+ configured: true,
+ audit: {
+ hasWildcardUnmentionedGroups: true,
+ unresolvedGroups: 1,
+ groups: [
+ {
+ chatId: "-1001",
+ ok: false,
+ status: "left",
+ error: "not in group",
+ },
+ ],
+ },
+ },
+ ],
+ });
+ expect(lines.join("\n")).toMatch(/Warnings:/);
+ expect(lines.join("\n")).toMatch(/membership probing is not possible/i);
+ expect(lines.join("\n")).toMatch(/Group -1001/i);
+ });
+
+ it("surfaces WhatsApp auth/runtime hints when unlinked or disconnected", () => {
+ const unlinked = formatGatewayProvidersStatusLines({
+ whatsappAccounts: [
+ { accountId: "default", enabled: true, linked: false },
+ ],
+ });
+ expect(unlinked.join("\n")).toMatch(/WhatsApp/i);
+ expect(unlinked.join("\n")).toMatch(/Not linked/i);
+
+ const disconnected = formatGatewayProvidersStatusLines({
+ whatsappAccounts: [
+ {
+ accountId: "default",
+ enabled: true,
+ linked: true,
+ running: true,
+ connected: false,
+ reconnectAttempts: 5,
+ lastError: "connection closed",
+ },
+ ],
+ });
+ expect(disconnected.join("\n")).toMatch(/disconnected/i);
+ });
});
diff --git a/src/commands/providers/status.ts b/src/commands/providers/status.ts
index 4fd169b5e..5039c53d9 100644
--- a/src/commands/providers/status.ts
+++ b/src/commands/providers/status.ts
@@ -13,6 +13,7 @@ import {
resolveIMessageAccount,
} from "../../imessage/accounts.js";
import { formatAge } from "../../infra/provider-summary.js";
+import { collectProvidersStatusIssues } from "../../infra/providers-status-issues.js";
import { listChatProviders } from "../../providers/registry.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import {
@@ -74,6 +75,21 @@ export function formatGatewayProvidersStatusLines(
if (typeof account.running === "boolean") {
bits.push(account.running ? "running" : "stopped");
}
+ if (typeof account.connected === "boolean") {
+ bits.push(account.connected ? "connected" : "disconnected");
+ }
+ const inboundAt =
+ typeof account.lastInboundAt === "number" &&
+ Number.isFinite(account.lastInboundAt)
+ ? account.lastInboundAt
+ : null;
+ const outboundAt =
+ typeof account.lastOutboundAt === "number" &&
+ Number.isFinite(account.lastOutboundAt)
+ ? account.lastOutboundAt
+ : null;
+ if (inboundAt) bits.push(`in:${formatAge(Date.now() - inboundAt)}`);
+ if (outboundAt) bits.push(`out:${formatAge(Date.now() - outboundAt)}`);
if (typeof account.mode === "string" && account.mode.length > 0) {
bits.push(`mode:${account.mode}`);
}
@@ -98,6 +114,20 @@ export function formatGatewayProvidersStatusLines(
) {
bits.push(`app:${account.appTokenSource}`);
}
+ const application = account.application as
+ | { intents?: { messageContent?: string } }
+ | undefined;
+ const messageContent = application?.intents?.messageContent;
+ if (
+ typeof messageContent === "string" &&
+ messageContent.length > 0 &&
+ messageContent !== "enabled"
+ ) {
+ bits.push(`intents:content=${messageContent}`);
+ }
+ if (account.allowUnmentionedGroups === true) {
+ bits.push("groups:unmentioned");
+ }
if (typeof account.baseUrl === "string" && account.baseUrl) {
bits.push(`url:${account.baseUrl}`);
}
@@ -105,6 +135,10 @@ export function formatGatewayProvidersStatusLines(
if (probe && typeof probe.ok === "boolean") {
bits.push(probe.ok ? "works" : "probe failed");
}
+ const audit = account.audit as { ok?: boolean } | undefined;
+ if (audit && typeof audit.ok === "boolean") {
+ bits.push(audit.ok ? "audit ok" : "audit failed");
+ }
if (typeof account.lastError === "string" && account.lastError) {
bits.push(`error:${account.lastError}`);
}
@@ -150,6 +184,17 @@ export function formatGatewayProvidersStatusLines(
}
lines.push("");
+ const issues = collectProvidersStatusIssues(payload);
+ if (issues.length > 0) {
+ lines.push(theme.warn("Warnings:"));
+ for (const issue of issues) {
+ lines.push(
+ `- ${issue.provider} ${issue.accountId}: ${issue.message}${issue.fix ? ` (${issue.fix})` : ""}`,
+ );
+ }
+ lines.push(`- Run: clawdbot doctor`);
+ lines.push("");
+ }
lines.push(
`Tip: ${formatDocsLink("/cli#status", "status --deep")} runs local probes without a gateway.`,
);
@@ -329,10 +374,15 @@ export async function providersStatusCommand(
runtime: RuntimeEnv = defaultRuntime,
) {
const timeoutMs = Number(opts.timeout ?? 10_000);
+ const statusLabel = opts.probe
+ ? "Checking provider status (probe)…"
+ : "Checking provider status…";
+ const shouldLogStatus = opts.json !== true && !process.stderr.isTTY;
+ if (shouldLogStatus) runtime.log(statusLabel);
try {
const payload = await withProgress(
{
- label: "Checking provider status…",
+ label: statusLabel,
indeterminate: true,
enabled: opts.json !== true,
},
diff --git a/src/config/config.test.ts b/src/config/config.test.ts
index 5f697a64c..6c05c889b 100644
--- a/src/config/config.test.ts
+++ b/src/config/config.test.ts
@@ -269,7 +269,7 @@ describe("config identity defaults", () => {
});
});
- it("does not synthesize session when absent", async () => {
+ it("does not synthesize agent/session when absent", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
@@ -295,7 +295,7 @@ describe("config identity defaults", () => {
expect(cfg.routing?.groupChat?.mentionPatterns).toEqual([
"\\b@?Samantha\\b",
]);
- expect(cfg.agent?.contextPruning?.mode).toBe("adaptive");
+ expect(cfg.agent).toBeUndefined();
expect(cfg.session).toBeUndefined();
});
});
@@ -327,6 +327,83 @@ describe("config identity defaults", () => {
});
});
+describe("config env vars", () => {
+ it("applies env vars from env block when missing", async () => {
+ await withTempHome(async (home) => {
+ const configDir = path.join(home, ".clawdbot");
+ await fs.mkdir(configDir, { recursive: true });
+ await fs.writeFile(
+ path.join(configDir, "clawdbot.json"),
+ JSON.stringify(
+ {
+ env: { OPENROUTER_API_KEY: "config-key" },
+ },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
+
+ await withEnvOverride({ OPENROUTER_API_KEY: undefined }, async () => {
+ const { loadConfig } = await import("./config.js");
+ loadConfig();
+ expect(process.env.OPENROUTER_API_KEY).toBe("config-key");
+ });
+ });
+ });
+
+ it("does not override existing env vars", async () => {
+ await withTempHome(async (home) => {
+ const configDir = path.join(home, ".clawdbot");
+ await fs.mkdir(configDir, { recursive: true });
+ await fs.writeFile(
+ path.join(configDir, "clawdbot.json"),
+ JSON.stringify(
+ {
+ env: { OPENROUTER_API_KEY: "config-key" },
+ },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
+
+ await withEnvOverride(
+ { OPENROUTER_API_KEY: "existing-key" },
+ async () => {
+ const { loadConfig } = await import("./config.js");
+ loadConfig();
+ expect(process.env.OPENROUTER_API_KEY).toBe("existing-key");
+ },
+ );
+ });
+ });
+
+ it("applies env vars from env.vars when missing", async () => {
+ await withTempHome(async (home) => {
+ const configDir = path.join(home, ".clawdbot");
+ await fs.mkdir(configDir, { recursive: true });
+ await fs.writeFile(
+ path.join(configDir, "clawdbot.json"),
+ JSON.stringify(
+ {
+ env: { vars: { GROQ_API_KEY: "gsk-config" } },
+ },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
+
+ await withEnvOverride({ GROQ_API_KEY: undefined }, async () => {
+ const { loadConfig } = await import("./config.js");
+ loadConfig();
+ expect(process.env.GROQ_API_KEY).toBe("gsk-config");
+ });
+ });
+ });
+});
+
describe("config pruning defaults", () => {
it("defaults contextPruning mode to adaptive", async () => {
await withTempHome(async (home) => {
@@ -352,11 +429,7 @@ describe("config pruning defaults", () => {
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "clawdbot.json"),
- JSON.stringify(
- { agent: { contextPruning: { mode: "off" } } },
- null,
- 2,
- ),
+ JSON.stringify({ agent: { contextPruning: { mode: "off" } } }, null, 2),
"utf-8",
);
diff --git a/src/config/defaults.ts b/src/config/defaults.ts
index 5aa5fa106..b88c7751a 100644
--- a/src/config/defaults.ts
+++ b/src/config/defaults.ts
@@ -165,6 +165,7 @@ export function applyContextPruningDefaults(
cfg: ClawdbotConfig,
): ClawdbotConfig {
const agent = cfg.agent;
+ if (!agent) return cfg;
const contextPruning = agent?.contextPruning;
if (contextPruning?.mode) return cfg;
diff --git a/src/config/io.ts b/src/config/io.ts
index d361484e2..c2de03d66 100644
--- a/src/config/io.ts
+++ b/src/config/io.ts
@@ -13,11 +13,11 @@ import {
findDuplicateAgentDirs,
} from "./agent-dirs.js";
import {
+ applyContextPruningDefaults,
applyIdentityDefaults,
applyLoggingDefaults,
applyMessageDefaults,
applyModelDefaults,
- applyContextPruningDefaults,
applySessionDefaults,
applyTalkApiKey,
} from "./defaults.js";
@@ -41,6 +41,7 @@ const SHELL_ENV_EXPECTED_KEYS = [
"ANTHROPIC_OAUTH_TOKEN",
"GEMINI_API_KEY",
"ZAI_API_KEY",
+ "OPENROUTER_API_KEY",
"MINIMAX_API_KEY",
"ELEVENLABS_API_KEY",
"TELEGRAM_BOT_TOKEN",
@@ -78,6 +79,31 @@ function warnOnConfigMiskeys(
}
}
+function applyConfigEnv(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): void {
+ const envConfig = cfg.env;
+ if (!envConfig) return;
+
+ const entries: Record = {};
+
+ if (envConfig.vars) {
+ for (const [key, value] of Object.entries(envConfig.vars)) {
+ if (!value) continue;
+ entries[key] = value;
+ }
+ }
+
+ for (const [key, value] of Object.entries(envConfig)) {
+ if (key === "shellEnv" || key === "vars") continue;
+ if (typeof value !== "string" || !value.trim()) continue;
+ entries[key] = value;
+ }
+
+ for (const [key, value] of Object.entries(entries)) {
+ if (env[key]?.trim()) continue;
+ env[key] = value;
+ }
+}
+
function resolveConfigPathForDeps(deps: Required): string {
if (deps.configPath) return deps.configPath;
return resolveConfigPath(deps.env, resolveStateDir(deps.env, deps.homedir));
@@ -155,6 +181,8 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
throw new DuplicateAgentDirError(duplicates);
}
+ applyConfigEnv(cfg, deps.env);
+
const enabled =
shouldEnableShellEnvFallback(deps.env) ||
cfg.env?.shellEnv?.enabled === true;
diff --git a/src/config/types.ts b/src/config/types.ts
index cfbf37bd7..0aa90d22d 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -723,6 +723,8 @@ export type RoutingConfig = {
workspace?: string;
agentDir?: string;
model?: string;
+ /** Per-agent override for group mention patterns. */
+ mentionPatterns?: string[];
subagents?: {
/** Allow spawning sub-agents under other agent ids. Use "*" to allow any. */
allowAgents?: string[];
@@ -1031,6 +1033,14 @@ export type ClawdbotConfig = {
/** Timeout for the login shell exec (ms). Default: 15000. */
timeoutMs?: number;
};
+ /** Inline env vars to apply when not already present in the process env. */
+ vars?: Record;
+ /** Sugar: allow env vars directly under env (string values only). */
+ [key: string]:
+ | string
+ | Record
+ | { enabled?: boolean; timeoutMs?: number }
+ | undefined;
};
identity?: {
name?: string;
diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts
index 77a1b92ee..cd1899548 100644
--- a/src/config/zod-schema.ts
+++ b/src/config/zod-schema.ts
@@ -641,6 +641,7 @@ const RoutingSchema = z
workspace: z.string().optional(),
agentDir: z.string().optional(),
model: z.string().optional(),
+ mentionPatterns: z.array(z.string()).optional(),
subagents: z
.object({
allowAgents: z.array(z.string()).optional(),
@@ -793,7 +794,9 @@ export const ClawdbotSchema = z.object({
timeoutMs: z.number().int().nonnegative().optional(),
})
.optional(),
+ vars: z.record(z.string(), z.string()).optional(),
})
+ .catchall(z.string())
.optional(),
identity: z
.object({
diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts
index c0364fc91..8591e7bdd 100644
--- a/src/cron/isolated-agent.test.ts
+++ b/src/cron/isolated-agent.test.ts
@@ -450,6 +450,51 @@ describe("runCronIsolatedAgentTurn", () => {
});
});
+ it("delivers telegram shorthand topic suffixes with messageThreadId", async () => {
+ await withTempHome(async (home) => {
+ const storePath = await writeSessionStore(home);
+ const deps: CliDeps = {
+ sendMessageWhatsApp: vi.fn(),
+ sendMessageTelegram: vi.fn().mockResolvedValue({
+ messageId: "t1",
+ chatId: "-1001234567890",
+ }),
+ sendMessageDiscord: vi.fn(),
+ sendMessageSignal: vi.fn(),
+ sendMessageIMessage: vi.fn(),
+ };
+ vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
+ payloads: [{ text: "hello from cron" }],
+ meta: {
+ durationMs: 5,
+ agentMeta: { sessionId: "s", provider: "p", model: "m" },
+ },
+ });
+
+ const res = await runCronIsolatedAgentTurn({
+ cfg: makeCfg(home, storePath),
+ deps,
+ job: makeJob({
+ kind: "agentTurn",
+ message: "do it",
+ deliver: true,
+ provider: "telegram",
+ to: "-1001234567890:321",
+ }),
+ message: "do it",
+ sessionKey: "cron:job-1",
+ lane: "cron",
+ });
+
+ expect(res.status).toBe("ok");
+ expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
+ "-1001234567890",
+ "hello from cron",
+ expect.objectContaining({ messageThreadId: 321 }),
+ );
+ });
+ });
+
it("delivers via discord when configured", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts
index 3488fcd5f..a4f7be945 100644
--- a/src/cron/isolated-agent.ts
+++ b/src/cron/isolated-agent.ts
@@ -57,6 +57,12 @@ export type RunCronAgentTurnResult = {
error?: string;
};
+type DeliveryPayload = {
+ text?: string;
+ mediaUrl?: string;
+ mediaUrls?: string[];
+};
+
function pickSummaryFromOutput(text: string | undefined) {
const clean = (text ?? "").trim();
if (!clean) return undefined;
@@ -79,7 +85,7 @@ function pickSummaryFromPayloads(
* Returns true if delivery should be skipped because there's no real content.
*/
function isHeartbeatOnlyResponse(
- payloads: Array<{ text?: string; mediaUrl?: string; mediaUrls?: string[] }>,
+ payloads: DeliveryPayload[],
ackMaxChars: number,
) {
if (payloads.length === 0) return true;
@@ -96,6 +102,53 @@ function isHeartbeatOnlyResponse(
return result.shouldSkip;
});
}
+
+function getMediaList(payload: DeliveryPayload) {
+ return payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
+}
+
+async function deliverPayloadsWithMedia(params: {
+ payloads: DeliveryPayload[];
+ sendText: (text: string) => Promise;
+ sendMedia: (caption: string, mediaUrl: string) => Promise;
+}) {
+ for (const payload of params.payloads) {
+ const mediaList = getMediaList(payload);
+ if (mediaList.length === 0) {
+ await params.sendText(payload.text ?? "");
+ continue;
+ }
+ let first = true;
+ for (const url of mediaList) {
+ const caption = first ? (payload.text ?? "") : "";
+ first = false;
+ await params.sendMedia(caption, url);
+ }
+ }
+}
+
+async function deliverChunkedPayloads(params: {
+ payloads: DeliveryPayload[];
+ chunkText: (text: string) => string[];
+ sendText: (text: string) => Promise;
+ sendMedia: (caption: string, mediaUrl: string) => Promise;
+}) {
+ for (const payload of params.payloads) {
+ const mediaList = getMediaList(payload);
+ if (mediaList.length === 0) {
+ for (const chunk of params.chunkText(payload.text ?? "")) {
+ await params.sendText(chunk);
+ }
+ continue;
+ }
+ let first = true;
+ for (const url of mediaList) {
+ const caption = first ? (payload.text ?? "") : "";
+ first = false;
+ await params.sendMedia(caption, url);
+ }
+ }
+}
function resolveDeliveryTarget(
cfg: ClawdbotConfig,
jobPayload: {
@@ -143,28 +196,34 @@ function resolveDeliveryTarget(
return lastProvider ?? "whatsapp";
})();
- const to = (() => {
- if (explicitTo) return explicitTo;
- return lastTo || undefined;
- })();
+ const rawTo = explicitTo ?? (lastTo || undefined);
+ const telegramTarget =
+ provider === "telegram" && rawTo ? parseTelegramTarget(rawTo) : undefined;
const sanitizedWhatsappTo = (() => {
- if (provider !== "whatsapp") return to;
+ if (provider !== "whatsapp") return rawTo;
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
- if (rawAllow.includes("*")) return to;
+ if (rawAllow.includes("*")) return rawTo;
const allowFrom = rawAllow
.map((val) => normalizeE164(val))
.filter((val) => val.length > 1);
- if (allowFrom.length === 0) return to;
- if (!to) return allowFrom[0];
- const normalized = normalizeE164(to);
+ if (allowFrom.length === 0) return rawTo;
+ if (!rawTo) return allowFrom[0];
+ const normalized = normalizeE164(rawTo);
if (allowFrom.includes(normalized)) return normalized;
return allowFrom[0];
})();
+ const to = (() => {
+ if (provider === "telegram" && telegramTarget) return telegramTarget.chatId;
+ if (provider === "whatsapp") return sanitizedWhatsappTo;
+ return rawTo;
+ })();
+
return {
provider,
- to: provider === "whatsapp" ? sanitizedWhatsappTo : to,
+ to,
+ messageThreadId: telegramTarget?.messageThreadId,
};
}
@@ -455,21 +514,16 @@ export async function runCronIsolatedAgentTurn(params: {
}
const to = normalizeE164(resolvedDelivery.to);
try {
- for (const payload of payloads) {
- const mediaList =
- payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
- const primaryMedia = mediaList[0];
- await params.deps.sendMessageWhatsApp(to, payload.text ?? "", {
- verbose: false,
- mediaUrl: primaryMedia,
- });
- for (const extra of mediaList.slice(1)) {
- await params.deps.sendMessageWhatsApp(to, "", {
+ await deliverPayloadsWithMedia({
+ payloads,
+ sendText: (text) =>
+ params.deps.sendMessageWhatsApp(to, text, { verbose: false }),
+ sendMedia: (caption, mediaUrl) =>
+ params.deps.sendMessageWhatsApp(to, caption, {
verbose: false,
- mediaUrl: extra,
- });
- }
- }
+ mediaUrl,
+ }),
+ });
} catch (err) {
if (!bestEffortDeliver)
return { status: "error", summary, error: String(err) };
@@ -488,39 +542,27 @@ export async function runCronIsolatedAgentTurn(params: {
summary: "Delivery skipped (no Telegram chatId).",
};
}
- const telegramTarget = parseTelegramTarget(resolvedDelivery.to);
- const chatId = telegramTarget.chatId;
- const messageThreadId = telegramTarget.messageThreadId;
+ const chatId = resolvedDelivery.to;
+ const messageThreadId = resolvedDelivery.messageThreadId;
const textLimit = resolveTextChunkLimit(params.cfg, "telegram");
try {
- for (const payload of payloads) {
- const mediaList =
- payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
- if (mediaList.length === 0) {
- for (const chunk of chunkMarkdownText(
- payload.text ?? "",
- textLimit,
- )) {
- await params.deps.sendMessageTelegram(chatId, chunk, {
- verbose: false,
- token: telegramToken || undefined,
- messageThreadId,
- });
- }
- } else {
- let first = true;
- for (const url of mediaList) {
- const caption = first ? (payload.text ?? "") : "";
- first = false;
- await params.deps.sendMessageTelegram(chatId, caption, {
- verbose: false,
- mediaUrl: url,
- token: telegramToken || undefined,
- messageThreadId,
- });
- }
- }
- }
+ await deliverChunkedPayloads({
+ payloads,
+ chunkText: (text) => chunkMarkdownText(text, textLimit),
+ sendText: (text) =>
+ params.deps.sendMessageTelegram(chatId, text, {
+ verbose: false,
+ token: telegramToken || undefined,
+ messageThreadId,
+ }),
+ sendMedia: (caption, mediaUrl) =>
+ params.deps.sendMessageTelegram(chatId, caption, {
+ verbose: false,
+ mediaUrl,
+ token: telegramToken || undefined,
+ messageThreadId,
+ }),
+ });
} catch (err) {
if (!bestEffortDeliver)
return { status: "error", summary, error: String(err) };
@@ -542,29 +584,18 @@ export async function runCronIsolatedAgentTurn(params: {
}
const discordTarget = resolvedDelivery.to;
try {
- for (const payload of payloads) {
- const mediaList =
- payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
- if (mediaList.length === 0) {
- await params.deps.sendMessageDiscord(
- discordTarget,
- payload.text ?? "",
- {
- token: process.env.DISCORD_BOT_TOKEN,
- },
- );
- } else {
- let first = true;
- for (const url of mediaList) {
- const caption = first ? (payload.text ?? "") : "";
- first = false;
- await params.deps.sendMessageDiscord(discordTarget, caption, {
- token: process.env.DISCORD_BOT_TOKEN,
- mediaUrl: url,
- });
- }
- }
- }
+ await deliverPayloadsWithMedia({
+ payloads,
+ sendText: (text) =>
+ params.deps.sendMessageDiscord(discordTarget, text, {
+ token: process.env.DISCORD_BOT_TOKEN,
+ }),
+ sendMedia: (caption, mediaUrl) =>
+ params.deps.sendMessageDiscord(discordTarget, caption, {
+ token: process.env.DISCORD_BOT_TOKEN,
+ mediaUrl,
+ }),
+ });
} catch (err) {
if (!bestEffortDeliver)
return { status: "error", summary, error: String(err) };
@@ -587,27 +618,13 @@ export async function runCronIsolatedAgentTurn(params: {
const slackTarget = resolvedDelivery.to;
const textLimit = resolveTextChunkLimit(params.cfg, "slack");
try {
- for (const payload of payloads) {
- const mediaList =
- payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
- if (mediaList.length === 0) {
- for (const chunk of chunkMarkdownText(
- payload.text ?? "",
- textLimit,
- )) {
- await params.deps.sendMessageSlack(slackTarget, chunk);
- }
- } else {
- let first = true;
- for (const url of mediaList) {
- const caption = first ? (payload.text ?? "") : "";
- first = false;
- await params.deps.sendMessageSlack(slackTarget, caption, {
- mediaUrl: url,
- });
- }
- }
- }
+ await deliverChunkedPayloads({
+ payloads,
+ chunkText: (text) => chunkMarkdownText(text, textLimit),
+ sendText: (text) => params.deps.sendMessageSlack(slackTarget, text),
+ sendMedia: (caption, mediaUrl) =>
+ params.deps.sendMessageSlack(slackTarget, caption, { mediaUrl }),
+ });
} catch (err) {
if (!bestEffortDeliver)
return { status: "error", summary, error: String(err) };
@@ -629,24 +646,13 @@ export async function runCronIsolatedAgentTurn(params: {
const to = resolvedDelivery.to;
const textLimit = resolveTextChunkLimit(params.cfg, "signal");
try {
- for (const payload of payloads) {
- const mediaList =
- payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
- if (mediaList.length === 0) {
- for (const chunk of chunkText(payload.text ?? "", textLimit)) {
- await params.deps.sendMessageSignal(to, chunk);
- }
- } else {
- let first = true;
- for (const url of mediaList) {
- const caption = first ? (payload.text ?? "") : "";
- first = false;
- await params.deps.sendMessageSignal(to, caption, {
- mediaUrl: url,
- });
- }
- }
- }
+ await deliverChunkedPayloads({
+ payloads,
+ chunkText: (text) => chunkText(text, textLimit),
+ sendText: (text) => params.deps.sendMessageSignal(to, text),
+ sendMedia: (caption, mediaUrl) =>
+ params.deps.sendMessageSignal(to, caption, { mediaUrl }),
+ });
} catch (err) {
if (!bestEffortDeliver)
return { status: "error", summary, error: String(err) };
@@ -668,24 +674,13 @@ export async function runCronIsolatedAgentTurn(params: {
const to = resolvedDelivery.to;
const textLimit = resolveTextChunkLimit(params.cfg, "imessage");
try {
- for (const payload of payloads) {
- const mediaList =
- payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
- if (mediaList.length === 0) {
- for (const chunk of chunkText(payload.text ?? "", textLimit)) {
- await params.deps.sendMessageIMessage(to, chunk);
- }
- } else {
- let first = true;
- for (const url of mediaList) {
- const caption = first ? (payload.text ?? "") : "";
- first = false;
- await params.deps.sendMessageIMessage(to, caption, {
- mediaUrl: url,
- });
- }
- }
- }
+ await deliverChunkedPayloads({
+ payloads,
+ chunkText: (text) => chunkText(text, textLimit),
+ sendText: (text) => params.deps.sendMessageIMessage(to, text),
+ sendMedia: (caption, mediaUrl) =>
+ params.deps.sendMessageIMessage(to, caption, { mediaUrl }),
+ });
} catch (err) {
if (!bestEffortDeliver)
return { status: "error", summary, error: String(err) };
diff --git a/src/daemon/program-args.ts b/src/daemon/program-args.ts
index f4e58a035..43ded6b30 100644
--- a/src/daemon/program-args.ts
+++ b/src/daemon/program-args.ts
@@ -146,15 +146,16 @@ export async function resolveGatewayProgramArguments(params: {
port: number;
dev?: boolean;
runtime?: GatewayRuntimePreference;
+ nodePath?: string;
}): Promise {
const gatewayArgs = ["gateway", "--port", String(params.port)];
const execPath = process.execPath;
const runtime = params.runtime ?? "auto";
if (runtime === "node") {
- const nodePath = isNodeRuntime(execPath)
- ? execPath
- : await resolveNodePath();
+ const nodePath =
+ params.nodePath ??
+ (isNodeRuntime(execPath) ? execPath : await resolveNodePath());
const cliEntrypointPath = await resolveCliEntrypointPathForService();
return {
programArguments: [nodePath, cliEntrypointPath, ...gatewayArgs],
diff --git a/src/daemon/runtime-paths.ts b/src/daemon/runtime-paths.ts
new file mode 100644
index 000000000..b0444af4f
--- /dev/null
+++ b/src/daemon/runtime-paths.ts
@@ -0,0 +1,94 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+
+const VERSION_MANAGER_MARKERS = [
+ "/.nvm/",
+ "/.fnm/",
+ "/.volta/",
+ "/.asdf/",
+ "/.n/",
+ "/.nodenv/",
+ "/.nodebrew/",
+ "/nvs/",
+];
+
+function getPathModule(platform: NodeJS.Platform) {
+ return platform === "win32" ? path.win32 : path.posix;
+}
+
+function normalizeForCompare(input: string, platform: NodeJS.Platform): string {
+ const pathModule = getPathModule(platform);
+ const normalized = pathModule.normalize(input).replaceAll("\\", "/");
+ if (platform === "win32") {
+ return normalized.toLowerCase();
+ }
+ return normalized;
+}
+
+function buildSystemNodeCandidates(
+ env: Record,
+ platform: NodeJS.Platform,
+): string[] {
+ if (platform === "darwin") {
+ return ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"];
+ }
+ if (platform === "linux") {
+ return ["/usr/local/bin/node", "/usr/bin/node"];
+ }
+ if (platform === "win32") {
+ const pathModule = getPathModule(platform);
+ const programFiles = env.ProgramFiles ?? "C:\\Program Files";
+ const programFilesX86 =
+ env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
+ return [
+ pathModule.join(programFiles, "nodejs", "node.exe"),
+ pathModule.join(programFilesX86, "nodejs", "node.exe"),
+ ];
+ }
+ return [];
+}
+
+export function isVersionManagedNodePath(
+ nodePath: string,
+ platform: NodeJS.Platform = process.platform,
+): boolean {
+ const normalized = normalizeForCompare(nodePath, platform);
+ return VERSION_MANAGER_MARKERS.some((marker) => normalized.includes(marker));
+}
+
+export function isSystemNodePath(
+ nodePath: string,
+ env: Record = process.env,
+ platform: NodeJS.Platform = process.platform,
+): boolean {
+ const normalized = normalizeForCompare(nodePath, platform);
+ return buildSystemNodeCandidates(env, platform).some((candidate) => {
+ const normalizedCandidate = normalizeForCompare(candidate, platform);
+ return normalized === normalizedCandidate;
+ });
+}
+
+export async function resolveSystemNodePath(
+ env: Record = process.env,
+ platform: NodeJS.Platform = process.platform,
+): Promise {
+ const candidates = buildSystemNodeCandidates(env, platform);
+ for (const candidate of candidates) {
+ try {
+ await fs.access(candidate);
+ return candidate;
+ } catch {
+ // keep going
+ }
+ }
+ return null;
+}
+
+export async function resolvePreferredNodePath(params: {
+ env?: Record;
+ runtime?: string;
+}): Promise {
+ if (params.runtime !== "node") return undefined;
+ const systemNode = await resolveSystemNodePath(params.env);
+ return systemNode ?? undefined;
+}
diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts
new file mode 100644
index 000000000..c5c7baa51
--- /dev/null
+++ b/src/daemon/service-audit.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import {
+ auditGatewayServiceConfig,
+ SERVICE_AUDIT_CODES,
+} from "./service-audit.js";
+
+describe("auditGatewayServiceConfig", () => {
+ it("flags bun runtime", async () => {
+ const audit = await auditGatewayServiceConfig({
+ env: { HOME: "/tmp" },
+ platform: "darwin",
+ command: {
+ programArguments: ["/opt/homebrew/bin/bun", "gateway"],
+ environment: { PATH: "/usr/bin:/bin" },
+ },
+ });
+ expect(
+ audit.issues.some(
+ (issue) => issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeBun,
+ ),
+ ).toBe(true);
+ });
+
+ it("flags version-managed node paths", async () => {
+ const audit = await auditGatewayServiceConfig({
+ env: { HOME: "/tmp" },
+ platform: "darwin",
+ command: {
+ programArguments: [
+ "/Users/test/.nvm/versions/node/v22.0.0/bin/node",
+ "gateway",
+ ],
+ environment: {
+ PATH: "/usr/bin:/bin:/Users/test/.nvm/versions/node/v22.0.0/bin",
+ },
+ },
+ });
+ expect(
+ audit.issues.some(
+ (issue) =>
+ issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeNodeVersionManager,
+ ),
+ ).toBe(true);
+ expect(
+ audit.issues.some(
+ (issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathNonMinimal,
+ ),
+ ).toBe(true);
+ expect(
+ audit.issues.some(
+ (issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathMissingDirs,
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts
index feb28dc4a..cf40e758e 100644
--- a/src/daemon/service-audit.ts
+++ b/src/daemon/service-audit.ts
@@ -1,5 +1,12 @@
import fs from "node:fs/promises";
+import path from "node:path";
import { resolveLaunchAgentPlistPath } from "./launchd.js";
+import {
+ isSystemNodePath,
+ isVersionManagedNodePath,
+ resolveSystemNodePath,
+} from "./runtime-paths.js";
+import { getMinimalServicePathParts } from "./service-env.js";
import { resolveSystemdUserUnitPath } from "./systemd.js";
export type GatewayServiceCommand = {
@@ -21,6 +28,31 @@ export type ServiceConfigAudit = {
issues: ServiceConfigIssue[];
};
+export const SERVICE_AUDIT_CODES = {
+ gatewayCommandMissing: "gateway-command-missing",
+ gatewayPathMissing: "gateway-path-missing",
+ gatewayPathMissingDirs: "gateway-path-missing-dirs",
+ gatewayPathNonMinimal: "gateway-path-nonminimal",
+ gatewayRuntimeBun: "gateway-runtime-bun",
+ gatewayRuntimeNodeVersionManager: "gateway-runtime-node-version-manager",
+ gatewayRuntimeNodeSystemMissing: "gateway-runtime-node-system-missing",
+ launchdKeepAlive: "launchd-keep-alive",
+ launchdRunAtLoad: "launchd-run-at-load",
+ systemdAfterNetworkOnline: "systemd-after-network-online",
+ systemdRestartSec: "systemd-restart-sec",
+ systemdWantsNetworkOnline: "systemd-wants-network-online",
+} as const;
+
+export function needsNodeRuntimeMigration(
+ issues: ServiceConfigIssue[],
+): boolean {
+ return issues.some(
+ (issue) =>
+ issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeBun ||
+ issue.code === SERVICE_AUDIT_CODES.gatewayRuntimeNodeVersionManager,
+ );
+}
+
function hasGatewaySubcommand(programArguments?: string[]): boolean {
return Boolean(programArguments?.some((arg) => arg === "gateway"));
}
@@ -82,7 +114,7 @@ async function auditSystemdUnit(
const parsed = parseSystemdUnit(content);
if (!parsed.after.has("network-online.target")) {
issues.push({
- code: "systemd-after-network-online",
+ code: SERVICE_AUDIT_CODES.systemdAfterNetworkOnline,
message: "Missing systemd After=network-online.target",
detail: unitPath,
level: "recommended",
@@ -90,7 +122,7 @@ async function auditSystemdUnit(
}
if (!parsed.wants.has("network-online.target")) {
issues.push({
- code: "systemd-wants-network-online",
+ code: SERVICE_AUDIT_CODES.systemdWantsNetworkOnline,
message: "Missing systemd Wants=network-online.target",
detail: unitPath,
level: "recommended",
@@ -98,7 +130,7 @@ async function auditSystemdUnit(
}
if (!isRestartSecPreferred(parsed.restartSec)) {
issues.push({
- code: "systemd-restart-sec",
+ code: SERVICE_AUDIT_CODES.systemdRestartSec,
message: "RestartSec does not match the recommended 5s",
detail: unitPath,
level: "recommended",
@@ -122,7 +154,7 @@ async function auditLaunchdPlist(
const hasKeepAlive = /KeepAlive<\/key>\s* /i.test(content);
if (!hasRunAtLoad) {
issues.push({
- code: "launchd-run-at-load",
+ code: SERVICE_AUDIT_CODES.launchdRunAtLoad,
message: "LaunchAgent is missing RunAtLoad=true",
detail: plistPath,
level: "recommended",
@@ -130,7 +162,7 @@ async function auditLaunchdPlist(
}
if (!hasKeepAlive) {
issues.push({
- code: "launchd-keep-alive",
+ code: SERVICE_AUDIT_CODES.launchdKeepAlive,
message: "LaunchAgent is missing KeepAlive=true",
detail: plistPath,
level: "recommended",
@@ -145,13 +177,144 @@ function auditGatewayCommand(
if (!programArguments || programArguments.length === 0) return;
if (!hasGatewaySubcommand(programArguments)) {
issues.push({
- code: "gateway-command-missing",
+ code: SERVICE_AUDIT_CODES.gatewayCommandMissing,
message: "Service command does not include the gateway subcommand",
level: "aggressive",
});
}
}
+function isNodeRuntime(execPath: string): boolean {
+ const base = path.basename(execPath).toLowerCase();
+ return base === "node" || base === "node.exe";
+}
+
+function isBunRuntime(execPath: string): boolean {
+ const base = path.basename(execPath).toLowerCase();
+ return base === "bun" || base === "bun.exe";
+}
+
+function getPathModule(platform: NodeJS.Platform) {
+ return platform === "win32" ? path.win32 : path.posix;
+}
+
+function normalizePathEntry(entry: string, platform: NodeJS.Platform): string {
+ const pathModule = getPathModule(platform);
+ const normalized = pathModule.normalize(entry).replaceAll("\\", "/");
+ if (platform === "win32") {
+ return normalized.toLowerCase();
+ }
+ return normalized;
+}
+
+function auditGatewayServicePath(
+ command: GatewayServiceCommand,
+ issues: ServiceConfigIssue[],
+ platform: NodeJS.Platform,
+) {
+ if (platform === "win32") return;
+ const servicePath = command?.environment?.PATH;
+ if (!servicePath) {
+ issues.push({
+ code: SERVICE_AUDIT_CODES.gatewayPathMissing,
+ message:
+ "Gateway service PATH is not set; the daemon should use a minimal PATH.",
+ level: "recommended",
+ });
+ return;
+ }
+
+ const expected = getMinimalServicePathParts({ platform });
+ const parts = servicePath
+ .split(getPathModule(platform).delimiter)
+ .map((entry) => entry.trim())
+ .filter(Boolean);
+ const normalizedParts = parts.map((entry) =>
+ normalizePathEntry(entry, platform),
+ );
+ const missing = expected.filter((entry) => {
+ const normalized = normalizePathEntry(entry, platform);
+ return !normalizedParts.includes(normalized);
+ });
+ if (missing.length > 0) {
+ issues.push({
+ code: SERVICE_AUDIT_CODES.gatewayPathMissingDirs,
+ message: `Gateway service PATH missing required dirs: ${missing.join(", ")}`,
+ level: "recommended",
+ });
+ }
+
+ const nonMinimal = parts.filter((entry) => {
+ const normalized = normalizePathEntry(entry, platform);
+ return (
+ normalized.includes("/.nvm/") ||
+ normalized.includes("/.fnm/") ||
+ normalized.includes("/.volta/") ||
+ normalized.includes("/.asdf/") ||
+ normalized.includes("/.n/") ||
+ normalized.includes("/.nodenv/") ||
+ normalized.includes("/.nodebrew/") ||
+ normalized.includes("/nvs/") ||
+ normalized.includes("/.local/share/pnpm/") ||
+ normalized.includes("/pnpm/") ||
+ normalized.endsWith("/pnpm")
+ );
+ });
+ if (nonMinimal.length > 0) {
+ issues.push({
+ code: SERVICE_AUDIT_CODES.gatewayPathNonMinimal,
+ message:
+ "Gateway service PATH includes version managers or package managers; recommend a minimal PATH.",
+ detail: nonMinimal.join(", "),
+ level: "recommended",
+ });
+ }
+}
+
+async function auditGatewayRuntime(
+ env: Record,
+ command: GatewayServiceCommand,
+ issues: ServiceConfigIssue[],
+ platform: NodeJS.Platform,
+) {
+ const execPath = command?.programArguments?.[0];
+ if (!execPath) return;
+
+ if (isBunRuntime(execPath)) {
+ issues.push({
+ code: SERVICE_AUDIT_CODES.gatewayRuntimeBun,
+ message:
+ "Gateway service uses Bun; Bun is incompatible with WhatsApp + Telegram providers.",
+ detail: execPath,
+ level: "recommended",
+ });
+ return;
+ }
+
+ if (!isNodeRuntime(execPath)) return;
+
+ if (isVersionManagedNodePath(execPath, platform)) {
+ issues.push({
+ code: SERVICE_AUDIT_CODES.gatewayRuntimeNodeVersionManager,
+ message:
+ "Gateway service uses Node from a version manager; it can break after upgrades.",
+ detail: execPath,
+ level: "recommended",
+ });
+ if (!isSystemNodePath(execPath, env, platform)) {
+ const systemNode = await resolveSystemNodePath(env, platform);
+ if (!systemNode) {
+ issues.push({
+ code: SERVICE_AUDIT_CODES.gatewayRuntimeNodeSystemMissing,
+ message:
+ "System Node 22+ not found; install it before migrating away from version managers.",
+ level: "recommended",
+ });
+ }
+ }
+ }
+}
+
export async function auditGatewayServiceConfig(params: {
env: Record;
command: GatewayServiceCommand;
@@ -161,6 +324,8 @@ export async function auditGatewayServiceConfig(params: {
const platform = params.platform ?? process.platform;
auditGatewayCommand(params.command?.programArguments, issues);
+ auditGatewayServicePath(params.command, issues, platform);
+ await auditGatewayRuntime(params.env, params.command, issues, platform);
if (platform === "linux") {
await auditSystemdUnit(params.env, issues);
diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts
new file mode 100644
index 000000000..f43b25091
--- /dev/null
+++ b/src/daemon/service-env.test.ts
@@ -0,0 +1,62 @@
+import path from "node:path";
+import { describe, expect, it } from "vitest";
+import {
+ buildMinimalServicePath,
+ buildServiceEnvironment,
+} from "./service-env.js";
+
+describe("buildMinimalServicePath", () => {
+ it("includes Homebrew + system dirs on macOS", () => {
+ const result = buildMinimalServicePath({
+ platform: "darwin",
+ });
+ const parts = result.split(path.delimiter);
+ expect(parts).toContain("/opt/homebrew/bin");
+ expect(parts).toContain("/usr/local/bin");
+ expect(parts).toContain("/usr/bin");
+ expect(parts).toContain("/bin");
+ });
+
+ it("returns PATH as-is on Windows", () => {
+ const result = buildMinimalServicePath({
+ env: { PATH: "C:\\\\Windows\\\\System32" },
+ platform: "win32",
+ });
+ expect(result).toBe("C:\\\\Windows\\\\System32");
+ });
+
+ it("includes extra directories when provided", () => {
+ const result = buildMinimalServicePath({
+ platform: "linux",
+ extraDirs: ["/custom/tools"],
+ });
+ expect(result.split(path.delimiter)).toContain("/custom/tools");
+ });
+
+ it("deduplicates directories", () => {
+ const result = buildMinimalServicePath({
+ platform: "linux",
+ extraDirs: ["/usr/bin"],
+ });
+ const parts = result.split(path.delimiter);
+ const unique = [...new Set(parts)];
+ expect(parts.length).toBe(unique.length);
+ });
+});
+
+describe("buildServiceEnvironment", () => {
+ it("sets minimal PATH and gateway vars", () => {
+ const env = buildServiceEnvironment({
+ env: { HOME: "/home/user" },
+ port: 18789,
+ token: "secret",
+ });
+ if (process.platform === "win32") {
+ expect(env.PATH).toBe("");
+ } else {
+ expect(env.PATH).toContain("/usr/bin");
+ }
+ expect(env.CLAWDBOT_GATEWAY_PORT).toBe("18789");
+ expect(env.CLAWDBOT_GATEWAY_TOKEN).toBe("secret");
+ });
+});
diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts
new file mode 100644
index 000000000..51f60bd61
--- /dev/null
+++ b/src/daemon/service-env.ts
@@ -0,0 +1,71 @@
+import path from "node:path";
+
+export type MinimalServicePathOptions = {
+ platform?: NodeJS.Platform;
+ extraDirs?: string[];
+};
+
+type BuildServicePathOptions = MinimalServicePathOptions & {
+ env?: Record;
+};
+
+function resolveSystemPathDirs(platform: NodeJS.Platform): string[] {
+ if (platform === "darwin") {
+ return ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
+ }
+ if (platform === "linux") {
+ return ["/usr/local/bin", "/usr/bin", "/bin"];
+ }
+ return [];
+}
+
+export function getMinimalServicePathParts(
+ options: MinimalServicePathOptions = {},
+): string[] {
+ const platform = options.platform ?? process.platform;
+ if (platform === "win32") return [];
+
+ const parts: string[] = [];
+ const extraDirs = options.extraDirs ?? [];
+ const systemDirs = resolveSystemPathDirs(platform);
+
+ const add = (dir: string) => {
+ if (!dir) return;
+ if (!parts.includes(dir)) parts.push(dir);
+ };
+
+ for (const dir of extraDirs) add(dir);
+ for (const dir of systemDirs) add(dir);
+
+ return parts;
+}
+
+export function buildMinimalServicePath(
+ options: BuildServicePathOptions = {},
+): string {
+ const env = options.env ?? process.env;
+ const platform = options.platform ?? process.platform;
+ if (platform === "win32") {
+ return env.PATH ?? "";
+ }
+
+ return getMinimalServicePathParts(options).join(path.delimiter);
+}
+
+export function buildServiceEnvironment(params: {
+ env: Record;
+ port: number;
+ token?: string;
+ launchdLabel?: string;
+}): Record {
+ const { env, port, token, launchdLabel } = params;
+ return {
+ PATH: buildMinimalServicePath({ env }),
+ CLAWDBOT_PROFILE: env.CLAWDBOT_PROFILE,
+ CLAWDBOT_STATE_DIR: env.CLAWDBOT_STATE_DIR,
+ CLAWDBOT_CONFIG_PATH: env.CLAWDBOT_CONFIG_PATH,
+ CLAWDBOT_GATEWAY_PORT: String(port),
+ CLAWDBOT_GATEWAY_TOKEN: token,
+ CLAWDBOT_LAUNCHD_LABEL: launchdLabel,
+ };
+}
diff --git a/src/discord/audit.test.ts b/src/discord/audit.test.ts
new file mode 100644
index 000000000..fe0b2ced6
--- /dev/null
+++ b/src/discord/audit.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it, vi } from "vitest";
+
+vi.mock("./send.js", () => ({
+ fetchChannelPermissionsDiscord: vi.fn(),
+}));
+
+describe("discord audit", () => {
+ it("collects numeric channel ids and counts unresolved keys", async () => {
+ const { collectDiscordAuditChannelIds, auditDiscordChannelPermissions } =
+ await import("./audit.js");
+ const { fetchChannelPermissionsDiscord } = await import("./send.js");
+
+ const cfg = {
+ discord: {
+ enabled: true,
+ token: "t",
+ groupPolicy: "allowlist",
+ guilds: {
+ "123": {
+ channels: {
+ "111": { allow: true },
+ general: { allow: true },
+ "222": { allow: false },
+ },
+ },
+ },
+ },
+ } as unknown as import("../config/config.js").ClawdbotConfig;
+
+ const collected = collectDiscordAuditChannelIds({
+ cfg,
+ accountId: "default",
+ });
+ expect(collected.channelIds).toEqual(["111"]);
+ expect(collected.unresolvedChannels).toBe(1);
+
+ (
+ fetchChannelPermissionsDiscord as unknown as ReturnType
+ ).mockResolvedValueOnce({
+ channelId: "111",
+ permissions: ["ViewChannel"],
+ raw: "0",
+ isDm: false,
+ });
+
+ const audit = await auditDiscordChannelPermissions({
+ token: "t",
+ accountId: "default",
+ channelIds: collected.channelIds,
+ timeoutMs: 1000,
+ });
+ expect(audit.ok).toBe(false);
+ expect(audit.channels[0]?.channelId).toBe("111");
+ expect(audit.channels[0]?.missing).toContain("SendMessages");
+ });
+});
diff --git a/src/discord/audit.ts b/src/discord/audit.ts
new file mode 100644
index 000000000..07febe86e
--- /dev/null
+++ b/src/discord/audit.ts
@@ -0,0 +1,127 @@
+import type { ClawdbotConfig } from "../config/config.js";
+import type {
+ DiscordGuildChannelConfig,
+ DiscordGuildEntry,
+} from "../config/types.js";
+import { resolveDiscordAccount } from "./accounts.js";
+import { fetchChannelPermissionsDiscord } from "./send.js";
+
+export type DiscordChannelPermissionsAuditEntry = {
+ channelId: string;
+ ok: boolean;
+ missing?: string[];
+ error?: string | null;
+};
+
+export type DiscordChannelPermissionsAudit = {
+ ok: boolean;
+ checkedChannels: number;
+ unresolvedChannels: number;
+ channels: DiscordChannelPermissionsAuditEntry[];
+ elapsedMs: number;
+};
+
+const REQUIRED_CHANNEL_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
+}
+
+function shouldAuditChannelConfig(
+ config: DiscordGuildChannelConfig | undefined,
+) {
+ if (!config) return true;
+ if (config.allow === false) return false;
+ if (config.enabled === false) return false;
+ return true;
+}
+
+function listConfiguredGuildChannelKeys(
+ guilds: Record | undefined,
+): string[] {
+ if (!guilds) return [];
+ const ids = new Set();
+ for (const entry of Object.values(guilds)) {
+ if (!entry || typeof entry !== "object") continue;
+ const channelsRaw = (entry as { channels?: unknown }).channels;
+ if (!isRecord(channelsRaw)) continue;
+ for (const [key, value] of Object.entries(channelsRaw)) {
+ const channelId = String(key).trim();
+ if (!channelId) continue;
+ if (
+ !shouldAuditChannelConfig(
+ value as DiscordGuildChannelConfig | undefined,
+ )
+ )
+ continue;
+ ids.add(channelId);
+ }
+ }
+ return [...ids].sort((a, b) => a.localeCompare(b));
+}
+
+export function collectDiscordAuditChannelIds(params: {
+ cfg: ClawdbotConfig;
+ accountId?: string | null;
+}) {
+ const account = resolveDiscordAccount({
+ cfg: params.cfg,
+ accountId: params.accountId,
+ });
+ const keys = listConfiguredGuildChannelKeys(account.config.guilds);
+ const channelIds = keys.filter((key) => /^\d+$/.test(key));
+ const unresolvedChannels = keys.length - channelIds.length;
+ return { channelIds, unresolvedChannels };
+}
+
+export async function auditDiscordChannelPermissions(params: {
+ token: string;
+ accountId?: string | null;
+ channelIds: string[];
+ timeoutMs: number;
+}): Promise {
+ const started = Date.now();
+ const token = params.token?.trim() ?? "";
+ if (!token || params.channelIds.length === 0) {
+ return {
+ ok: true,
+ checkedChannels: 0,
+ unresolvedChannels: 0,
+ channels: [],
+ elapsedMs: Date.now() - started,
+ };
+ }
+
+ const required = [...REQUIRED_CHANNEL_PERMISSIONS];
+ const channels: DiscordChannelPermissionsAuditEntry[] = [];
+
+ for (const channelId of params.channelIds) {
+ try {
+ const perms = await fetchChannelPermissionsDiscord(channelId, {
+ token,
+ accountId: params.accountId ?? undefined,
+ });
+ const missing = required.filter((p) => !perms.permissions.includes(p));
+ channels.push({
+ channelId,
+ ok: missing.length === 0,
+ missing: missing.length ? missing : undefined,
+ error: null,
+ });
+ } catch (err) {
+ channels.push({
+ channelId,
+ ok: false,
+ error: err instanceof Error ? err.message : String(err),
+ });
+ }
+ }
+
+ return {
+ ok: channels.every((c) => c.ok),
+ checkedChannels: channels.length,
+ unresolvedChannels: 0,
+ channels,
+ elapsedMs: Date.now() - started,
+ };
+}
diff --git a/src/discord/monitor.gateway.test.ts b/src/discord/monitor.gateway.test.ts
index 26685da72..f052e0f00 100644
--- a/src/discord/monitor.gateway.test.ts
+++ b/src/discord/monitor.gateway.test.ts
@@ -47,6 +47,31 @@ describe("waitForDiscordGatewayStop", () => {
expect(disconnect).toHaveBeenCalledTimes(1);
});
+ it("ignores gateway errors when instructed", async () => {
+ const emitter = new EventEmitter();
+ const disconnect = vi.fn();
+ const onGatewayError = vi.fn();
+ const abort = new AbortController();
+ const err = new Error("transient");
+
+ const promise = waitForDiscordGatewayStop({
+ gateway: { emitter, disconnect },
+ abortSignal: abort.signal,
+ onGatewayError,
+ shouldStopOnError: () => false,
+ });
+
+ emitter.emit("error", err);
+ expect(onGatewayError).toHaveBeenCalledWith(err);
+ expect(disconnect).toHaveBeenCalledTimes(0);
+ expect(emitter.listenerCount("error")).toBe(1);
+
+ abort.abort();
+ await expect(promise).resolves.toBeUndefined();
+ expect(disconnect).toHaveBeenCalledTimes(1);
+ expect(emitter.listenerCount("error")).toBe(0);
+ });
+
it("resolves on abort without a gateway", async () => {
const abort = new AbortController();
diff --git a/src/discord/monitor.gateway.ts b/src/discord/monitor.gateway.ts
index d09df288b..d6412d8a0 100644
--- a/src/discord/monitor.gateway.ts
+++ b/src/discord/monitor.gateway.ts
@@ -15,8 +15,9 @@ export async function waitForDiscordGatewayStop(params: {
gateway?: DiscordGatewayHandle;
abortSignal?: AbortSignal;
onGatewayError?: (err: unknown) => void;
+ shouldStopOnError?: (err: unknown) => boolean;
}): Promise {
- const { gateway, abortSignal, onGatewayError } = params;
+ const { gateway, abortSignal, onGatewayError, shouldStopOnError } = params;
const emitter = gateway?.emitter;
return await new Promise((resolve, reject) => {
let settled = false;
@@ -49,7 +50,10 @@ export async function waitForDiscordGatewayStop(params: {
};
const onGatewayErrorEvent = (err: unknown) => {
onGatewayError?.(err);
- finishReject(err);
+ const shouldStop = shouldStopOnError?.(err) ?? true;
+ if (shouldStop) {
+ finishReject(err);
+ }
};
if (abortSignal?.aborted) {
diff --git a/src/discord/monitor.tool-result.test.ts b/src/discord/monitor.tool-result.test.ts
index 26c824a6d..a9d09bdd1 100644
--- a/src/discord/monitor.tool-result.test.ts
+++ b/src/discord/monitor.tool-result.test.ts
@@ -5,6 +5,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMock = vi.fn();
const updateLastRouteMock = vi.fn();
const dispatchMock = vi.fn();
+const readAllowFromStoreMock = vi.fn();
+const upsertPairingRequestMock = vi.fn();
vi.mock("./send.js", () => ({
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
@@ -12,6 +14,12 @@ vi.mock("./send.js", () => ({
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
dispatchReplyFromConfig: (...args: unknown[]) => dispatchMock(...args),
}));
+vi.mock("../pairing/pairing-store.js", () => ({
+ readProviderAllowFromStore: (...args: unknown[]) =>
+ readAllowFromStoreMock(...args),
+ upsertProviderPairingRequest: (...args: unknown[]) =>
+ upsertPairingRequestMock(...args),
+}));
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -29,6 +37,10 @@ beforeEach(() => {
dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { final: 1 } };
});
+ readAllowFromStoreMock.mockReset().mockResolvedValue([]);
+ upsertPairingRequestMock
+ .mockReset()
+ .mockResolvedValue({ code: "PAIRCODE", created: true });
vi.resetModules();
});
@@ -99,6 +111,76 @@ describe("discord tool result dispatch", () => {
expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /);
}, 10000);
+ it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
+ const { createDiscordMessageHandler } = await import("./monitor.js");
+ const cfg = {
+ agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
+ session: { store: "/tmp/clawdbot-sessions.json" },
+ discord: { dm: { enabled: true, policy: "pairing", allowFrom: [] } },
+ routing: { allowFrom: [] },
+ } as ReturnType;
+
+ const handler = createDiscordMessageHandler({
+ cfg,
+ discordConfig: cfg.discord,
+ accountId: "default",
+ token: "token",
+ runtime: {
+ log: vi.fn(),
+ error: vi.fn(),
+ exit: (code: number): never => {
+ throw new Error(`exit ${code}`);
+ },
+ },
+ botUserId: "bot-id",
+ guildHistories: new Map(),
+ historyLimit: 0,
+ mediaMaxBytes: 10_000,
+ textLimit: 2000,
+ replyToMode: "off",
+ dmEnabled: true,
+ groupDmEnabled: false,
+ });
+
+ const client = {
+ fetchChannel: vi.fn().mockResolvedValue({
+ type: ChannelType.DM,
+ name: "dm",
+ }),
+ } as unknown as Client;
+
+ await handler(
+ {
+ message: {
+ id: "m1",
+ content: "hello",
+ channelId: "c1",
+ timestamp: new Date().toISOString(),
+ type: MessageType.Default,
+ attachments: [],
+ embeds: [],
+ mentionedEveryone: false,
+ mentionedUsers: [],
+ mentionedRoles: [],
+ author: { id: "u2", bot: false, username: "Ada" },
+ },
+ author: { id: "u2", bot: false, username: "Ada" },
+ guild_id: null,
+ },
+ client,
+ );
+
+ expect(dispatchMock).not.toHaveBeenCalled();
+ expect(upsertPairingRequestMock).toHaveBeenCalled();
+ expect(sendMock).toHaveBeenCalledTimes(1);
+ expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
+ "Your Discord user id: u2",
+ );
+ expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
+ "Pairing code: PAIRCODE",
+ );
+ }, 10000);
+
it("accepts guild messages when mentionPatterns match", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts
index 754d5f302..224c63a70 100644
--- a/src/discord/monitor.ts
+++ b/src/discord/monitor.ts
@@ -44,10 +44,12 @@ import { loadConfig } from "../config/config.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { formatDurationSeconds } from "../infra/format-duration.js";
+import { recordProviderActivity } from "../infra/provider-activity.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
import { detectMime } from "../media/mime.js";
import { saveMediaBuffer } from "../media/store.js";
+import { buildPairingReply } from "../pairing/pairing-messages.js";
import {
readProviderAllowFromStore,
upsertProviderPairingRequest,
@@ -330,6 +332,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
},
[
new GatewayPlugin({
+ reconnect: {
+ maxAttempts: Number.POSITIVE_INFINITY,
+ },
intents:
GatewayIntents.Guilds |
GatewayIntents.GuildMessages |
@@ -408,18 +413,35 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const gateway = client.getPlugin("gateway");
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
- await waitForDiscordGatewayStop({
- gateway: gateway
- ? {
- emitter: gatewayEmitter,
- disconnect: () => gateway.disconnect(),
- }
- : undefined,
- abortSignal: opts.abortSignal,
- onGatewayError: (err) => {
- runtime.error?.(danger(`discord gateway error: ${String(err)}`));
- },
- });
+ const onGatewayWarning = (warning: unknown) => {
+ logVerbose(`discord gateway warning: ${String(warning)}`);
+ };
+ if (shouldLogVerbose()) {
+ gatewayEmitter?.on("warning", onGatewayWarning);
+ }
+ try {
+ await waitForDiscordGatewayStop({
+ gateway: gateway
+ ? {
+ emitter: gatewayEmitter,
+ disconnect: () => gateway.disconnect(),
+ }
+ : undefined,
+ abortSignal: opts.abortSignal,
+ onGatewayError: (err) => {
+ runtime.error?.(danger(`discord gateway error: ${String(err)}`));
+ },
+ shouldStopOnError: (err) => {
+ const message = String(err);
+ return (
+ message.includes("Max reconnect attempts") ||
+ message.includes("Fatal Gateway error")
+ );
+ },
+ });
+ } finally {
+ gatewayEmitter?.removeListener("warning", onGatewayWarning);
+ }
}
async function clearDiscordNativeCommands(params: {
@@ -479,7 +501,6 @@ export function createDiscordMessageHandler(params: {
guildEntries,
} = params;
const logger = getChildLogger({ module: "discord-auto-reply" });
- const mentionRegexes = buildMentionRegexes(cfg);
const ackReaction = (cfg.messages?.ackReaction ?? "").trim();
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const groupPolicy = discordConfig?.groupPolicy ?? "open";
@@ -548,14 +569,11 @@ export function createDiscordMessageHandler(params: {
try {
await sendMessageDiscord(
`user:${author.id}`,
- [
- "Clawdbot: access not configured.",
- "",
- `Pairing code: ${code}`,
- "",
- "Ask the bot owner to approve with:",
- "clawdbot pairing approve --provider discord ",
- ].join("\n"),
+ buildPairingReply({
+ provider: "discord",
+ idLine: `Your Discord user id: ${author.id}`,
+ code,
+ }),
{ token, rest: client.rest, accountId },
);
} catch (err) {
@@ -576,6 +594,22 @@ export function createDiscordMessageHandler(params: {
}
const botId = botUserId;
const baseText = resolveDiscordMessageText(message);
+ recordProviderActivity({
+ provider: "discord",
+ accountId,
+ direction: "inbound",
+ });
+ const route = resolveAgentRoute({
+ cfg,
+ provider: "discord",
+ accountId,
+ guildId: data.guild_id ?? undefined,
+ peer: {
+ kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
+ id: isDirectMessage ? author.id : message.channelId,
+ },
+ });
+ const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const wasMentioned =
!isDirectMessage &&
(Boolean(
@@ -647,16 +681,6 @@ export function createDiscordMessageHandler(params: {
guildInfo?.slug ||
(data.guild?.name ? normalizeDiscordSlug(data.guild.name) : "");
- const route = resolveAgentRoute({
- cfg,
- provider: "discord",
- accountId,
- guildId: data.guild_id ?? undefined,
- peer: {
- kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
- id: isDirectMessage ? author.id : message.channelId,
- },
- });
const baseSessionKey = route.sessionKey;
const channelConfig = isGuildMessage
? resolveDiscordChannelConfig({
@@ -1384,14 +1408,11 @@ function createDiscordNativeCommand(params: {
});
if (created) {
await interaction.reply({
- content: [
- "Clawdbot: access not configured.",
- "",
- `Pairing code: ${code}`,
- "",
- "Ask the bot owner to approve with:",
- "clawdbot pairing approve --provider discord ",
- ].join("\n"),
+ content: buildPairingReply({
+ provider: "discord",
+ idLine: `Your Discord user id: ${user.id}`,
+ code,
+ }),
ephemeral: true,
});
}
diff --git a/src/discord/probe.intents.test.ts b/src/discord/probe.intents.test.ts
new file mode 100644
index 000000000..e7179ddb4
--- /dev/null
+++ b/src/discord/probe.intents.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+
+import { resolveDiscordPrivilegedIntentsFromFlags } from "./probe.js";
+
+describe("resolveDiscordPrivilegedIntentsFromFlags", () => {
+ it("reports disabled when no bits set", () => {
+ expect(resolveDiscordPrivilegedIntentsFromFlags(0)).toEqual({
+ presence: "disabled",
+ guildMembers: "disabled",
+ messageContent: "disabled",
+ });
+ });
+
+ it("reports enabled when full intent bits set", () => {
+ const flags = (1 << 12) | (1 << 14) | (1 << 18);
+ expect(resolveDiscordPrivilegedIntentsFromFlags(flags)).toEqual({
+ presence: "enabled",
+ guildMembers: "enabled",
+ messageContent: "enabled",
+ });
+ });
+
+ it("reports limited when limited intent bits set", () => {
+ const flags = (1 << 13) | (1 << 15) | (1 << 19);
+ expect(resolveDiscordPrivilegedIntentsFromFlags(flags)).toEqual({
+ presence: "limited",
+ guildMembers: "limited",
+ messageContent: "limited",
+ });
+ });
+
+ it("prefers enabled over limited when both set", () => {
+ const flags =
+ (1 << 12) | (1 << 13) | (1 << 14) | (1 << 15) | (1 << 18) | (1 << 19);
+ expect(resolveDiscordPrivilegedIntentsFromFlags(flags)).toEqual({
+ presence: "enabled",
+ guildMembers: "enabled",
+ messageContent: "enabled",
+ });
+ });
+});
diff --git a/src/discord/probe.ts b/src/discord/probe.ts
index 523169f32..bb15ef85a 100644
--- a/src/discord/probe.ts
+++ b/src/discord/probe.ts
@@ -8,8 +8,89 @@ export type DiscordProbe = {
error?: string | null;
elapsedMs: number;
bot?: { id?: string | null; username?: string | null };
+ application?: DiscordApplicationSummary;
};
+export type DiscordPrivilegedIntentStatus = "enabled" | "limited" | "disabled";
+
+export type DiscordPrivilegedIntentsSummary = {
+ messageContent: DiscordPrivilegedIntentStatus;
+ guildMembers: DiscordPrivilegedIntentStatus;
+ presence: DiscordPrivilegedIntentStatus;
+};
+
+export type DiscordApplicationSummary = {
+ id?: string | null;
+ flags?: number | null;
+ intents?: DiscordPrivilegedIntentsSummary;
+};
+
+const DISCORD_APP_FLAG_GATEWAY_PRESENCE = 1 << 12;
+const DISCORD_APP_FLAG_GATEWAY_PRESENCE_LIMITED = 1 << 13;
+const DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS = 1 << 14;
+const DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS_LIMITED = 1 << 15;
+const DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT = 1 << 18;
+const DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT_LIMITED = 1 << 19;
+
+export function resolveDiscordPrivilegedIntentsFromFlags(
+ flags: number,
+): DiscordPrivilegedIntentsSummary {
+ const resolve = (enabledBit: number, limitedBit: number) => {
+ if ((flags & enabledBit) !== 0) return "enabled";
+ if ((flags & limitedBit) !== 0) return "limited";
+ return "disabled";
+ };
+ return {
+ presence: resolve(
+ DISCORD_APP_FLAG_GATEWAY_PRESENCE,
+ DISCORD_APP_FLAG_GATEWAY_PRESENCE_LIMITED,
+ ),
+ guildMembers: resolve(
+ DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS,
+ DISCORD_APP_FLAG_GATEWAY_GUILD_MEMBERS_LIMITED,
+ ),
+ messageContent: resolve(
+ DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT,
+ DISCORD_APP_FLAG_GATEWAY_MESSAGE_CONTENT_LIMITED,
+ ),
+ };
+}
+
+export async function fetchDiscordApplicationSummary(
+ token: string,
+ timeoutMs: number,
+ fetcher: typeof fetch = fetch,
+): Promise {
+ const normalized = normalizeDiscordToken(token);
+ if (!normalized) return undefined;
+ try {
+ const res = await fetchWithTimeout(
+ `${DISCORD_API_BASE}/oauth2/applications/@me`,
+ timeoutMs,
+ fetcher,
+ {
+ Authorization: `Bot ${normalized}`,
+ },
+ );
+ if (!res.ok) return undefined;
+ const json = (await res.json()) as { id?: string; flags?: number };
+ const flags =
+ typeof json.flags === "number" && Number.isFinite(json.flags)
+ ? json.flags
+ : undefined;
+ return {
+ id: json.id ?? null,
+ flags: flags ?? null,
+ intents:
+ typeof flags === "number"
+ ? resolveDiscordPrivilegedIntentsFromFlags(flags)
+ : undefined,
+ };
+ } catch {
+ return undefined;
+ }
+}
+
async function fetchWithTimeout(
url: string,
timeoutMs: number,
@@ -28,8 +109,11 @@ async function fetchWithTimeout(
export async function probeDiscord(
token: string,
timeoutMs: number,
+ opts?: { fetcher?: typeof fetch; includeApplication?: boolean },
): Promise {
const started = Date.now();
+ const fetcher = opts?.fetcher ?? fetch;
+ const includeApplication = opts?.includeApplication === true;
const normalized = normalizeDiscordToken(token);
const result: DiscordProbe = {
ok: false,
@@ -48,7 +132,7 @@ export async function probeDiscord(
const res = await fetchWithTimeout(
`${DISCORD_API_BASE}/users/@me`,
timeoutMs,
- fetch,
+ fetcher,
{
Authorization: `Bot ${normalized}`,
},
@@ -64,6 +148,14 @@ export async function probeDiscord(
id: json.id ?? null,
username: json.username ?? null,
};
+ if (includeApplication) {
+ result.application =
+ (await fetchDiscordApplicationSummary(
+ normalized,
+ timeoutMs,
+ fetcher,
+ )) ?? undefined;
+ }
return { ...result, elapsedMs: Date.now() - started };
} catch (err) {
return {
diff --git a/src/discord/send.ts b/src/discord/send.ts
index 063b80937..30091fedf 100644
--- a/src/discord/send.ts
+++ b/src/discord/send.ts
@@ -18,6 +18,7 @@ import {
} from "discord-api-types/v10";
import { loadConfig } from "../config/config.js";
+import { recordProviderActivity } from "../infra/provider-activity.js";
import type { RetryConfig } from "../infra/retry.js";
import {
createDiscordRetryRunner,
@@ -589,6 +590,11 @@ export async function sendMessageDiscord(
});
}
+ recordProviderActivity({
+ provider: "discord",
+ accountId: accountInfo.accountId,
+ direction: "outbound",
+ });
return {
messageId: result.id ? String(result.id) : "unknown",
channelId: String(result.channel_id ?? channelId),
diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts
index 705b59f18..ad7c91c0e 100644
--- a/src/gateway/auth.test.ts
+++ b/src/gateway/auth.test.ts
@@ -12,4 +12,84 @@ describe("gateway auth", () => {
});
expect(res.ok).toBe(true);
});
+
+ it("reports missing and mismatched token reasons", async () => {
+ const missing = await authorizeGatewayConnect({
+ auth: { mode: "token", token: "secret", allowTailscale: false },
+ connectAuth: null,
+ });
+ expect(missing.ok).toBe(false);
+ expect(missing.reason).toBe("token_missing");
+
+ const mismatch = await authorizeGatewayConnect({
+ auth: { mode: "token", token: "secret", allowTailscale: false },
+ connectAuth: { token: "wrong" },
+ });
+ expect(mismatch.ok).toBe(false);
+ expect(mismatch.reason).toBe("token_mismatch");
+ });
+
+ it("reports missing token config reason", async () => {
+ const res = await authorizeGatewayConnect({
+ auth: { mode: "token", allowTailscale: false },
+ connectAuth: { token: "anything" },
+ });
+ expect(res.ok).toBe(false);
+ expect(res.reason).toBe("token_missing_config");
+ });
+
+ it("reports missing and mismatched password reasons", async () => {
+ const missing = await authorizeGatewayConnect({
+ auth: { mode: "password", password: "secret", allowTailscale: false },
+ connectAuth: null,
+ });
+ expect(missing.ok).toBe(false);
+ expect(missing.reason).toBe("password_missing");
+
+ const mismatch = await authorizeGatewayConnect({
+ auth: { mode: "password", password: "secret", allowTailscale: false },
+ connectAuth: { password: "wrong" },
+ });
+ expect(mismatch.ok).toBe(false);
+ expect(mismatch.reason).toBe("password_mismatch");
+ });
+
+ it("reports missing password config reason", async () => {
+ const res = await authorizeGatewayConnect({
+ auth: { mode: "password", allowTailscale: false },
+ connectAuth: { password: "secret" },
+ });
+ expect(res.ok).toBe(false);
+ expect(res.reason).toBe("password_missing_config");
+ });
+
+ it("reports tailscale auth reasons when required", async () => {
+ const reqBase = {
+ socket: { remoteAddress: "100.100.100.100" },
+ headers: { host: "gateway.local" },
+ };
+
+ const missingUser = await authorizeGatewayConnect({
+ auth: { mode: "none", allowTailscale: true },
+ connectAuth: null,
+ req: reqBase as never,
+ });
+ expect(missingUser.ok).toBe(false);
+ expect(missingUser.reason).toBe("tailscale_user_missing");
+
+ const missingProxy = await authorizeGatewayConnect({
+ auth: { mode: "none", allowTailscale: true },
+ connectAuth: null,
+ req: {
+ ...reqBase,
+ headers: {
+ host: "gateway.local",
+ "tailscale-user-login": "peter",
+ "tailscale-user-name": "Peter",
+ },
+ } as never,
+ });
+ expect(missingProxy.ok).toBe(false);
+ expect(missingProxy.reason).toBe("tailscale_proxy_missing");
+ });
});
diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts
index 1aecd1348..91577a342 100644
--- a/src/gateway/auth.ts
+++ b/src/gateway/auth.ts
@@ -150,10 +150,10 @@ export async function authorizeGatewayConnect(params: {
if (auth.allowTailscale && !localDirect) {
const tailscaleUser = getTailscaleUser(req);
if (!tailscaleUser) {
- return { ok: false, reason: "unauthorized" };
+ return { ok: false, reason: "tailscale_user_missing" };
}
if (!isTailscaleProxyRequest(req)) {
- return { ok: false, reason: "unauthorized" };
+ return { ok: false, reason: "tailscale_proxy_missing" };
}
return {
ok: true,
@@ -165,31 +165,45 @@ export async function authorizeGatewayConnect(params: {
}
if (auth.mode === "token") {
- if (auth.token && connectAuth?.token === auth.token) {
- return { ok: true, method: "token" };
+ if (!auth.token) {
+ return { ok: false, reason: "token_missing_config" };
}
+ if (!connectAuth?.token) {
+ return { ok: false, reason: "token_missing" };
+ }
+ if (connectAuth.token !== auth.token) {
+ return { ok: false, reason: "token_mismatch" };
+ }
+ return { ok: true, method: "token" };
}
if (auth.mode === "password") {
const password = connectAuth?.password;
- if (!password || !auth.password) {
- return { ok: false, reason: "unauthorized" };
+ if (!auth.password) {
+ return { ok: false, reason: "password_missing_config" };
+ }
+ if (!password) {
+ return { ok: false, reason: "password_missing" };
}
if (!safeEqual(password, auth.password)) {
- return { ok: false, reason: "unauthorized" };
+ return { ok: false, reason: "password_mismatch" };
}
return { ok: true, method: "password" };
}
if (auth.allowTailscale) {
const tailscaleUser = getTailscaleUser(req);
- if (tailscaleUser && isTailscaleProxyRequest(req)) {
- return {
- ok: true,
- method: "tailscale",
- user: tailscaleUser.login,
- };
+ if (!tailscaleUser) {
+ return { ok: false, reason: "tailscale_user_missing" };
}
+ if (!isTailscaleProxyRequest(req)) {
+ return { ok: false, reason: "tailscale_proxy_missing" };
+ }
+ return {
+ ok: true,
+ method: "tailscale",
+ user: tailscaleUser.login,
+ };
}
return { ok: false, reason: "unauthorized" };
diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts
index 5f76ee5ed..18d40aff0 100644
--- a/src/gateway/protocol/index.ts
+++ b/src/gateway/protocol/index.ts
@@ -3,6 +3,12 @@ import {
type AgentEvent,
AgentEventSchema,
AgentParamsSchema,
+ type AgentSummary,
+ AgentSummarySchema,
+ type AgentsListParams,
+ AgentsListParamsSchema,
+ type AgentsListResult,
+ AgentsListResultSchema,
type AgentWaitParams,
AgentWaitParamsSchema,
type ChatAbortParams,
@@ -163,6 +169,9 @@ export const validateAgentWaitParams = ajv.compile(
AgentWaitParamsSchema,
);
export const validateWakeParams = ajv.compile(WakeParamsSchema);
+export const validateAgentsListParams = ajv.compile(
+ AgentsListParamsSchema,
+);
export const validateNodePairRequestParams = ajv.compile(
NodePairRequestParamsSchema,
);
@@ -332,6 +341,9 @@ export {
ProvidersStatusParamsSchema,
WebLoginStartParamsSchema,
WebLoginWaitParamsSchema,
+ AgentSummarySchema,
+ AgentsListParamsSchema,
+ AgentsListResultSchema,
ModelsListParamsSchema,
SkillsStatusParamsSchema,
SkillsInstallParamsSchema,
@@ -394,6 +406,9 @@ export type {
ProvidersStatusParams,
WebLoginStartParams,
WebLoginWaitParams,
+ AgentSummary,
+ AgentsListParams,
+ AgentsListResult,
SkillsStatusParams,
SkillsInstallParams,
SkillsUpdateParams,
diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts
index eccb05ffe..ac11af14c 100644
--- a/src/gateway/protocol/schema.ts
+++ b/src/gateway/protocol/schema.ts
@@ -314,6 +314,7 @@ export const SessionsListParamsSchema = Type.Object(
includeGlobal: Type.Optional(Type.Boolean()),
includeUnknown: Type.Optional(Type.Boolean()),
spawnedBy: Type.Optional(NonEmptyString),
+ agentId: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);
@@ -590,6 +591,29 @@ export const ModelChoiceSchema = Type.Object(
{ additionalProperties: false },
);
+export const AgentSummarySchema = Type.Object(
+ {
+ id: NonEmptyString,
+ name: Type.Optional(NonEmptyString),
+ },
+ { additionalProperties: false },
+);
+
+export const AgentsListParamsSchema = Type.Object(
+ {},
+ { additionalProperties: false },
+);
+
+export const AgentsListResultSchema = Type.Object(
+ {
+ defaultId: NonEmptyString,
+ mainKey: NonEmptyString,
+ scope: Type.Union([Type.Literal("per-sender"), Type.Literal("global")]),
+ agents: Type.Array(AgentSummarySchema),
+ },
+ { additionalProperties: false },
+);
+
export const ModelsListParamsSchema = Type.Object(
{},
{ additionalProperties: false },
@@ -927,6 +951,9 @@ export const ProtocolSchemas: Record = {
ProvidersStatusParams: ProvidersStatusParamsSchema,
WebLoginStartParams: WebLoginStartParamsSchema,
WebLoginWaitParams: WebLoginWaitParamsSchema,
+ AgentSummary: AgentSummarySchema,
+ AgentsListParams: AgentsListParamsSchema,
+ AgentsListResult: AgentsListResultSchema,
ModelChoice: ModelChoiceSchema,
ModelsListParams: ModelsListParamsSchema,
ModelsListResult: ModelsListResultSchema,
@@ -1000,6 +1027,9 @@ export type TalkModeParams = Static;
export type ProvidersStatusParams = Static;
export type WebLoginStartParams = Static;
export type WebLoginWaitParams = Static;
+export type AgentSummary = Static;
+export type AgentsListParams = Static;
+export type AgentsListResult = Static;
export type ModelChoice = Static;
export type ModelsListParams = Static;
export type ModelsListResult = Static;
diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts
index 39a19a748..c6d7d352f 100644
--- a/src/gateway/server-methods.ts
+++ b/src/gateway/server-methods.ts
@@ -1,5 +1,6 @@
import { ErrorCodes, errorShape } from "./protocol/index.js";
import { agentHandlers } from "./server-methods/agent.js";
+import { agentsHandlers } from "./server-methods/agents.js";
import { chatHandlers } from "./server-methods/chat.js";
import { configHandlers } from "./server-methods/config.js";
import { connectHandlers } from "./server-methods/connect.js";
@@ -45,6 +46,7 @@ const handlers: GatewayRequestHandlers = {
...sendHandlers,
...usageHandlers,
...agentHandlers,
+ ...agentsHandlers,
};
export async function handleGatewayRequest(
diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts
new file mode 100644
index 000000000..444efbe9d
--- /dev/null
+++ b/src/gateway/server-methods/agents.ts
@@ -0,0 +1,29 @@
+import { loadConfig } from "../../config/config.js";
+import {
+ ErrorCodes,
+ errorShape,
+ formatValidationErrors,
+ validateAgentsListParams,
+} from "../protocol/index.js";
+import { listAgentsForGateway } from "../session-utils.js";
+import type { GatewayRequestHandlers } from "./types.js";
+
+export const agentsHandlers: GatewayRequestHandlers = {
+ "agents.list": ({ params, respond }) => {
+ if (!validateAgentsListParams(params)) {
+ respond(
+ false,
+ undefined,
+ errorShape(
+ ErrorCodes.INVALID_REQUEST,
+ `invalid agents.list params: ${formatValidationErrors(validateAgentsListParams.errors)}`,
+ ),
+ );
+ return;
+ }
+
+ const cfg = loadConfig();
+ const result = listAgentsForGateway(cfg);
+ respond(true, result, undefined);
+ },
+};
diff --git a/src/gateway/server-methods/providers.ts b/src/gateway/server-methods/providers.ts
index a9318a0ee..a56bee292 100644
--- a/src/gateway/server-methods/providers.ts
+++ b/src/gateway/server-methods/providers.ts
@@ -4,11 +4,16 @@ import {
readConfigFileSnapshot,
writeConfigFile,
} from "../../config/config.js";
+import type { TelegramGroupConfig } from "../../config/types.js";
import {
listDiscordAccountIds,
resolveDefaultDiscordAccountId,
resolveDiscordAccount,
} from "../../discord/accounts.js";
+import {
+ auditDiscordChannelPermissions,
+ collectDiscordAuditChannelIds,
+} from "../../discord/audit.js";
import { type DiscordProbe, probeDiscord } from "../../discord/probe.js";
import {
listIMessageAccountIds,
@@ -16,6 +21,7 @@ import {
resolveIMessageAccount,
} from "../../imessage/accounts.js";
import { type IMessageProbe, probeIMessage } from "../../imessage/probe.js";
+import { getProviderActivity } from "../../infra/provider-activity.js";
import {
listSignalAccountIds,
resolveDefaultSignalAccountId,
@@ -33,6 +39,10 @@ import {
resolveDefaultTelegramAccountId,
resolveTelegramAccount,
} from "../../telegram/accounts.js";
+import {
+ auditTelegramGroupMembership,
+ collectTelegramUnmentionedGroupIds,
+} from "../../telegram/audit.js";
import { probeTelegram, type TelegramProbe } from "../../telegram/probe.js";
import {
listEnabledWhatsAppAccounts,
@@ -89,6 +99,16 @@ export const providersHandlers: GatewayRequestHandlers = {
const configured = Boolean(account.token);
let telegramProbe: TelegramProbe | undefined;
let lastProbeAt: number | null = null;
+ const groups =
+ cfg.telegram?.accounts?.[account.accountId]?.groups ??
+ cfg.telegram?.groups;
+ const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } =
+ collectTelegramUnmentionedGroupIds(
+ groups as Record | undefined,
+ );
+ let audit:
+ | Awaited>
+ | undefined;
if (probe && configured && account.enabled) {
telegramProbe = await probeTelegram(
account.token,
@@ -96,7 +116,47 @@ export const providersHandlers: GatewayRequestHandlers = {
account.config.proxy,
);
lastProbeAt = Date.now();
+ const botId =
+ telegramProbe.ok && telegramProbe.bot?.id != null
+ ? telegramProbe.bot.id
+ : null;
+ if (botId && (groupIds.length > 0 || unresolvedGroups > 0)) {
+ const auditRes = await auditTelegramGroupMembership({
+ token: account.token,
+ botId,
+ groupIds,
+ proxyUrl: account.config.proxy,
+ timeoutMs,
+ });
+ audit = {
+ ...auditRes,
+ unresolvedGroups,
+ hasWildcardUnmentionedGroups,
+ };
+ } else if (unresolvedGroups > 0 || hasWildcardUnmentionedGroups) {
+ audit = {
+ ok: unresolvedGroups === 0 && !hasWildcardUnmentionedGroups,
+ checkedGroups: 0,
+ unresolvedGroups,
+ hasWildcardUnmentionedGroups,
+ groups: [],
+ elapsedMs: 0,
+ };
+ }
}
+ const allowUnmentionedGroups =
+ Boolean(
+ groups?.["*"] &&
+ (groups["*"] as { requireMention?: boolean }).requireMention ===
+ false,
+ ) ||
+ Object.entries(groups ?? {}).some(
+ ([key, value]) =>
+ key !== "*" &&
+ Boolean(value) &&
+ typeof value === "object" &&
+ (value as { requireMention?: boolean }).requireMention === false,
+ );
return {
accountId: account.accountId,
name: account.name,
@@ -110,6 +170,16 @@ export const providersHandlers: GatewayRequestHandlers = {
lastError: rt?.lastError ?? null,
probe: telegramProbe,
lastProbeAt,
+ audit,
+ allowUnmentionedGroups,
+ lastInboundAt: getProviderActivity({
+ provider: "telegram",
+ accountId: account.accountId,
+ }).inboundAt,
+ lastOutboundAt: getProviderActivity({
+ provider: "telegram",
+ accountId: account.accountId,
+ }).outboundAt,
};
}),
);
@@ -129,9 +199,25 @@ export const providersHandlers: GatewayRequestHandlers = {
const configured = Boolean(account.token);
let discordProbe: DiscordProbe | undefined;
let lastProbeAt: number | null = null;
+ const { channelIds: auditChannelIds, unresolvedChannels } =
+ collectDiscordAuditChannelIds({ cfg, accountId: account.accountId });
+ let audit:
+ | Awaited>
+ | undefined;
if (probe && configured && account.enabled) {
- discordProbe = await probeDiscord(account.token, timeoutMs);
+ discordProbe = await probeDiscord(account.token, timeoutMs, {
+ includeApplication: true,
+ });
lastProbeAt = Date.now();
+ if (auditChannelIds.length > 0 || unresolvedChannels > 0) {
+ const auditRes = await auditDiscordChannelPermissions({
+ token: account.token,
+ accountId: account.accountId,
+ channelIds: auditChannelIds,
+ timeoutMs,
+ });
+ audit = { ...auditRes, unresolvedChannels };
+ }
}
return {
accountId: account.accountId,
@@ -139,12 +225,23 @@ export const providersHandlers: GatewayRequestHandlers = {
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
+ bot: rt?.bot ?? null,
+ application: rt?.application ?? null,
running: rt?.running ?? false,
lastStartAt: rt?.lastStartAt ?? null,
lastStopAt: rt?.lastStopAt ?? null,
lastError: rt?.lastError ?? null,
probe: discordProbe,
lastProbeAt,
+ audit,
+ lastInboundAt: getProviderActivity({
+ provider: "discord",
+ accountId: account.accountId,
+ }).inboundAt,
+ lastOutboundAt: getProviderActivity({
+ provider: "discord",
+ accountId: account.accountId,
+ }).outboundAt,
};
}),
);
@@ -302,6 +399,14 @@ export const providersHandlers: GatewayRequestHandlers = {
lastMessageAt: rt.lastMessageAt ?? null,
lastEventAt: rt.lastEventAt ?? null,
lastError: rt.lastError ?? null,
+ lastInboundAt: getProviderActivity({
+ provider: "whatsapp",
+ accountId: account.accountId,
+ }).inboundAt,
+ lastOutboundAt: getProviderActivity({
+ provider: "whatsapp",
+ accountId: account.accountId,
+ }).outboundAt,
};
}),
);
diff --git a/src/gateway/server-providers.ts b/src/gateway/server-providers.ts
index 846c3ec1d..1a4efbbc1 100644
--- a/src/gateway/server-providers.ts
+++ b/src/gateway/server-providers.ts
@@ -5,6 +5,10 @@ import {
resolveDiscordAccount,
} from "../discord/accounts.js";
import { monitorDiscordProvider } from "../discord/index.js";
+import type {
+ DiscordApplicationSummary,
+ DiscordProbe,
+} from "../discord/probe.js";
import { probeDiscord } from "../discord/probe.js";
import { shouldLogVerbose } from "../globals.js";
import {
@@ -56,6 +60,8 @@ export type DiscordRuntimeStatus = {
lastStartAt?: number | null;
lastStopAt?: number | null;
lastError?: string | null;
+ bot?: DiscordProbe["bot"];
+ application?: DiscordApplicationSummary;
};
export type SlackRuntimeStatus = {
@@ -194,6 +200,8 @@ export function createProviderManager(
lastStartAt: null,
lastStopAt: null,
lastError: null,
+ bot: undefined,
+ application: undefined,
});
const defaultSlackStatus = (): SlackRuntimeStatus => ({
running: false,
@@ -544,9 +552,24 @@ export function createProviderManager(
}
let discordBotLabel = "";
try {
- const probe = await probeDiscord(token, 2500);
+ const probe = await probeDiscord(token, 2500, {
+ includeApplication: true,
+ });
const username = probe.ok ? probe.bot?.username?.trim() : null;
if (username) discordBotLabel = ` (@${username})`;
+ const latest =
+ discordRuntimes.get(account.accountId) ?? defaultDiscordStatus();
+ discordRuntimes.set(account.accountId, {
+ ...latest,
+ bot: probe.bot,
+ application: probe.application,
+ });
+ const messageContent = probe.application?.intents?.messageContent;
+ if (messageContent && messageContent !== "enabled") {
+ logDiscord.warn(
+ `[${account.accountId}] Discord Message Content Intent is ${messageContent}; bot may not respond to channel messages. Enable it in Discord Dev Portal (Bot → Privileged Gateway Intents) or require mentions.`,
+ );
+ }
} catch (err) {
if (shouldLogVerbose()) {
logDiscord.debug(
diff --git a/src/gateway/server.agents.test.ts b/src/gateway/server.agents.test.ts
new file mode 100644
index 000000000..36ab82f4a
--- /dev/null
+++ b/src/gateway/server.agents.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, test } from "vitest";
+import {
+ connectOk,
+ installGatewayTestHooks,
+ rpcReq,
+ startServerWithClient,
+ testState,
+} from "./test-helpers.js";
+
+installGatewayTestHooks();
+
+describe("gateway server agents", () => {
+ test("lists configured agents via agents.list RPC", async () => {
+ testState.routingConfig = {
+ defaultAgentId: "work",
+ agents: {
+ work: { name: "Work" },
+ home: { name: "Home" },
+ },
+ };
+
+ const { ws } = await startServerWithClient();
+ const hello = await connectOk(ws);
+ expect(
+ (hello as unknown as { features?: { methods?: string[] } }).features
+ ?.methods,
+ ).toEqual(expect.arrayContaining(["agents.list"]));
+
+ const res = await rpcReq<{
+ defaultId: string;
+ mainKey: string;
+ scope: string;
+ agents: Array<{ id: string; name?: string }>;
+ }>(ws, "agents.list", {});
+
+ expect(res.ok).toBe(true);
+ expect(res.payload?.defaultId).toBe("work");
+ expect(res.payload?.mainKey).toBe("main");
+ expect(res.payload?.scope).toBe("per-sender");
+ expect(res.payload?.agents.map((agent) => agent.id)).toEqual([
+ "work",
+ "home",
+ ]);
+ const work = res.payload?.agents.find((agent) => agent.id === "work");
+ const home = res.payload?.agents.find((agent) => agent.id === "home");
+ expect(work?.name).toBe("Work");
+ expect(home?.name).toBe("Home");
+ });
+});
diff --git a/src/gateway/server.sessions.test.ts b/src/gateway/server.sessions.test.ts
index d700c2d65..2f346018f 100644
--- a/src/gateway/server.sessions.test.ts
+++ b/src/gateway/server.sessions.test.ts
@@ -320,4 +320,84 @@ describe("gateway server sessions", () => {
ws.close();
await server.close();
});
+
+ test("filters sessions by agentId", async () => {
+ const dir = await fs.mkdtemp(
+ path.join(os.tmpdir(), "clawdbot-sessions-agents-"),
+ );
+ testState.sessionConfig = {
+ store: path.join(dir, "{agentId}", "sessions.json"),
+ };
+ testState.routingConfig = {
+ defaultAgentId: "home",
+ agents: {
+ home: {},
+ work: {},
+ },
+ };
+ const homeDir = path.join(dir, "home");
+ const workDir = path.join(dir, "work");
+ await fs.mkdir(homeDir, { recursive: true });
+ await fs.mkdir(workDir, { recursive: true });
+ await fs.writeFile(
+ path.join(homeDir, "sessions.json"),
+ JSON.stringify(
+ {
+ "agent:home:main": {
+ sessionId: "sess-home-main",
+ updatedAt: Date.now(),
+ },
+ "agent:home:discord:group:dev": {
+ sessionId: "sess-home-group",
+ updatedAt: Date.now() - 1000,
+ },
+ },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
+ await fs.writeFile(
+ path.join(workDir, "sessions.json"),
+ JSON.stringify(
+ {
+ "agent:work:main": {
+ sessionId: "sess-work-main",
+ updatedAt: Date.now(),
+ },
+ },
+ null,
+ 2,
+ ),
+ "utf-8",
+ );
+
+ const { ws } = await startServerWithClient();
+ await connectOk(ws);
+
+ const homeSessions = await rpcReq<{
+ sessions: Array<{ key: string }>;
+ }>(ws, "sessions.list", {
+ includeGlobal: false,
+ includeUnknown: false,
+ agentId: "home",
+ });
+ expect(homeSessions.ok).toBe(true);
+ expect(homeSessions.payload?.sessions.map((s) => s.key).sort()).toEqual([
+ "agent:home:discord:group:dev",
+ "agent:home:main",
+ ]);
+
+ const workSessions = await rpcReq<{
+ sessions: Array<{ key: string }>;
+ }>(ws, "sessions.list", {
+ includeGlobal: false,
+ includeUnknown: false,
+ agentId: "work",
+ });
+ expect(workSessions.ok).toBe(true);
+ expect(workSessions.payload?.sessions.map((s) => s.key)).toEqual([
+ "agent:work:main",
+ ]);
+ });
});
diff --git a/src/gateway/server.ts b/src/gateway/server.ts
index caf5696b2..cfd2a849d 100644
--- a/src/gateway/server.ts
+++ b/src/gateway/server.ts
@@ -227,6 +227,7 @@ const METHODS = [
"wizard.status",
"talk.mode",
"models.list",
+ "agents.list",
"skills.status",
"skills.install",
"skills.update",
@@ -1202,10 +1203,17 @@ export async function startGatewayServer(
wss.on("connection", (socket, upgradeReq) => {
let client: Client | null = null;
let closed = false;
+ const openedAt = Date.now();
const connId = randomUUID();
const remoteAddr = (
socket as WebSocket & { _socket?: { remoteAddress?: string } }
)._socket?.remoteAddress;
+ const headerValue = (value: string | string[] | undefined) =>
+ Array.isArray(value) ? value[0] : value;
+ const requestHost = headerValue(upgradeReq.headers.host);
+ const requestOrigin = headerValue(upgradeReq.headers.origin);
+ const requestUserAgent = headerValue(upgradeReq.headers["user-agent"]);
+ const forwardedFor = headerValue(upgradeReq.headers["x-forwarded-for"]);
const canvasHostPortForWs =
canvasHostServer?.port ?? (canvasHost ? port : undefined);
const canvasHostOverride =
@@ -1223,6 +1231,19 @@ export async function startGatewayServer(
const isWebchatConnect = (params: ConnectParams | null | undefined) =>
params?.client?.mode === "webchat" ||
params?.client?.name === "webchat-ui";
+ let handshakeState: "pending" | "connected" | "failed" = "pending";
+ let closeCause: string | undefined;
+ let closeMeta: Record = {};
+ let lastFrameType: string | undefined;
+ let lastFrameMethod: string | undefined;
+ let lastFrameId: string | undefined;
+
+ const setCloseCause = (cause: string, meta?: Record) => {
+ if (!closeCause) closeCause = cause;
+ if (meta && Object.keys(meta).length > 0) {
+ closeMeta = { ...closeMeta, ...meta };
+ }
+ };
const send = (obj: unknown) => {
try {
@@ -1251,9 +1272,24 @@ export async function startGatewayServer(
close();
});
socket.once("close", (code, reason) => {
+ const durationMs = Date.now() - openedAt;
+ const closeContext = {
+ cause: closeCause,
+ handshake: handshakeState,
+ durationMs,
+ lastFrameType,
+ lastFrameMethod,
+ lastFrameId,
+ host: requestHost,
+ origin: requestOrigin,
+ userAgent: requestUserAgent,
+ forwardedFor,
+ ...closeMeta,
+ };
if (!client) {
logWsControl.warn(
`closed before connect conn=${connId} remote=${remoteAddr ?? "?"} code=${code ?? "n/a"} reason=${reason?.toString() || "n/a"}`,
+ closeContext,
);
}
if (client && isWebchatConnect(client.connect)) {
@@ -1280,12 +1316,22 @@ export async function startGatewayServer(
connId,
code,
reason: reason?.toString(),
+ durationMs,
+ cause: closeCause,
+ handshake: handshakeState,
+ lastFrameType,
+ lastFrameMethod,
+ lastFrameId,
});
close();
});
const handshakeTimer = setTimeout(() => {
if (!client) {
+ handshakeState = "failed";
+ setCloseCause("handshake-timeout", {
+ handshakeMs: Date.now() - openedAt,
+ });
logWsControl.warn(
`handshake timeout conn=${connId} remote=${remoteAddr ?? "?"}`,
);
@@ -1298,6 +1344,29 @@ export async function startGatewayServer(
const text = rawDataToString(data);
try {
const parsed = JSON.parse(text);
+ const frameType =
+ parsed && typeof parsed === "object" && "type" in parsed
+ ? typeof (parsed as { type?: unknown }).type === "string"
+ ? String((parsed as { type?: unknown }).type)
+ : undefined
+ : undefined;
+ const frameMethod =
+ parsed && typeof parsed === "object" && "method" in parsed
+ ? typeof (parsed as { method?: unknown }).method === "string"
+ ? String((parsed as { method?: unknown }).method)
+ : undefined
+ : undefined;
+ const frameId =
+ parsed && typeof parsed === "object" && "id" in parsed
+ ? typeof (parsed as { id?: unknown }).id === "string"
+ ? String((parsed as { id?: unknown }).id)
+ : undefined
+ : undefined;
+ if (frameType || frameMethod || frameId) {
+ lastFrameType = frameType;
+ lastFrameMethod = frameMethod;
+ lastFrameId = frameId;
+ }
if (!client) {
// Handshake must be a normal request:
// { type:"req", method:"connect", params: ConnectParams }.
@@ -1324,6 +1393,18 @@ export async function startGatewayServer(
`invalid handshake conn=${connId} remote=${remoteAddr ?? "?"}`,
);
}
+ handshakeState = "failed";
+ const handshakeError = validateRequestFrame(parsed)
+ ? (parsed as RequestFrame).method === "connect"
+ ? `invalid connect params: ${formatValidationErrors(validateConnectParams.errors)}`
+ : "invalid handshake: first request must be connect"
+ : "invalid request frame";
+ setCloseCause("invalid-handshake", {
+ frameType,
+ frameMethod,
+ frameId,
+ handshakeError,
+ });
socket.close(1008, "invalid handshake");
close();
return;
@@ -1338,9 +1419,18 @@ export async function startGatewayServer(
maxProtocol < PROTOCOL_VERSION ||
minProtocol > PROTOCOL_VERSION
) {
+ handshakeState = "failed";
logWsControl.warn(
`protocol mismatch conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`,
);
+ setCloseCause("protocol-mismatch", {
+ minProtocol,
+ maxProtocol,
+ expectedProtocol: PROTOCOL_VERSION,
+ client: connectParams.client.name,
+ mode: connectParams.client.mode,
+ version: connectParams.client.version,
+ });
send({
type: "res",
id: frame.id,
@@ -1364,9 +1454,24 @@ export async function startGatewayServer(
req: upgradeReq,
});
if (!authResult.ok) {
+ handshakeState = "failed";
logWsControl.warn(
`unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${connectParams.client.name} ${connectParams.client.mode} v${connectParams.client.version}`,
);
+ const authProvided = connectParams.auth?.token
+ ? "token"
+ : connectParams.auth?.password
+ ? "password"
+ : "none";
+ setCloseCause("unauthorized", {
+ authMode: resolvedAuth.mode,
+ authProvided,
+ authReason: authResult.reason,
+ allowTailscale: resolvedAuth.allowTailscale,
+ client: connectParams.client.name,
+ mode: connectParams.client.mode,
+ version: connectParams.client.version,
+ });
send({
type: "res",
id: frame.id,
@@ -1444,6 +1549,7 @@ export async function startGatewayServer(
clearTimeout(handshakeTimer);
client = { socket, connect: connectParams, connId, presenceKey };
+ handshakeState = "connected";
logWs("out", "hello-ok", {
connId,
diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts
index 91dc540e8..dd3bb0024 100644
--- a/src/gateway/session-utils.ts
+++ b/src/gateway/session-utils.ts
@@ -17,8 +17,10 @@ import {
resolveSessionTranscriptPath,
resolveStorePath,
type SessionEntry,
+ type SessionScope,
} from "../config/sessions.js";
import {
+ DEFAULT_MAIN_KEY,
normalizeAgentId,
parseAgentSessionKey,
} from "../routing/session-key.js";
@@ -56,6 +58,11 @@ export type GatewaySessionRow = {
lastAccountId?: string;
};
+export type GatewayAgentRow = {
+ id: string;
+ name?: string;
+};
+
export type SessionsListResult = {
ts: number;
path: string;
@@ -237,6 +244,39 @@ function listConfiguredAgentIds(cfg: ClawdbotConfig): string[] {
return sorted;
}
+export function listAgentsForGateway(cfg: ClawdbotConfig): {
+ defaultId: string;
+ mainKey: string;
+ scope: SessionScope;
+ agents: GatewayAgentRow[];
+} {
+ const defaultId = normalizeAgentId(cfg.routing?.defaultAgentId);
+ const mainKey =
+ (cfg.session?.mainKey ?? DEFAULT_MAIN_KEY).trim() || DEFAULT_MAIN_KEY;
+ const scope = cfg.session?.scope ?? "per-sender";
+ const configured = cfg.routing?.agents;
+ const configuredById = new Map();
+ if (configured && typeof configured === "object") {
+ for (const [key, value] of Object.entries(configured)) {
+ if (!value || typeof value !== "object") continue;
+ configuredById.set(normalizeAgentId(key), {
+ name:
+ typeof value.name === "string" && value.name.trim()
+ ? value.name.trim()
+ : undefined,
+ });
+ }
+ }
+ const agents = listConfiguredAgentIds(cfg).map((id) => {
+ const meta = configuredById.get(id);
+ return {
+ id,
+ name: meta?.name,
+ };
+ });
+ return { defaultId, mainKey, scope, agents };
+}
+
function canonicalizeSessionKeyForAgent(agentId: string, key: string): string {
if (key === "global" || key === "unknown") return key;
if (key.startsWith("agent:")) return key;
@@ -394,6 +434,8 @@ export function listSessionsFromStore(params: {
const includeGlobal = opts.includeGlobal === true;
const includeUnknown = opts.includeUnknown === true;
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
+ const agentId =
+ typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
const activeMinutes =
typeof opts.activeMinutes === "number" &&
Number.isFinite(opts.activeMinutes)
@@ -404,6 +446,12 @@ export function listSessionsFromStore(params: {
.filter(([key]) => {
if (!includeGlobal && key === "global") return false;
if (!includeUnknown && key === "unknown") return false;
+ if (agentId) {
+ if (key === "global" || key === "unknown") return false;
+ const parsed = parseAgentSessionKey(key);
+ if (!parsed) return false;
+ return normalizeAgentId(parsed.agentId) === agentId;
+ }
return true;
})
.filter(([key, entry]) => {
diff --git a/src/gateway/test-helpers.ts b/src/gateway/test-helpers.ts
index 98caaa89a..c7b454603 100644
--- a/src/gateway/test-helpers.ts
+++ b/src/gateway/test-helpers.ts
@@ -85,6 +85,7 @@ export const agentCommand = hoisted.agentCommand;
export const testState = {
agentConfig: undefined as Record | undefined,
+ routingConfig: undefined as Record | undefined,
sessionStorePath: undefined as string | undefined,
sessionConfig: undefined as Record | undefined,
allowFrom: undefined as string[] | undefined,
@@ -246,6 +247,7 @@ vi.mock("../config/config.js", async () => {
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
...testState.agentConfig,
},
+ routing: testState.routingConfig,
whatsapp: {
allowFrom: testState.allowFrom,
},
@@ -354,6 +356,7 @@ export function installGatewayTestHooks() {
testState.sessionConfig = undefined;
testState.sessionStorePath = undefined;
testState.agentConfig = undefined;
+ testState.routingConfig = undefined;
testState.allowFrom = undefined;
testIsNixMode.value = false;
cronIsolatedRun.mockClear();
diff --git a/src/imessage/monitor.test.ts b/src/imessage/monitor.test.ts
index 52cae4963..c7e67171f 100644
--- a/src/imessage/monitor.test.ts
+++ b/src/imessage/monitor.test.ts
@@ -284,6 +284,9 @@ describe("monitorIMessageProvider", () => {
expect(replyMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).toHaveBeenCalled();
expect(sendMock).toHaveBeenCalledTimes(1);
+ expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
+ "Your iMessage sender id: +15550001111",
+ );
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain(
"Pairing code: PAIRCODE",
);
diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts
index b30f066b6..b8c0aef4a 100644
--- a/src/imessage/monitor.ts
+++ b/src/imessage/monitor.ts
@@ -17,6 +17,7 @@ import {
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { mediaKindFromMime } from "../media/constants.js";
+import { buildPairingReply } from "../pairing/pairing-messages.js";
import {
readProviderAllowFromStore,
upsertProviderPairingRequest,
@@ -149,7 +150,6 @@ export async function monitorIMessageProvider(
);
const groupPolicy = imessageCfg.groupPolicy ?? "open";
const dmPolicy = imessageCfg.dmPolicy ?? "pairing";
- const mentionRegexes = buildMentionRegexes(cfg);
const includeAttachments =
opts.includeAttachments ?? imessageCfg.includeAttachments ?? false;
const mediaMaxBytes =
@@ -257,14 +257,11 @@ export async function monitorIMessageProvider(
try {
await sendMessageIMessage(
sender,
- [
- "Clawdbot: access not configured.",
- "",
- `Pairing code: ${code}`,
- "",
- "Ask the bot owner to approve with:",
- "clawdbot pairing approve --provider imessage