Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a14267dfa | ||
|
|
010d305401 | ||
|
|
3210c91f6b | ||
|
|
e3cea55d72 | ||
|
|
687a902f3e | ||
|
|
fe860de148 | ||
|
|
bc8a59faa4 | ||
|
|
91bcdad503 | ||
|
|
ab97c6880b | ||
|
|
65dd73b4c3 | ||
|
|
b69aa011fe | ||
|
|
e3a44b10bc | ||
|
|
5b8007784b | ||
|
|
0d6e78b718 | ||
|
|
46ab4cb19e | ||
|
|
32edaad823 | ||
|
|
5dcd48544a | ||
|
|
1e05925e47 | ||
|
|
fb47f1cbeb | ||
|
|
15d1421cf2 | ||
|
|
899bbd40d7 | ||
|
|
555b2578a8 | ||
|
|
0229b8bbd8 | ||
|
|
552f9eff7b | ||
|
|
36e0cffaaf | ||
|
|
e17a9c6abf | ||
|
|
6180603ef4 | ||
|
|
810374d648 | ||
|
|
968b967854 | ||
|
|
110079d99d | ||
|
|
34a126a6d7 | ||
|
|
31462f64d8 | ||
|
|
b46855d8c4 | ||
|
|
feaad8250b |
No files matched your search
@@ -100,7 +100,7 @@
|
|||||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||||
- 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.**
|
- 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`.
|
- macOS logs: use `./scripts/clawlog.sh` 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.
|
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||||
|
|||||||
+25
-65
@@ -2,75 +2,39 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
|
## 2026.1.21
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
||||||
|
|
||||||
|
## 2026.1.20-1
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Install: include pnpm patch files in the npm package to avoid postinstall failures.
|
||||||
|
|
||||||
## 2026.1.20
|
## 2026.1.20
|
||||||
|
|
||||||
### Changes
|
### Highlights
|
||||||
- Deps: update workspace + memory-lancedb dependencies.
|
- Installer: npm packages now ship pnpm patch files again, fixing `curl | bash` installs.
|
||||||
- Dev: use tsgo for dev/watch builds by default; set `CLAWDBOT_TS_COMPILER=tsc` to opt out.
|
|
||||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
|
||||||
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
|
||||||
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
|
||||||
- Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) — thanks @sebslight.
|
|
||||||
- Channels: add the Nostr plugin channel with profile management + onboarding install defaults. (#1323) — thanks @joelklabo.
|
|
||||||
- Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow.
|
|
||||||
- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel.
|
|
||||||
- Discord: fall back to /skill when native command limits are exceeded; expose /skill globally. (#1287) — thanks @thewilloftheshadow.
|
|
||||||
- Docs: refresh bird skill install metadata and usage notes. (#1302) — thanks @odysseus0.
|
|
||||||
- Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) — thanks @sibbl.
|
|
||||||
- Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) — thanks @steipete.
|
|
||||||
- Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) — thanks @suminhthanh.
|
|
||||||
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
|
|
||||||
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill.
|
|
||||||
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes.
|
|
||||||
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
|
|
||||||
- TUI: add input history (up/down) for submitted messages. (#1348) — thanks @vignesh07.
|
|
||||||
### Fixes
|
|
||||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
|
||||||
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
|
||||||
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
|
|
||||||
- Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.
|
|
||||||
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
|
|
||||||
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
|
|
||||||
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
|
|
||||||
- Nodes: enforce node.invoke timeouts for node handlers. (#1357) — thanks @vignesh07.
|
|
||||||
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
|
|
||||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
|
||||||
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
|
||||||
- Gateway: clarify connect/validation errors for gateway params. (#1347) — thanks @vignesh07.
|
|
||||||
- Gateway: preserve restart wake routing + thread replies across restarts. (#1337) — thanks @John-Rood.
|
|
||||||
- Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.
|
|
||||||
- Config: log invalid config issues once per run and keep invalid-config errors stackless.
|
|
||||||
- Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).
|
|
||||||
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
|
|
||||||
- UI: preserve ordered list numbering in chat markdown. (#1341) — thanks @bradleypriest.
|
|
||||||
- UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342) — thanks @ameno-.
|
|
||||||
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
|
|
||||||
- Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346) — thanks @fogboots.
|
|
||||||
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
|
|
||||||
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.
|
|
||||||
- CLI: avoid duplicating --profile/--dev flags when formatting commands.
|
|
||||||
- Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301)
|
|
||||||
- Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander.
|
|
||||||
- Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)
|
|
||||||
- Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297) — thanks @ysqander.
|
|
||||||
- Anthropic: default API prompt caching to 1h with configurable TTL override; ignore TTL for OAuth.
|
|
||||||
- Discord: make resolve warnings avoid raw JSON payloads on rate limits.
|
|
||||||
- Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)
|
|
||||||
- Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)
|
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Agents: add auto-notify-on-completion guidance for coding-agent background runs.
|
||||||
|
- Build: remove the legacy Peekaboo submodule pointer (SPM release already in use).
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- Installer: ship pnpm patch files in the npm tarball so postinstall patches apply correctly.
|
||||||
|
- Agents: suppress duplicate assistant text blocks that only differ in trailing whitespace; add a regression test.
|
||||||
|
- Slack: fix Bolt ESM/CJS import resolution on Node 25.x and remove duplicate thread metadata. (#1360) — thanks @SocialNerd42069.
|
||||||
|
- CLI: fix a duplicate UpdateStepResult import that broke `pnpm build`.
|
||||||
|
- macOS: mark Tailscale IP fallback helpers nonisolated to fix Swift 6.2 build failures.
|
||||||
## 2026.1.19-3
|
## 2026.1.19-3
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
- Android: remove legacy bridge transport code now that nodes use the gateway protocol.
|
||||||
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
|
- Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.
|
||||||
- Gateway: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) — thanks @RyanLisse.
|
|
||||||
- Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) — thanks @steipete.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Gateway: strip inbound envelope headers from chat history messages to keep clients clean.
|
- Slack: respect verbose tool summaries and keep tool notifications threaded. (#1360) — thanks @SocialNerd42069.
|
||||||
- UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283) — thanks @bradleypriest.
|
|
||||||
- Config: allow Perplexity as a web_search provider in config validation. (#1230)
|
|
||||||
- Browser: register AI snapshot refs for act commands. (#1282) — thanks @John-Rood.
|
|
||||||
|
|
||||||
## 2026.1.19-2
|
## 2026.1.19-2
|
||||||
|
|
||||||
@@ -89,11 +53,9 @@ Docs: https://docs.clawd.bot
|
|||||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
|
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Gateway: add `/v1/responses` endpoint (OpenResponses API) for agentic workflows with item-based input and semantic streaming events. Enable via `gateway.http.endpoints.responses.enabled: true`.
|
|
||||||
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
||||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
||||||
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
||||||
@@ -102,11 +64,9 @@ Docs: https://docs.clawd.bot
|
|||||||
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
||||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
||||||
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) — thanks @ameno-.
|
- Daemon: include HOME in service environments to avoid missing HOME errors. (#1214) — thanks @ameno-.
|
||||||
- Memory: show total file counts + scan issues in `clawdbot memory status`; fall back to non-batch embeddings after repeated batch failures.
|
|
||||||
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
||||||
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||||
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
||||||
- Config: allow custom fields under `skills.entries.<name>.config` for skill credentials/config. (#1226) — thanks @VACInc. (fixes #1225)
|
|
||||||
|
|
||||||
## 2026.1.18-5
|
## 2026.1.18-5
|
||||||
|
|
||||||
@@ -132,8 +92,8 @@ Docs: https://docs.clawd.bot
|
|||||||
## 2026.1.18-4
|
## 2026.1.18-4
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.
|
- macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release (no submodule).
|
||||||
- macOS: stop syncing Peekaboo in postinstall.
|
- macOS: stop syncing Peekaboo as a git submodule in postinstall.
|
||||||
- Swabble: use the tagged Commander Swift package release.
|
- Swabble: use the tagged Commander Swift package release.
|
||||||
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
- CLI: add `clawdbot acp client` interactive ACP harness for debugging.
|
||||||
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
- Plugins: route command detection/text chunking helpers through the plugin runtime and drop runtime exports from the SDK.
|
||||||
|
|||||||
+29
-172
@@ -2,6 +2,35 @@
|
|||||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||||
<channel>
|
<channel>
|
||||||
<title>Clawdbot</title>
|
<title>Clawdbot</title>
|
||||||
|
<item>
|
||||||
|
<title>2026.1.20</title>
|
||||||
|
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
|
||||||
|
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||||
|
<sparkle:version>7116</sparkle:version>
|
||||||
|
<sparkle:shortVersionString>2026.1.20</sparkle:shortVersionString>
|
||||||
|
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||||
|
<description><![CDATA[<h2>Clawdbot 2026.1.20</h2>
|
||||||
|
<h3>Highlights</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Installer: npm packages now ship pnpm patch files again, fixing <code>curl | bash</code> installs.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Agents: add auto-notify-on-completion guidance for coding-agent background runs.</li>
|
||||||
|
<li>Build: remove the legacy Peekaboo submodule pointer (SPM release already in use).</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Fixes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Installer: ship pnpm patch files in the npm tarball so postinstall patches apply correctly.</li>
|
||||||
|
<li>Agents: suppress duplicate assistant text blocks that only differ in trailing whitespace; add a regression test.</li>
|
||||||
|
<li>Slack: fix Bolt ESM/CJS import resolution on Node 25.x and remove duplicate thread metadata. (#1360) — thanks @SocialNerd42069.</li>
|
||||||
|
<li>CLI: fix a duplicate UpdateStepResult import that broke <code>pnpm build</code>.</li>
|
||||||
|
<li>macOS: mark Tailscale IP fallback helpers nonisolated to fix Swift 6.2 build failures.</li>
|
||||||
|
</ul>
|
||||||
|
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||||
|
]]></description>
|
||||||
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.20/Clawdbot-2026.1.20.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
||||||
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>2026.1.16-2</title>
|
<title>2026.1.16-2</title>
|
||||||
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
|
||||||
@@ -99,177 +128,5 @@
|
|||||||
]]></description>
|
]]></description>
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
|
||||||
<title>2026.1.14-1</title>
|
|
||||||
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
|
||||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
|
||||||
<sparkle:version>5825</sparkle:version>
|
|
||||||
<sparkle:shortVersionString>2026.1.14-1</sparkle:shortVersionString>
|
|
||||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
|
||||||
<description><![CDATA[<h2>Clawdbot 2026.1.14-1</h2>
|
|
||||||
<h3>Highlights</h3>
|
|
||||||
<ul>
|
|
||||||
<li>Web search: <code>web_search</code>/<code>web_fetch</code> tools (Brave API) + first-time setup in onboarding/configure.</li>
|
|
||||||
<li>Browser control: Chrome extension relay takeover mode + remote browser control via <code>clawdbot browser serve</code>.</li>
|
|
||||||
<li>Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.</li>
|
|
||||||
<li>Security: expanded <code>clawdbot security audit</code> (+ <code>--fix</code>), detect-secrets CI scan, and a <code>SECURITY.md</code> reporting policy.</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Changes</h3>
|
|
||||||
<h4>Web Tools</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Tools: add <code>web_search</code>/<code>web_fetch</code> (Brave API), including helpful setup hints when the key is missing.</li>
|
|
||||||
<li>Tools: enable <code>web_fetch</code> by default (unless explicitly disabled in config).</li>
|
|
||||||
<li>CLI/Docs: add <code>clawdbot configure --section web</code> for storing Brave API keys and update onboarding tips.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Browser / Control UI</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Browser: add Chrome extension relay takeover mode (toolbar button) + <code>clawdbot browser serve</code> remote control + <code>browser.controlToken</code>.</li>
|
|
||||||
<li>Browser: ship a built-in <code>chrome</code> profile for extension relay and start the relay automatically when running locally.</li>
|
|
||||||
<li>Browser: default <code>browser.defaultProfile</code> to <code>chrome</code> (existing Chrome takeover mode).</li>
|
|
||||||
<li>Browser: add <code>clawdbot browser extension install/path</code> and copy extension path to clipboard.</li>
|
|
||||||
<li>Browser: add <code>snapshot refs=aria</code> (Playwright aria-ref ids) for self-resolving refs across <code>snapshot</code> → <code>act</code>.</li>
|
|
||||||
<li>Browser: <code>profile="chrome"</code> now defaults to host control and returns clearer “attach a tab” errors.</li>
|
|
||||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
|
||||||
<li>Control UI: show raw any-map entries in config views; move Docs link into the left nav.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Plugins</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Plugins: add plugin HTTP hooks + loader updates to support channel plugins. (#854) — thanks @longmaba.</li>
|
|
||||||
<li>Plugins: add onboarding plugin install flow. (#854) — thanks @longmaba.</li>
|
|
||||||
<li>Channels: add Matrix plugin (external) with docs + onboarding hooks.</li>
|
|
||||||
<li>Voice Call: add Plivo provider (no SDK dependency). (#846) — thanks @vrknetha.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Security</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Security: expand <code>clawdbot security audit</code> checks and publish a <code>SECURITY.md</code> reporting policy.</li>
|
|
||||||
<li>Security: extend <code>clawdbot security audit --fix</code> to tighten more sensitive state paths.</li>
|
|
||||||
<li>Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Onboarding / Daemon</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require <code>--accept-risk</code> for <code>--non-interactive</code>.</li>
|
|
||||||
<li>Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Auth / Usage / Config</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Usage: add MiniMax coding plan usage tracking.</li>
|
|
||||||
<li>Auth: label Claude Code CLI auth options. (#915) — thanks @SeanZoR.</li>
|
|
||||||
<li>Agents: add optional auth-profile copy prompt on <code>agents add</code> and improve auth error messaging.</li>
|
|
||||||
<li>Auth: add dynamic template variables to <code>messages.responsePrefix</code>. (#928) — thanks @sebslight.</li>
|
|
||||||
<li>Config: add <code>channels.<provider>.configWrites</code> gating for channel-initiated config writes; migrate Slack channel IDs.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Channels</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Telegram: add message delete action in the message tool. (#903) — thanks @sleontenko.</li>
|
|
||||||
<li>WhatsApp: add <code>channels.whatsapp.sendReadReceipts</code> to disable auto read receipts. (#882) — thanks @chrisrodz.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Docs</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.</li>
|
|
||||||
<li>Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.</li>
|
|
||||||
<li>Docs: expand gateway security hardening guidance and incident response checklist.</li>
|
|
||||||
<li>Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.</li>
|
|
||||||
<li>Docs: standardize Claude Code CLI naming across docs and prompts. (follow-up to #915)</li>
|
|
||||||
<li>Docs: add per-command CLI doc pages and link them from <code>clawdbot <command> --help</code>.</li>
|
|
||||||
<li>Docs: add multi-gateway guide (sidebar + nav).</li>
|
|
||||||
</ul>
|
|
||||||
<h3>Fixes</h3>
|
|
||||||
<h4>Gateway / Daemon / Sessions</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Gateway: forward termination signals to respawned CLI child processes to avoid orphaned systemd runs. (#933) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Gateway/UI: ship session defaults in the hello snapshot so the Control UI canonicalizes main session keys (no bare <code>main</code> alias).</li>
|
|
||||||
<li>Agents: skip thinking/final tag stripping inside Markdown code spans. (#939) — thanks @ngutman.</li>
|
|
||||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
|
||||||
<li>Browser: persist role snapshot refs per CDP target so <code>snapshot</code> → <code>act</code> clicks work even if Playwright returns a different Page instance.</li>
|
|
||||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
|
||||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
|
||||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
|
||||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
|
||||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
|
||||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
|
||||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
|
||||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
|
||||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
|
||||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
|
||||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
|
||||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
|
||||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
|
||||||
<li>Daemon: clear persisted launchd disabled state before bootstrap (fixes <code>daemon install</code> after uninstall). (#849) — thanks @ndraiman.</li>
|
|
||||||
<li>Sessions: return deep clones (<code>structuredClone</code>) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani.</li>
|
|
||||||
<li>Heartbeat: keep <code>updatedAt</code> monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani.</li>
|
|
||||||
<li>Agent: clear run context after CLI runs (<code>clearAgentRunContext</code>) to avoid runaway contexts. (#934) — thanks @ronak-guliani.</li>
|
|
||||||
<li>Gateway/Dev: ensure <code>pnpm gateway:dev</code> always uses the dev profile config + state (<code>~/.clawdbot-dev</code>).</li>
|
|
||||||
</ul>
|
|
||||||
<h4>CLI / Onboarding</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Onboarding: show web search setup at the end (not the beginning).</li>
|
|
||||||
<li>Onboarding: show daemon install/restart progress (avoid “blinking cursor”) and fix daemon install output formatting.</li>
|
|
||||||
<li>Health: colorize “not configured” provider lines for easier scanning.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Control UI / TUI</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Control UI: load cron run history on job selection and clarify empty-state messaging. (#866)</li>
|
|
||||||
<li>UI: use application-defined WebSocket close code and fix dashboard auth query items. (#918) — thanks @rahthakor.</li>
|
|
||||||
<li>UI: always apply <code>?token=</code> from URL (fixes unauthorized after re-onboard).</li>
|
|
||||||
<li>Browser: add tests for snapshot labels/efficient query params and labeled image responses.</li>
|
|
||||||
<li>TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank.</li>
|
|
||||||
<li>TUI: add a bright spinner + elapsed time in the status line for send/stream/run states.</li>
|
|
||||||
<li>TUI: show LLM error messages (rate limits, auth, etc.) instead of <code>(no output)</code>.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Agents / Auth / Tools / Sandbox</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Agents: make user time zone and 24-hour time explicit in the system prompt. (#859) — thanks @CashWilliams.</li>
|
|
||||||
<li>Agents: strip downgraded tool call text without eating adjacent replies and filter thinking-tag leaks. (#905) — thanks @erikpr1994.</li>
|
|
||||||
<li>Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.</li>
|
|
||||||
<li>Agents: scrub tuple <code>items</code> schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.</li>
|
|
||||||
<li>Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.</li>
|
|
||||||
<li>Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.</li>
|
|
||||||
<li>Logging: tolerate <code>EIO</code> from console writes to avoid gateway crashes. (#925, fixes #878) — thanks @grp06.</li>
|
|
||||||
<li>Sandbox: restore <code>docker.binds</code> config validation and preserve configured PATH for <code>docker exec</code>. (#873) — thanks @akonyer.</li>
|
|
||||||
<li>Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>macOS / Apps</h4>
|
|
||||||
<ul>
|
|
||||||
<li>macOS: ensure launchd log directory exists with a test-only override. (#909) — thanks @roshanasingh4.</li>
|
|
||||||
<li>macOS: format ConnectionsStore config to satisfy SwiftFormat lint. (#852) — thanks @mneves75.</li>
|
|
||||||
<li>macOS: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor.</li>
|
|
||||||
<li>macOS: reuse launchd gateway auth and skip wizard when gateway config already exists. (#917)</li>
|
|
||||||
<li>Apps: use canonical main session keys from gateway defaults across macOS/iOS/Android to avoid creating bare <code>main</code> sessions.</li>
|
|
||||||
<li>macOS: fix cron preview/testing payload to use <code>channel</code> key. (#867) — thanks @wes-davis.</li>
|
|
||||||
<li>macOS: update cron testing channel arg. (#896) — thanks @ngutman.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Channels / Messaging</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Slack: isolate thread history and avoid inheriting channel transcripts for new threads by default. (#758)</li>
|
|
||||||
<li>Slack: respect <code>channels.slack.requireMention</code> default when resolving channel mention gating. (#850) — thanks @evalexpr.</li>
|
|
||||||
<li>Slack: drop Socket Mode events with mismatched <code>api_app_id</code>/<code>team_id</code>. (#889) — thanks @roshanasingh4.</li>
|
|
||||||
<li>Commands: add native command argument menus across Discord/Slack/Telegram. (#936) — thanks @thewilloftheshadow.</li>
|
|
||||||
<li>Discord: isolate autoThread thread context. (#856) — thanks @davidguttman.</li>
|
|
||||||
<li>Telegram: honor <code>channels.telegram.timeoutSeconds</code> for grammY API requests. (#863) — thanks @Snaver.</li>
|
|
||||||
<li>Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).</li>
|
|
||||||
<li>Telegram: let control commands bypass per-chat sequentialization; always allow abort triggers.</li>
|
|
||||||
<li>Telegram: split long captions into media + follow-up text messages. (#907) — thanks @jalehman.</li>
|
|
||||||
<li>Telegram: migrate group config when supergroups change chat IDs. (#906) — thanks @sleontenko.</li>
|
|
||||||
<li>Telegram: register dock native commands with underscores to avoid <code>BOT_COMMAND_INVALID</code> (#929, fixes #901) — thanks @grp06.</li>
|
|
||||||
<li>Messaging: unify markdown formatting + format-first chunking for Slack/Telegram/Signal. (#920) — thanks @TheSethRose.</li>
|
|
||||||
<li>iMessage: prefer handle routing for direct-message replies; include imsg RPC error details. (#935)</li>
|
|
||||||
<li>WhatsApp: fix context isolation using wrong ID (was bot's number, now conversation ID). (#911) — thanks @tristanmanchester.</li>
|
|
||||||
<li>WhatsApp: normalize user JIDs with device suffix for allowlist checks in groups. (#838) — thanks @peschee.</li>
|
|
||||||
<li>WhatsApp: harden owner command auth.</li>
|
|
||||||
<li>Auto-reply: treat trailing <code>NO_REPLY</code> tokens as silent replies.</li>
|
|
||||||
</ul>
|
|
||||||
<h4>Config / Doctor / Packaging</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).</li>
|
|
||||||
<li>Config/Doctor: remove legacy Clawdis env fallbacks and config/service migrations (Clawdbot-only).</li>
|
|
||||||
<li>Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.</li>
|
|
||||||
<li>Packaging: run <code>pnpm build</code> on <code>prepack</code> so npm publishes include fresh <code>dist/</code> output.</li>
|
|
||||||
</ul>
|
|
||||||
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
|
|
||||||
]]></description>
|
|
||||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
|
|
||||||
</item>
|
|
||||||
</channel>
|
</channel>
|
||||||
</rss>
|
</rss>
|
||||||
@@ -529,7 +529,7 @@ class NodeRuntime(context: Context) {
|
|||||||
caps = buildCapabilities(),
|
caps = buildCapabilities(),
|
||||||
commands = buildInvokeCommands(),
|
commands = buildInvokeCommands(),
|
||||||
permissions = emptyMap(),
|
permissions = emptyMap(),
|
||||||
client = buildClientInfo(clientId = "node-host", clientMode = "node"),
|
client = buildClientInfo(clientId = "clawdbot-android", clientMode = "node"),
|
||||||
userAgent = buildUserAgent(),
|
userAgent = buildUserAgent(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,12 @@ final class TalkModeManager: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startRecognition() throws {
|
private func startRecognition() throws {
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
throw NSError(domain: "TalkMode", code: 2, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Talk mode is not supported on the iOS simulator",
|
||||||
|
])
|
||||||
|
#endif
|
||||||
|
|
||||||
self.stopRecognition()
|
self.stopRecognition()
|
||||||
self.speechRecognizer = SFSpeechRecognizer()
|
self.speechRecognizer = SFSpeechRecognizer()
|
||||||
guard let recognizer = self.speechRecognizer else {
|
guard let recognizer = self.speechRecognizer else {
|
||||||
@@ -146,6 +152,11 @@ final class TalkModeManager: NSObject {
|
|||||||
|
|
||||||
let input = self.audioEngine.inputNode
|
let input = self.audioEngine.inputNode
|
||||||
let format = input.outputFormat(forBus: 0)
|
let format = input.outputFormat(forBus: 0)
|
||||||
|
guard format.sampleRate > 0, format.channelCount > 0 else {
|
||||||
|
throw NSError(domain: "TalkMode", code: 3, userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Invalid audio input format",
|
||||||
|
])
|
||||||
|
}
|
||||||
input.removeTap(onBus: 0)
|
input.removeTap(onBus: 0)
|
||||||
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
|
let tapBlock = Self.makeAudioTapAppendCallback(request: request)
|
||||||
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
|
input.installTap(onBus: 0, bufferSize: 2048, format: format, block: tapBlock)
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ final class TailscaleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isTailnetIPv4(_ address: String) -> Bool {
|
private nonisolated static func isTailnetIPv4(_ address: String) -> Bool {
|
||||||
let parts = address.split(separator: ".")
|
let parts = address.split(separator: ".")
|
||||||
guard parts.count == 4 else { return false }
|
guard parts.count == 4 else { return false }
|
||||||
let octets = parts.compactMap { Int($0) }
|
let octets = parts.compactMap { Int($0) }
|
||||||
@@ -188,7 +188,7 @@ final class TailscaleService {
|
|||||||
return a == 100 && b >= 64 && b <= 127
|
return a == 100 && b >= 64 && b <= 127
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func detectTailnetIPv4() -> String? {
|
private nonisolated static func detectTailnetIPv4() -> String? {
|
||||||
var addrList: UnsafeMutablePointer<ifaddrs>?
|
var addrList: UnsafeMutablePointer<ifaddrs>?
|
||||||
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
guard getifaddrs(&addrList) == 0, let first = addrList else { return nil }
|
||||||
defer { freeifaddrs(addrList) }
|
defer { freeifaddrs(addrList) }
|
||||||
|
|||||||
@@ -11,35 +11,6 @@ private struct NodeInvokeRequestPayload: Codable, Sendable {
|
|||||||
var idempotencyKey: String?
|
var idempotencyKey: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensures the timeout can win even if the invoke task never completes.
|
|
||||||
private actor InvokeTimeoutRace {
|
|
||||||
private var finished = false
|
|
||||||
private let continuation: CheckedContinuation<BridgeInvokeResponse, Never>
|
|
||||||
private var invokeTask: Task<Void, Never>?
|
|
||||||
private var timeoutTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
init(continuation: CheckedContinuation<BridgeInvokeResponse, Never>) {
|
|
||||||
self.continuation = continuation
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerTasks(invoke: Task<Void, Never>, timeout: Task<Void, Never>) {
|
|
||||||
self.invokeTask = invoke
|
|
||||||
self.timeoutTask = timeout
|
|
||||||
if finished {
|
|
||||||
invoke.cancel()
|
|
||||||
timeout.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func finish(_ response: BridgeInvokeResponse) {
|
|
||||||
guard !finished else { return }
|
|
||||||
finished = true
|
|
||||||
continuation.resume(returning: response)
|
|
||||||
invokeTask?.cancel()
|
|
||||||
timeoutTask?.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public actor GatewayNodeSession {
|
public actor GatewayNodeSession {
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway")
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
@@ -63,32 +34,22 @@ public actor GatewayNodeSession {
|
|||||||
return await onInvoke(request)
|
return await onInvoke(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
let cappedTimeout = min(timeout, Int(UInt64.max / 1_000_000))
|
return await withTaskGroup(of: BridgeInvokeResponse.self) { group in
|
||||||
let timeoutResponse = BridgeInvokeResponse(
|
group.addTask { await onInvoke(request) }
|
||||||
id: request.id,
|
group.addTask {
|
||||||
ok: false,
|
try? await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000)
|
||||||
error: ClawdbotNodeError(
|
return BridgeInvokeResponse(
|
||||||
code: .unavailable,
|
id: request.id,
|
||||||
message: "node invoke timed out")
|
ok: false,
|
||||||
)
|
error: ClawdbotNodeError(
|
||||||
|
code: .unavailable,
|
||||||
|
message: "node invoke timed out")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return await withCheckedContinuation { continuation in
|
let first = await group.next()!
|
||||||
let race = InvokeTimeoutRace(continuation: continuation)
|
group.cancelAll()
|
||||||
let invokeTask = Task {
|
return first
|
||||||
let response = await onInvoke(request)
|
|
||||||
await race.finish(response)
|
|
||||||
}
|
|
||||||
let timeoutTask = Task {
|
|
||||||
do {
|
|
||||||
try await Task.sleep(nanoseconds: UInt64(cappedTimeout) * 1_000_000)
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await race.finish(timeoutResponse)
|
|
||||||
}
|
|
||||||
Task {
|
|
||||||
await race.registerTasks(invoke: invokeTask, timeout: timeoutTask)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
|
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
|
||||||
|
|||||||
@@ -38,28 +38,6 @@ struct GatewayNodeSessionTests {
|
|||||||
#expect(response.error?.message.contains("timed out") == true)
|
#expect(response.error?.message.contains("timed out") == true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
func invokeWithTimeoutReturnsWhenHandlerNeverCompletes() async {
|
|
||||||
let request = BridgeInvokeRequest(id: "stall", command: "x", paramsJSON: nil)
|
|
||||||
let response = try? await AsyncTimeout.withTimeoutMs(
|
|
||||||
timeoutMs: 200,
|
|
||||||
onTimeout: { NSError(domain: "GatewayNodeSessionTests", code: 1) },
|
|
||||||
operation: {
|
|
||||||
await GatewayNodeSession.invokeWithTimeout(
|
|
||||||
request: request,
|
|
||||||
timeoutMs: 10,
|
|
||||||
onInvoke: { _ in
|
|
||||||
await withCheckedContinuation { _ in }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
#expect(response != nil)
|
|
||||||
#expect(response?.ok == false)
|
|
||||||
#expect(response?.error?.code == .unavailable)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func invokeWithTimeoutZeroDisablesTimeout() async {
|
func invokeWithTimeoutZeroDisablesTimeout() async {
|
||||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
||||||
|
|||||||
+12
-3
@@ -7,9 +7,9 @@ read_when:
|
|||||||
|
|
||||||
# `clawdbot update`
|
# `clawdbot update`
|
||||||
|
|
||||||
Safely update a **source checkout** (git install) of Clawdbot.
|
Safely update Clawdbot and switch between stable/beta/dev channels.
|
||||||
|
|
||||||
If you installed via **npm/pnpm** (global install, no git metadata), use the package manager flow in [Updating](/install/updating).
|
If you installed via **npm/pnpm** (global install, no git metadata), updates happen via the package manager flow in [Updating](/install/updating).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -48,7 +48,16 @@ Options:
|
|||||||
- `--json`: print machine-readable status JSON.
|
- `--json`: print machine-readable status JSON.
|
||||||
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
- `--timeout <seconds>`: timeout for checks (default is 3s).
|
||||||
|
|
||||||
## What it does (git checkout)
|
## What it does
|
||||||
|
|
||||||
|
When you switch channels explicitly (`--channel ...`), Clawdbot also keeps the
|
||||||
|
install method aligned:
|
||||||
|
|
||||||
|
- `dev` → ensures a git checkout (default: `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
|
||||||
|
updates it, and installs the global CLI from that checkout.
|
||||||
|
- `stable`/`beta` → installs from npm using the matching dist-tag.
|
||||||
|
|
||||||
|
## Git checkout flow
|
||||||
|
|
||||||
Channels:
|
Channels:
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ read_when:
|
|||||||
|
|
||||||
# Development channels
|
# Development channels
|
||||||
|
|
||||||
Last updated: 2026-01-20
|
Last updated: 2026-01-21
|
||||||
|
|
||||||
Clawdbot ships three update channels:
|
Clawdbot ships three update channels:
|
||||||
|
|
||||||
@@ -38,6 +38,13 @@ clawdbot update --channel dev
|
|||||||
|
|
||||||
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
|
This updates via the corresponding npm dist-tag (`latest`, `beta`, `dev`).
|
||||||
|
|
||||||
|
When you **explicitly** switch channels with `--channel`, Clawdbot also aligns
|
||||||
|
the install method:
|
||||||
|
|
||||||
|
- `dev` ensures a git checkout (default `~/clawdbot`, override with `CLAWDBOT_GIT_DIR`),
|
||||||
|
updates it, and installs the global CLI from that checkout.
|
||||||
|
- `stable`/`beta` installs from npm using the matching dist-tag.
|
||||||
|
|
||||||
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
|
Tip: if you want stable + dev in parallel, keep two clones and point your gateway at the stable one.
|
||||||
|
|
||||||
## Plugins and channels
|
## Plugins and channels
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
|||||||
2) **Build & artifacts**
|
2) **Build & artifacts**
|
||||||
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
|
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
|
||||||
- [ ] `pnpm run build` (regenerates `dist/`).
|
- [ ] `pnpm run build` (regenerates `dist/`).
|
||||||
|
- [ ] Verify npm package `files` includes all required `dist/*` folders (notably `dist/node-host/**` and `dist/acp/**` for headless node + ACP CLI).
|
||||||
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
|
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
|
||||||
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
|
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ Text + native (when enabled):
|
|||||||
- `/commands`
|
- `/commands`
|
||||||
- `/skill <name> [input]` (run a skill by name)
|
- `/skill <name> [input]` (run a skill by name)
|
||||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
||||||
|
- `/allowlist` (list/add/remove allowlist entries)
|
||||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||||
- `/whoami` (show your sender id; alias: `/id`)
|
- `/whoami` (show your sender id; alias: `/id`)
|
||||||
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
||||||
@@ -93,6 +94,7 @@ Notes:
|
|||||||
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
|
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
|
||||||
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
|
- `/new <model>` accepts a model alias, `provider/model`, or a provider name (fuzzy match); if no match, the text is treated as the message body.
|
||||||
- For full provider usage breakdown, use `clawdbot status --usage`.
|
- For full provider usage breakdown, use `clawdbot status --usage`.
|
||||||
|
- `/allowlist add|remove` requires `commands.config=true` and honors channel `configWrites`.
|
||||||
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
|
- `/usage` controls the per-response usage footer; `/usage cost` prints a local cost summary from Clawdbot session logs.
|
||||||
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
|
||||||
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/bluebubbles",
|
"name": "@clawdbot/bluebubbles",
|
||||||
"version": "2026.1.15",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot BlueBubbles channel plugin",
|
"description": "Clawdbot BlueBubbles channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"],
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
],
|
||||||
"channel": {
|
"channel": {
|
||||||
"id": "bluebubbles",
|
"id": "bluebubbles",
|
||||||
"label": "BlueBubbles",
|
"label": "BlueBubbles",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/copilot-proxy",
|
"name": "@clawdbot/copilot-proxy",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Copilot Proxy provider plugin",
|
"description": "Clawdbot Copilot Proxy provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/diagnostics-otel",
|
"name": "@clawdbot/diagnostics-otel",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
"description": "Clawdbot diagnostics OpenTelemetry exporter",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/discord",
|
"name": "@clawdbot/discord",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Discord channel plugin",
|
"description": "Clawdbot Discord channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/google-antigravity-auth",
|
"name": "@clawdbot/google-antigravity-auth",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
"description": "Clawdbot Google Antigravity OAuth provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/google-gemini-cli-auth",
|
"name": "@clawdbot/google-gemini-cli-auth",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
"description": "Clawdbot Gemini CLI OAuth provider plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/imessage",
|
"name": "@clawdbot/imessage",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot iMessage channel plugin",
|
"description": "Clawdbot iMessage channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.20
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Version alignment with core Clawdbot release numbers.
|
||||||
|
|
||||||
## 2026.1.17-1
|
## 2026.1.17-1
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/matrix",
|
"name": "@clawdbot/matrix",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Matrix channel plugin",
|
"description": "Clawdbot Matrix channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/memory-core",
|
"name": "@clawdbot/memory-core",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot core memory search plugin",
|
"description": "Clawdbot core memory search plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/memory-lancedb",
|
"name": "@clawdbot/memory-lancedb",
|
||||||
"version": "0.0.1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.20
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Version alignment with core Clawdbot release numbers.
|
||||||
|
|
||||||
## 2026.1.17-1
|
## 2026.1.17-1
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/msteams",
|
"name": "@clawdbot/msteams",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Microsoft Teams channel plugin",
|
"description": "Clawdbot Microsoft Teams channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
"docsPath": "/channels/msteams",
|
"docsPath": "/channels/msteams",
|
||||||
"docsLabel": "msteams",
|
"docsLabel": "msteams",
|
||||||
"blurb": "Bot Framework; enterprise support.",
|
"blurb": "Bot Framework; enterprise support.",
|
||||||
"aliases": ["teams"],
|
"aliases": [
|
||||||
|
"teams"
|
||||||
|
],
|
||||||
"order": 60
|
"order": 60
|
||||||
},
|
},
|
||||||
"install": {
|
"install": {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/nextcloud-talk",
|
"name": "@clawdbot/nextcloud-talk",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Nextcloud Talk channel plugin",
|
"description": "Clawdbot Nextcloud Talk channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"],
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
],
|
||||||
"channel": {
|
"channel": {
|
||||||
"id": "nextcloud-talk",
|
"id": "nextcloud-talk",
|
||||||
"label": "Nextcloud Talk",
|
"label": "Nextcloud Talk",
|
||||||
@@ -12,7 +14,10 @@
|
|||||||
"docsPath": "/channels/nextcloud-talk",
|
"docsPath": "/channels/nextcloud-talk",
|
||||||
"docsLabel": "nextcloud-talk",
|
"docsLabel": "nextcloud-talk",
|
||||||
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
|
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
|
||||||
"aliases": ["nc-talk", "nc"],
|
"aliases": [
|
||||||
|
"nc-talk",
|
||||||
|
"nc"
|
||||||
|
],
|
||||||
"order": 65,
|
"order": 65,
|
||||||
"quickstartAllowFrom": true
|
"quickstartAllowFrom": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.20
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Version alignment with core Clawdbot release numbers.
|
||||||
|
|
||||||
## 2026.1.19-1
|
## 2026.1.19-1
|
||||||
|
|
||||||
Initial release.
|
Initial release.
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/nostr",
|
"name": "@clawdbot/nostr",
|
||||||
"version": "2026.1.19-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
|
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"],
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
],
|
||||||
"channel": {
|
"channel": {
|
||||||
"id": "nostr",
|
"id": "nostr",
|
||||||
"label": "Nostr",
|
"label": "Nostr",
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/signal",
|
"name": "@clawdbot/signal",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Signal channel plugin",
|
"description": "Clawdbot Signal channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/slack",
|
"name": "@clawdbot/slack",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Slack channel plugin",
|
"description": "Clawdbot Slack channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/telegram",
|
"name": "@clawdbot/telegram",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Telegram channel plugin",
|
"description": "Clawdbot Telegram channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.20
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Version alignment with core Clawdbot release numbers.
|
||||||
|
|
||||||
## 2026.1.17-1
|
## 2026.1.17-1
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/voice-call",
|
"name": "@clawdbot/voice-call",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot voice-call plugin",
|
"description": "Clawdbot voice-call plugin",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/whatsapp",
|
"name": "@clawdbot/whatsapp",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot WhatsApp channel plugin",
|
"description": "Clawdbot WhatsApp channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
"extensions": ["./index.ts"]
|
"extensions": [
|
||||||
|
"./index.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.20
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Version alignment with core Clawdbot release numbers.
|
||||||
|
|
||||||
## 2026.1.17-1
|
## 2026.1.17-1
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/zalo",
|
"name": "@clawdbot/zalo",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Zalo channel plugin",
|
"description": "Clawdbot Zalo channel plugin",
|
||||||
"clawdbot": {
|
"clawdbot": {
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
"docsPath": "/channels/zalo",
|
"docsPath": "/channels/zalo",
|
||||||
"docsLabel": "zalo",
|
"docsLabel": "zalo",
|
||||||
"blurb": "Vietnam-focused messaging platform with Bot API.",
|
"blurb": "Vietnam-focused messaging platform with Bot API.",
|
||||||
"aliases": ["zl"],
|
"aliases": [
|
||||||
|
"zl"
|
||||||
|
],
|
||||||
"order": 80,
|
"order": 80,
|
||||||
"quickstartAllowFrom": true
|
"quickstartAllowFrom": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026.1.20
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
- Version alignment with core Clawdbot release numbers.
|
||||||
|
|
||||||
## 2026.1.17-1
|
## 2026.1.17-1
|
||||||
|
|
||||||
- Initial version with full channel plugin support
|
- Initial version with full channel plugin support
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@clawdbot/zalouser",
|
"name": "@clawdbot/zalouser",
|
||||||
"version": "2026.1.17-1",
|
"version": "2026.1.20",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawdbot",
|
"name": "clawdbot",
|
||||||
"version": "2026.1.20",
|
"version": "2026.1.20-1",
|
||||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
"extensions/**",
|
"extensions/**",
|
||||||
"assets/**",
|
"assets/**",
|
||||||
"skills/**",
|
"skills/**",
|
||||||
|
"patches/**",
|
||||||
"README.md",
|
"README.md",
|
||||||
"README-header.png",
|
"README-header.png",
|
||||||
"CHANGELOG.md",
|
"CHANGELOG.md",
|
||||||
|
|||||||
+15
-15
@@ -21,7 +21,7 @@ handle_sudo_error() {
|
|||||||
echo -e "\n${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
echo -e "\n${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||||
echo -e "${YELLOW}⚠️ Password Required for Log Access${NC}"
|
echo -e "${YELLOW}⚠️ Password Required for Log Access${NC}"
|
||||||
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"
|
||||||
echo -e "vtlog needs to use sudo to show complete log data (Apple hides sensitive info by default)."
|
echo -e "clawlog needs to use sudo to show complete log data (Apple hides sensitive info by default)."
|
||||||
echo -e "\nTo avoid password prompts, configure passwordless sudo for the log command:"
|
echo -e "\nTo avoid password prompts, configure passwordless sudo for the log command:"
|
||||||
echo -e "See: ${BLUE}apple/docs/logging-private-fix.md${NC}\n"
|
echo -e "See: ${BLUE}apple/docs/logging-private-fix.md${NC}\n"
|
||||||
echo -e "Quick fix:"
|
echo -e "Quick fix:"
|
||||||
@@ -51,7 +51,7 @@ show_usage() {
|
|||||||
clawlog - Clawdbot Logging Utility
|
clawlog - Clawdbot Logging Utility
|
||||||
|
|
||||||
USAGE:
|
USAGE:
|
||||||
vtlog [OPTIONS]
|
clawlog [OPTIONS]
|
||||||
|
|
||||||
DESCRIPTION:
|
DESCRIPTION:
|
||||||
View Clawdbot logs with full details (bypasses Apple's privacy redaction).
|
View Clawdbot logs with full details (bypasses Apple's privacy redaction).
|
||||||
@@ -69,10 +69,10 @@ LOG CATEGORIES (examples):
|
|||||||
• shell - ShellExecutor
|
• shell - ShellExecutor
|
||||||
|
|
||||||
QUICK START:
|
QUICK START:
|
||||||
vtlog -n 100 Show last 100 lines from all components
|
clawlog -n 100 Show last 100 lines from all components
|
||||||
vtlog -f Follow logs in real-time
|
clawlog -f Follow logs in real-time
|
||||||
vtlog -e Show only errors
|
clawlog -e Show only errors
|
||||||
vtlog -c ServerManager Show logs from ServerManager only
|
clawlog -c ServerManager Show logs from ServerManager only
|
||||||
|
|
||||||
OPTIONS:
|
OPTIONS:
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
@@ -91,15 +91,15 @@ OPTIONS:
|
|||||||
--json Output in JSON format
|
--json Output in JSON format
|
||||||
|
|
||||||
EXAMPLES:
|
EXAMPLES:
|
||||||
vtlog Show last 50 lines from past 5 minutes (default)
|
clawlog Show last 50 lines from past 5 minutes (default)
|
||||||
vtlog -f Stream logs continuously
|
clawlog -f Stream logs continuously
|
||||||
vtlog -n 100 Show last 100 lines
|
clawlog -n 100 Show last 100 lines
|
||||||
vtlog -e Show only recent errors
|
clawlog -e Show only recent errors
|
||||||
vtlog -l 30m -n 200 Show last 200 lines from past 30 minutes
|
clawlog -l 30m -n 200 Show last 200 lines from past 30 minutes
|
||||||
vtlog -c ServerManager Show recent ServerManager logs
|
clawlog -c ServerManager Show recent ServerManager logs
|
||||||
vtlog -s "fail" Search for "fail" in recent logs
|
clawlog -s "fail" Search for "fail" in recent logs
|
||||||
vtlog --server -e Show recent server errors
|
clawlog --server -e Show recent server errors
|
||||||
vtlog -f -d Stream debug logs continuously
|
clawlog -f -d Stream debug logs continuously
|
||||||
|
|
||||||
CATEGORIES:
|
CATEGORIES:
|
||||||
Common categories include:
|
Common categories include:
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ bash pty:true workdir:~/project background:true command:"codex exec --full-auto
|
|||||||
# Monitor progress
|
# Monitor progress
|
||||||
process action:log sessionId:XXX
|
process action:log sessionId:XXX
|
||||||
|
|
||||||
# Check if done
|
# Check if done
|
||||||
process action:poll sessionId:XXX
|
process action:poll sessionId:XXX
|
||||||
|
|
||||||
# Send input (if agent asks a question)
|
# Send input (if agent asks a question)
|
||||||
@@ -217,15 +217,55 @@ git worktree remove /tmp/issue-99
|
|||||||
|
|
||||||
## ⚠️ Rules
|
## ⚠️ Rules
|
||||||
|
|
||||||
1. **Always use pty:true** — coding agents need a terminal!
|
1. **Always use pty:true** - coding agents need a terminal!
|
||||||
2. **Respect tool choice** — if user asks for Codex, use Codex. NEVER offer to build it yourself!
|
2. **Respect tool choice** - if user asks for Codex, use Codex.
|
||||||
3. **Be patient** — don't kill sessions because they're "slow"
|
- Orchestrator mode: do NOT hand-code patches yourself.
|
||||||
4. **Monitor with process:log** — check progress without interfering
|
- If an agent fails/hangs, respawn it or ask the user for direction, but don't silently take over.
|
||||||
5. **--full-auto for building** — auto-approves changes
|
3. **Be patient** - don't kill sessions because they're "slow"
|
||||||
6. **vanilla for reviewing** — no special flags needed
|
4. **Monitor with process:log** - check progress without interfering
|
||||||
7. **Parallel is OK** — run many Codex processes at once for batch work
|
5. **--full-auto for building** - auto-approves changes
|
||||||
8. **NEVER start Codex in ~/clawd/** — it'll read your soul docs and get weird ideas about the org chart!
|
6. **vanilla for reviewing** - no special flags needed
|
||||||
9. **NEVER checkout branches in ~/Projects/clawdbot/** — that's the LIVE Clawdbot instance!
|
7. **Parallel is OK** - run many Codex processes at once for batch work
|
||||||
|
8. **NEVER start Codex in ~/clawd/** - it'll read your soul docs and get weird ideas about the org chart!
|
||||||
|
9. **NEVER checkout branches in ~/Projects/clawdbot/** - that's the LIVE Clawdbot instance!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress Updates (Critical)
|
||||||
|
|
||||||
|
When you spawn coding agents in the background, keep the user in the loop.
|
||||||
|
|
||||||
|
- Send 1 short message when you start (what's running + where).
|
||||||
|
- Then only update again when something changes:
|
||||||
|
- a milestone completes (build finished, tests passed)
|
||||||
|
- the agent asks a question / needs input
|
||||||
|
- you hit an error or need user action
|
||||||
|
- the agent finishes (include what changed + where)
|
||||||
|
- If you kill a session, immediately say you killed it and why.
|
||||||
|
|
||||||
|
This prevents the user from seeing only "Agent failed before reply" and having no idea what happened.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto-Notify on Completion
|
||||||
|
|
||||||
|
For long-running background tasks, append a wake trigger to your prompt so Clawdbot gets notified immediately when the agent finishes (instead of waiting for the next heartbeat):
|
||||||
|
|
||||||
|
```
|
||||||
|
... your task here.
|
||||||
|
|
||||||
|
When completely finished, run this command to notify me:
|
||||||
|
clawdbot gateway wake --text "Done: [brief summary of what was built]" --mode now
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
bash pty:true workdir:~/project background:true command:"codex --yolo exec 'Build a REST API for todos.
|
||||||
|
|
||||||
|
When completely finished, run: clawdbot gateway wake --text \"Done: Built todos REST API with CRUD endpoints\" --mode now'"
|
||||||
|
```
|
||||||
|
|
||||||
|
This triggers an immediate wake event — Skippy gets pinged in seconds, not 10 minutes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { SessionEntry } from "../../config/sessions.js";
|
||||||
|
import { resolveSessionAuthProfileOverride } from "./session-override.js";
|
||||||
|
|
||||||
|
async function writeAuthStore(agentDir: string) {
|
||||||
|
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||||
|
const payload = {
|
||||||
|
version: 1,
|
||||||
|
profiles: {
|
||||||
|
"zai:work": { type: "api_key", provider: "zai", key: "sk-test" },
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
zai: ["zai:work"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await fs.writeFile(authPath, JSON.stringify(payload), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("resolveSessionAuthProfileOverride", () => {
|
||||||
|
it("keeps user override when provider alias differs", async () => {
|
||||||
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
|
||||||
|
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||||
|
process.env.CLAWDBOT_STATE_DIR = tmpDir;
|
||||||
|
try {
|
||||||
|
const agentDir = path.join(tmpDir, "agent");
|
||||||
|
await fs.mkdir(agentDir, { recursive: true });
|
||||||
|
await writeAuthStore(agentDir);
|
||||||
|
|
||||||
|
const sessionEntry: SessionEntry = {
|
||||||
|
sessionId: "s1",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
authProfileOverride: "zai:work",
|
||||||
|
authProfileOverrideSource: "user",
|
||||||
|
};
|
||||||
|
const sessionStore = { "agent:main:main": sessionEntry };
|
||||||
|
|
||||||
|
const resolved = await resolveSessionAuthProfileOverride({
|
||||||
|
cfg: {} as ClawdbotConfig,
|
||||||
|
provider: "z.ai",
|
||||||
|
agentDir,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
storePath: undefined,
|
||||||
|
isNewSession: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved).toBe("zai:work");
|
||||||
|
expect(sessionEntry.authProfileOverride).toBe("zai:work");
|
||||||
|
} finally {
|
||||||
|
if (prevStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
||||||
|
else process.env.CLAWDBOT_STATE_DIR = prevStateDir;
|
||||||
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";
|
||||||
|
import { normalizeProviderId } from "../model-selection.js";
|
||||||
|
import {
|
||||||
|
ensureAuthProfileStore,
|
||||||
|
isProfileInCooldown,
|
||||||
|
resolveAuthProfileOrder,
|
||||||
|
} from "../auth-profiles.js";
|
||||||
|
|
||||||
|
function isProfileForProvider(params: {
|
||||||
|
provider: string;
|
||||||
|
profileId: string;
|
||||||
|
store: ReturnType<typeof ensureAuthProfileStore>;
|
||||||
|
}): boolean {
|
||||||
|
const entry = params.store.profiles[params.profileId];
|
||||||
|
if (!entry?.provider) return false;
|
||||||
|
return normalizeProviderId(entry.provider) === normalizeProviderId(params.provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSessionAuthProfileOverride(params: {
|
||||||
|
sessionEntry: SessionEntry;
|
||||||
|
sessionStore: Record<string, SessionEntry>;
|
||||||
|
sessionKey: string;
|
||||||
|
storePath?: string;
|
||||||
|
}) {
|
||||||
|
const { sessionEntry, sessionStore, sessionKey, storePath } = params;
|
||||||
|
delete sessionEntry.authProfileOverride;
|
||||||
|
delete sessionEntry.authProfileOverrideSource;
|
||||||
|
delete sessionEntry.authProfileOverrideCompactionCount;
|
||||||
|
sessionEntry.updatedAt = Date.now();
|
||||||
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
|
if (storePath) {
|
||||||
|
await updateSessionStore(storePath, (store) => {
|
||||||
|
store[sessionKey] = sessionEntry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSessionAuthProfileOverride(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
provider: string;
|
||||||
|
agentDir: string;
|
||||||
|
sessionEntry?: SessionEntry;
|
||||||
|
sessionStore?: Record<string, SessionEntry>;
|
||||||
|
sessionKey?: string;
|
||||||
|
storePath?: string;
|
||||||
|
isNewSession: boolean;
|
||||||
|
}): Promise<string | undefined> {
|
||||||
|
const {
|
||||||
|
cfg,
|
||||||
|
provider,
|
||||||
|
agentDir,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
storePath,
|
||||||
|
isNewSession,
|
||||||
|
} = params;
|
||||||
|
if (!sessionEntry || !sessionStore || !sessionKey) return sessionEntry?.authProfileOverride;
|
||||||
|
|
||||||
|
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||||
|
const order = resolveAuthProfileOrder({ cfg, store, provider });
|
||||||
|
let current = sessionEntry.authProfileOverride?.trim();
|
||||||
|
|
||||||
|
if (current && !store.profiles[current]) {
|
||||||
|
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
|
||||||
|
current = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current && !isProfileForProvider({ provider, profileId: current, store })) {
|
||||||
|
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
|
||||||
|
current = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current && order.length > 0 && !order.includes(current)) {
|
||||||
|
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
|
||||||
|
current = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.length === 0) return undefined;
|
||||||
|
|
||||||
|
const pickFirstAvailable = () =>
|
||||||
|
order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
|
||||||
|
const pickNextAvailable = (active: string) => {
|
||||||
|
const startIndex = order.indexOf(active);
|
||||||
|
if (startIndex < 0) return pickFirstAvailable();
|
||||||
|
for (let offset = 1; offset <= order.length; offset += 1) {
|
||||||
|
const candidate = order[(startIndex + offset) % order.length];
|
||||||
|
if (!isProfileInCooldown(store, candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return order[startIndex] ?? order[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const compactionCount = sessionEntry.compactionCount ?? 0;
|
||||||
|
const storedCompaction =
|
||||||
|
typeof sessionEntry.authProfileOverrideCompactionCount === "number"
|
||||||
|
? sessionEntry.authProfileOverrideCompactionCount
|
||||||
|
: compactionCount;
|
||||||
|
|
||||||
|
const source =
|
||||||
|
sessionEntry.authProfileOverrideSource ??
|
||||||
|
(typeof sessionEntry.authProfileOverrideCompactionCount === "number"
|
||||||
|
? "auto"
|
||||||
|
: current
|
||||||
|
? "user"
|
||||||
|
: undefined);
|
||||||
|
if (source === "user" && current && !isNewSession) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = current;
|
||||||
|
if (isNewSession) {
|
||||||
|
next = current ? pickNextAvailable(current) : pickFirstAvailable();
|
||||||
|
} else if (current && compactionCount > storedCompaction) {
|
||||||
|
next = pickNextAvailable(current);
|
||||||
|
} else if (!current || isProfileInCooldown(store, current)) {
|
||||||
|
next = pickFirstAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!next) return current;
|
||||||
|
const shouldPersist =
|
||||||
|
next !== sessionEntry.authProfileOverride ||
|
||||||
|
sessionEntry.authProfileOverrideSource !== "auto" ||
|
||||||
|
sessionEntry.authProfileOverrideCompactionCount !== compactionCount;
|
||||||
|
if (shouldPersist) {
|
||||||
|
sessionEntry.authProfileOverride = next;
|
||||||
|
sessionEntry.authProfileOverrideSource = "auto";
|
||||||
|
sessionEntry.authProfileOverrideCompactionCount = compactionCount;
|
||||||
|
sessionEntry.updatedAt = Date.now();
|
||||||
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
|
if (storePath) {
|
||||||
|
await updateSessionStore(storePath, (store) => {
|
||||||
|
store[sessionKey] = sessionEntry;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -92,13 +92,16 @@ const makeConfig = (): ClawdbotConfig =>
|
|||||||
},
|
},
|
||||||
}) satisfies ClawdbotConfig;
|
}) satisfies ClawdbotConfig;
|
||||||
|
|
||||||
const writeAuthStore = async (agentDir: string) => {
|
const writeAuthStore = async (agentDir: string, opts?: { includeAnthropic?: boolean }) => {
|
||||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||||
const payload = {
|
const payload = {
|
||||||
version: 1,
|
version: 1,
|
||||||
profiles: {
|
profiles: {
|
||||||
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
|
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
|
||||||
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
|
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
|
||||||
|
...(opts?.includeAnthropic
|
||||||
|
? { "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-anth" } }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
usageStats: {
|
usageStats: {
|
||||||
"openai:p1": { lastUsed: 1 },
|
"openai:p1": { lastUsed: 1 },
|
||||||
@@ -206,4 +209,43 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
|||||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores user-locked profile when provider mismatches", async () => {
|
||||||
|
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||||
|
try {
|
||||||
|
await writeAuthStore(agentDir, { includeAnthropic: true });
|
||||||
|
|
||||||
|
runEmbeddedAttemptMock.mockResolvedValueOnce(
|
||||||
|
makeAttempt({
|
||||||
|
assistantTexts: ["ok"],
|
||||||
|
lastAssistant: buildAssistant({
|
||||||
|
stopReason: "stop",
|
||||||
|
content: [{ type: "text", text: "ok" }],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await runEmbeddedPiAgent({
|
||||||
|
sessionId: "session:test",
|
||||||
|
sessionKey: "agent:test:mismatch",
|
||||||
|
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||||
|
workspaceDir,
|
||||||
|
agentDir,
|
||||||
|
config: makeConfig(),
|
||||||
|
prompt: "hello",
|
||||||
|
provider: "openai",
|
||||||
|
model: "mock-1",
|
||||||
|
authProfileId: "anthropic:default",
|
||||||
|
authProfileIdSource: "user",
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
runId: "run:mismatch",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(agentDir, { recursive: true, force: true });
|
||||||
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
resolveAuthProfileOrder,
|
resolveAuthProfileOrder,
|
||||||
type ResolvedProviderAuth,
|
type ResolvedProviderAuth,
|
||||||
} from "../model-auth.js";
|
} from "../model-auth.js";
|
||||||
|
import { normalizeProviderId } from "../model-selection.js";
|
||||||
import { ensureClawdbotModelsJson } from "../models-config.js";
|
import { ensureClawdbotModelsJson } from "../models-config.js";
|
||||||
import {
|
import {
|
||||||
classifyFailoverReason,
|
classifyFailoverReason,
|
||||||
@@ -50,6 +51,18 @@ import { describeUnknownError } from "./utils.js";
|
|||||||
|
|
||||||
type ApiKeyInfo = ResolvedProviderAuth;
|
type ApiKeyInfo = ResolvedProviderAuth;
|
||||||
|
|
||||||
|
// Avoid Anthropic's refusal test token poisoning session transcripts.
|
||||||
|
const ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL = "ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL";
|
||||||
|
const ANTHROPIC_MAGIC_STRING_REPLACEMENT = "ANTHROPIC MAGIC STRING TRIGGER REFUSAL (redacted)";
|
||||||
|
|
||||||
|
function scrubAnthropicRefusalMagic(prompt: string): string {
|
||||||
|
if (!prompt.includes(ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL)) return prompt;
|
||||||
|
return prompt.replaceAll(
|
||||||
|
ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL,
|
||||||
|
ANTHROPIC_MAGIC_STRING_REPLACEMENT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function runEmbeddedPiAgent(
|
export async function runEmbeddedPiAgent(
|
||||||
params: RunEmbeddedPiAgentParams,
|
params: RunEmbeddedPiAgentParams,
|
||||||
): Promise<EmbeddedPiRunResult> {
|
): Promise<EmbeddedPiRunResult> {
|
||||||
@@ -116,8 +129,16 @@ export async function runEmbeddedPiAgent(
|
|||||||
|
|
||||||
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||||
const preferredProfileId = params.authProfileId?.trim();
|
const preferredProfileId = params.authProfileId?.trim();
|
||||||
const lockedProfileId =
|
let lockedProfileId = params.authProfileIdSource === "user" ? preferredProfileId : undefined;
|
||||||
params.authProfileIdSource === "user" ? preferredProfileId : undefined;
|
if (lockedProfileId) {
|
||||||
|
const lockedProfile = authStore.profiles[lockedProfileId];
|
||||||
|
if (
|
||||||
|
!lockedProfile ||
|
||||||
|
normalizeProviderId(lockedProfile.provider) !== normalizeProviderId(provider)
|
||||||
|
) {
|
||||||
|
lockedProfileId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
const profileOrder = resolveAuthProfileOrder({
|
const profileOrder = resolveAuthProfileOrder({
|
||||||
cfg: params.config,
|
cfg: params.config,
|
||||||
store: authStore,
|
store: authStore,
|
||||||
@@ -202,6 +223,9 @@ export async function runEmbeddedPiAgent(
|
|||||||
attemptedThinking.add(thinkLevel);
|
attemptedThinking.add(thinkLevel);
|
||||||
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
||||||
|
|
||||||
|
const prompt =
|
||||||
|
provider === "anthropic" ? scrubAnthropicRefusalMagic(params.prompt) : params.prompt;
|
||||||
|
|
||||||
const attempt = await runEmbeddedAttempt({
|
const attempt = await runEmbeddedAttempt({
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
@@ -219,7 +243,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
agentDir,
|
agentDir,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
skillsSnapshot: params.skillsSnapshot,
|
skillsSnapshot: params.skillsSnapshot,
|
||||||
prompt: params.prompt,
|
prompt,
|
||||||
images: params.images,
|
images: params.images,
|
||||||
provider,
|
provider,
|
||||||
modelId,
|
modelId,
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ export type EmbeddedPiSubscribeState = {
|
|||||||
lastStreamedAssistant?: string;
|
lastStreamedAssistant?: string;
|
||||||
lastStreamedReasoning?: string;
|
lastStreamedReasoning?: string;
|
||||||
lastBlockReplyText?: string;
|
lastBlockReplyText?: string;
|
||||||
|
assistantMessageIndex: number;
|
||||||
|
lastAssistantTextMessageIndex: number;
|
||||||
|
lastAssistantTextNormalized?: string;
|
||||||
|
lastAssistantTextTrimmed?: string;
|
||||||
assistantTextBaseline: number;
|
assistantTextBaseline: number;
|
||||||
suppressBlockChunks: boolean;
|
suppressBlockChunks: boolean;
|
||||||
lastReasoningSent?: string;
|
lastReasoningSent?: string;
|
||||||
|
|||||||
+29
@@ -86,6 +86,35 @@ describe("subscribeEmbeddedPiSession", () => {
|
|||||||
|
|
||||||
expect(subscription.assistantTexts).toEqual(["Hello world"]);
|
expect(subscription.assistantTexts).toEqual(["Hello world"]);
|
||||||
});
|
});
|
||||||
|
it("does not duplicate assistantTexts when message_end repeats with trailing whitespace changes", () => {
|
||||||
|
let handler: SessionEventHandler | undefined;
|
||||||
|
const session: StubSession = {
|
||||||
|
subscribe: (fn) => {
|
||||||
|
handler = fn;
|
||||||
|
return () => {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = subscribeEmbeddedPiSession({
|
||||||
|
session: session as unknown as Parameters<typeof subscribeEmbeddedPiSession>[0]["session"],
|
||||||
|
runId: "run",
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantMessageWithNewline = {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Hello world\n" }],
|
||||||
|
} as AssistantMessage;
|
||||||
|
|
||||||
|
const assistantMessageTrimmed = {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "Hello world" }],
|
||||||
|
} as AssistantMessage;
|
||||||
|
|
||||||
|
handler?.({ type: "message_end", message: assistantMessageWithNewline });
|
||||||
|
handler?.({ type: "message_end", message: assistantMessageTrimmed });
|
||||||
|
|
||||||
|
expect(subscription.assistantTexts).toEqual(["Hello world"]);
|
||||||
|
});
|
||||||
it("does not duplicate assistantTexts when message_end repeats with reasoning blocks", () => {
|
it("does not duplicate assistantTexts when message_end repeats with reasoning blocks", () => {
|
||||||
let handler: SessionEventHandler | undefined;
|
let handler: SessionEventHandler | undefined;
|
||||||
const session: StubSession = {
|
const session: StubSession = {
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
lastStreamedAssistant: undefined,
|
lastStreamedAssistant: undefined,
|
||||||
lastStreamedReasoning: undefined,
|
lastStreamedReasoning: undefined,
|
||||||
lastBlockReplyText: undefined,
|
lastBlockReplyText: undefined,
|
||||||
|
assistantMessageIndex: 0,
|
||||||
|
lastAssistantTextMessageIndex: -1,
|
||||||
|
lastAssistantTextNormalized: undefined,
|
||||||
|
lastAssistantTextTrimmed: undefined,
|
||||||
assistantTextBaseline: 0,
|
assistantTextBaseline: 0,
|
||||||
suppressBlockChunks: false, // Avoid late chunk inserts after final text merge.
|
suppressBlockChunks: false, // Avoid late chunk inserts after final text merge.
|
||||||
lastReasoningSent: undefined,
|
lastReasoningSent: undefined,
|
||||||
@@ -84,9 +88,36 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
state.lastStreamedReasoning = undefined;
|
state.lastStreamedReasoning = undefined;
|
||||||
state.lastReasoningSent = undefined;
|
state.lastReasoningSent = undefined;
|
||||||
state.suppressBlockChunks = false;
|
state.suppressBlockChunks = false;
|
||||||
|
state.assistantMessageIndex += 1;
|
||||||
|
state.lastAssistantTextMessageIndex = -1;
|
||||||
|
state.lastAssistantTextNormalized = undefined;
|
||||||
|
state.lastAssistantTextTrimmed = undefined;
|
||||||
state.assistantTextBaseline = nextAssistantTextBaseline;
|
state.assistantTextBaseline = nextAssistantTextBaseline;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rememberAssistantText = (text: string) => {
|
||||||
|
state.lastAssistantTextMessageIndex = state.assistantMessageIndex;
|
||||||
|
state.lastAssistantTextTrimmed = text.trimEnd();
|
||||||
|
const normalized = normalizeTextForComparison(text);
|
||||||
|
state.lastAssistantTextNormalized = normalized.length > 0 ? normalized : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldSkipAssistantText = (text: string) => {
|
||||||
|
if (state.lastAssistantTextMessageIndex !== state.assistantMessageIndex) return false;
|
||||||
|
const trimmed = text.trimEnd();
|
||||||
|
if (trimmed && trimmed === state.lastAssistantTextTrimmed) return true;
|
||||||
|
const normalized = normalizeTextForComparison(text);
|
||||||
|
if (normalized.length > 0 && normalized === state.lastAssistantTextNormalized) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushAssistantText = (text: string) => {
|
||||||
|
if (!text) return;
|
||||||
|
if (shouldSkipAssistantText(text)) return;
|
||||||
|
assistantTexts.push(text);
|
||||||
|
rememberAssistantText(text);
|
||||||
|
};
|
||||||
|
|
||||||
const finalizeAssistantTexts = (args: {
|
const finalizeAssistantTexts = (args: {
|
||||||
text: string;
|
text: string;
|
||||||
addedDuringMessage: boolean;
|
addedDuringMessage: boolean;
|
||||||
@@ -103,16 +134,15 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
assistantTexts.length - state.assistantTextBaseline,
|
assistantTexts.length - state.assistantTextBaseline,
|
||||||
text,
|
text,
|
||||||
);
|
);
|
||||||
|
rememberAssistantText(text);
|
||||||
} else {
|
} else {
|
||||||
const last = assistantTexts.at(-1);
|
pushAssistantText(text);
|
||||||
if (!last || last !== text) assistantTexts.push(text);
|
|
||||||
}
|
}
|
||||||
state.suppressBlockChunks = true;
|
state.suppressBlockChunks = true;
|
||||||
} else if (!addedDuringMessage && !chunkerHasBuffered && text) {
|
} else if (!addedDuringMessage && !chunkerHasBuffered && text) {
|
||||||
// Non-streaming models (no text_delta): ensure assistantTexts gets the final
|
// Non-streaming models (no text_delta): ensure assistantTexts gets the final
|
||||||
// text when the chunker has nothing buffered to drain.
|
// text when the chunker has nothing buffered to drain.
|
||||||
const last = assistantTexts.at(-1);
|
pushAssistantText(text);
|
||||||
if (!last || last !== text) assistantTexts.push(text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
state.assistantTextBaseline = assistantTexts.length;
|
state.assistantTextBaseline = assistantTexts.length;
|
||||||
@@ -338,8 +368,11 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldSkipAssistantText(chunk)) return;
|
||||||
|
|
||||||
state.lastBlockReplyText = chunk;
|
state.lastBlockReplyText = chunk;
|
||||||
assistantTexts.push(chunk);
|
assistantTexts.push(chunk);
|
||||||
|
rememberAssistantText(chunk);
|
||||||
if (!params.onBlockReply) return;
|
if (!params.onBlockReply) return;
|
||||||
const splitResult = parseReplyDirectives(chunk);
|
const splitResult = parseReplyDirectives(chunk);
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
DEFAULT_AGENT_ID,
|
DEFAULT_AGENT_ID,
|
||||||
resolveAgentIdFromSessionKey,
|
resolveAgentIdFromSessionKey,
|
||||||
} from "../../routing/session-key.js";
|
} from "../../routing/session-key.js";
|
||||||
|
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||||
import type { AnyAgentTool } from "./common.js";
|
import type { AnyAgentTool } from "./common.js";
|
||||||
import { readStringParam } from "./common.js";
|
import { readStringParam } from "./common.js";
|
||||||
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
import { resolveInternalSessionKey, resolveMainSessionAlias } from "./sessions-helpers.js";
|
||||||
@@ -240,6 +241,7 @@ export function createSessionStatusTool(opts?: {
|
|||||||
throw new Error(`Unknown sessionKey: ${requestedKeyRaw}`);
|
throw new Error(`Unknown sessionKey: ${requestedKeyRaw}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const configured = resolveDefaultModelForAgent({ cfg, agentId });
|
||||||
const modelRaw = readStringParam(params, "model");
|
const modelRaw = readStringParam(params, "model");
|
||||||
let changedModel = false;
|
let changedModel = false;
|
||||||
if (typeof modelRaw === "string") {
|
if (typeof modelRaw === "string") {
|
||||||
@@ -249,33 +251,33 @@ export function createSessionStatusTool(opts?: {
|
|||||||
sessionEntry: resolved.entry,
|
sessionEntry: resolved.entry,
|
||||||
agentId,
|
agentId,
|
||||||
});
|
});
|
||||||
const nextEntry: SessionEntry = {
|
const nextEntry: SessionEntry = { ...resolved.entry };
|
||||||
...resolved.entry,
|
const applied = applyModelOverrideToSessionEntry({
|
||||||
updatedAt: Date.now(),
|
entry: nextEntry,
|
||||||
};
|
selection:
|
||||||
if (selection.kind === "reset" || selection.isDefault) {
|
selection.kind === "reset"
|
||||||
delete nextEntry.providerOverride;
|
? {
|
||||||
delete nextEntry.modelOverride;
|
provider: configured.provider,
|
||||||
delete nextEntry.authProfileOverride;
|
model: configured.model,
|
||||||
delete nextEntry.authProfileOverrideSource;
|
isDefault: true,
|
||||||
delete nextEntry.authProfileOverrideCompactionCount;
|
}
|
||||||
} else {
|
: {
|
||||||
nextEntry.providerOverride = selection.provider;
|
provider: selection.provider,
|
||||||
nextEntry.modelOverride = selection.model;
|
model: selection.model,
|
||||||
delete nextEntry.authProfileOverride;
|
isDefault: selection.isDefault,
|
||||||
delete nextEntry.authProfileOverrideSource;
|
},
|
||||||
delete nextEntry.authProfileOverrideCompactionCount;
|
|
||||||
}
|
|
||||||
store[resolved.key] = nextEntry;
|
|
||||||
await updateSessionStore(storePath, (nextStore) => {
|
|
||||||
nextStore[resolved.key] = nextEntry;
|
|
||||||
});
|
});
|
||||||
resolved.entry = nextEntry;
|
if (applied.updated) {
|
||||||
changedModel = true;
|
store[resolved.key] = nextEntry;
|
||||||
|
await updateSessionStore(storePath, (nextStore) => {
|
||||||
|
nextStore[resolved.key] = nextEntry;
|
||||||
|
});
|
||||||
|
resolved.entry = nextEntry;
|
||||||
|
changedModel = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentDir = resolveAgentDir(cfg, agentId);
|
const agentDir = resolveAgentDir(cfg, agentId);
|
||||||
const configured = resolveDefaultModelForAgent({ cfg, agentId });
|
|
||||||
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
|
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
|
||||||
const usageProvider = resolveUsageProviderId(providerForCard);
|
const usageProvider = resolveUsageProviderId(providerForCard);
|
||||||
let usageLine: string | undefined;
|
let usageLine: string | undefined;
|
||||||
|
|||||||
@@ -157,6 +157,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
description: "Show current status.",
|
description: "Show current status.",
|
||||||
textAlias: "/status",
|
textAlias: "/status",
|
||||||
}),
|
}),
|
||||||
|
defineChatCommand({
|
||||||
|
key: "allowlist",
|
||||||
|
description: "List/add/remove allowlist entries.",
|
||||||
|
textAlias: "/allowlist",
|
||||||
|
acceptsArgs: true,
|
||||||
|
scope: "text",
|
||||||
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "context",
|
key: "context",
|
||||||
nativeName: "context",
|
nativeName: "context",
|
||||||
|
|||||||
@@ -16,19 +16,21 @@ export const createShouldEmitToolResult = (params: {
|
|||||||
storePath?: string;
|
storePath?: string;
|
||||||
resolvedVerboseLevel: VerboseLevel;
|
resolvedVerboseLevel: VerboseLevel;
|
||||||
}): (() => boolean) => {
|
}): (() => boolean) => {
|
||||||
|
// Normalize verbose values from session store/config so false/"false" still means off.
|
||||||
|
const fallbackVerbose = normalizeVerboseLevel(String(params.resolvedVerboseLevel ?? "")) ?? "off";
|
||||||
return () => {
|
return () => {
|
||||||
if (!params.sessionKey || !params.storePath) {
|
if (!params.sessionKey || !params.storePath) {
|
||||||
return params.resolvedVerboseLevel !== "off";
|
return fallbackVerbose !== "off";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const store = loadSessionStore(params.storePath);
|
const store = loadSessionStore(params.storePath);
|
||||||
const entry = store[params.sessionKey];
|
const entry = store[params.sessionKey];
|
||||||
const current = normalizeVerboseLevel(entry?.verboseLevel);
|
const current = normalizeVerboseLevel(String(entry?.verboseLevel ?? ""));
|
||||||
if (current) return current !== "off";
|
if (current) return current !== "off";
|
||||||
} catch {
|
} catch {
|
||||||
// ignore store read failures
|
// ignore store read failures
|
||||||
}
|
}
|
||||||
return params.resolvedVerboseLevel !== "off";
|
return fallbackVerbose !== "off";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,19 +39,21 @@ export const createShouldEmitToolOutput = (params: {
|
|||||||
storePath?: string;
|
storePath?: string;
|
||||||
resolvedVerboseLevel: VerboseLevel;
|
resolvedVerboseLevel: VerboseLevel;
|
||||||
}): (() => boolean) => {
|
}): (() => boolean) => {
|
||||||
|
// Normalize verbose values from session store/config so false/"false" still means off.
|
||||||
|
const fallbackVerbose = normalizeVerboseLevel(String(params.resolvedVerboseLevel ?? "")) ?? "off";
|
||||||
return () => {
|
return () => {
|
||||||
if (!params.sessionKey || !params.storePath) {
|
if (!params.sessionKey || !params.storePath) {
|
||||||
return params.resolvedVerboseLevel === "full";
|
return fallbackVerbose === "full";
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const store = loadSessionStore(params.storePath);
|
const store = loadSessionStore(params.storePath);
|
||||||
const entry = store[params.sessionKey];
|
const entry = store[params.sessionKey];
|
||||||
const current = normalizeVerboseLevel(entry?.verboseLevel);
|
const current = normalizeVerboseLevel(String(entry?.verboseLevel ?? ""));
|
||||||
if (current) return current === "full";
|
if (current) return current === "full";
|
||||||
} catch {
|
} catch {
|
||||||
// ignore store read failures
|
// ignore store read failures
|
||||||
}
|
}
|
||||||
return params.resolvedVerboseLevel === "full";
|
return fallbackVerbose === "full";
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { MsgContext } from "../templating.js";
|
||||||
|
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||||
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
|
|
||||||
|
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
|
||||||
|
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
|
||||||
|
const writeConfigFileMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../config/config.js", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
readConfigFileSnapshot: readConfigFileSnapshotMock,
|
||||||
|
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
|
||||||
|
writeConfigFile: writeConfigFileMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||||
|
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||||
|
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../pairing/pairing-store.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
|
||||||
|
"../../pairing/pairing-store.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
readChannelAllowFromStore: readChannelAllowFromStoreMock,
|
||||||
|
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
|
||||||
|
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../../channels/plugins/pairing.js", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
|
||||||
|
"../../channels/plugins/pairing.js",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
listPairingChannels: () => ["telegram"],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||||
|
const ctx = {
|
||||||
|
Body: commandBody,
|
||||||
|
CommandBody: commandBody,
|
||||||
|
CommandSource: "text",
|
||||||
|
CommandAuthorized: true,
|
||||||
|
Provider: "telegram",
|
||||||
|
Surface: "telegram",
|
||||||
|
...ctxOverrides,
|
||||||
|
} as MsgContext;
|
||||||
|
|
||||||
|
const command = buildCommandContext({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
isGroup: false,
|
||||||
|
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
command,
|
||||||
|
directives: parseInlineDirectives(commandBody),
|
||||||
|
elevated: { enabled: true, allowed: true, failures: [] },
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
workspaceDir: "/tmp",
|
||||||
|
defaultGroupActivation: () => "mention",
|
||||||
|
resolvedVerboseLevel: "off" as const,
|
||||||
|
resolvedReasoningLevel: "off" as const,
|
||||||
|
resolveDefaultThinkingLevel: async () => undefined,
|
||||||
|
provider: "telegram",
|
||||||
|
model: "test-model",
|
||||||
|
contextTokens: 0,
|
||||||
|
isGroup: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("handleCommands /allowlist", () => {
|
||||||
|
it("lists config + store allowFrom entries", async () => {
|
||||||
|
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const params = buildParams("/allowlist list dm", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Channel: telegram");
|
||||||
|
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
|
||||||
|
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds entries to config and pairing store", async () => {
|
||||||
|
readConfigFileSnapshotMock.mockResolvedValueOnce({
|
||||||
|
valid: true,
|
||||||
|
parsed: {
|
||||||
|
channels: { telegram: { allowFrom: ["123"] } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
|
||||||
|
ok: true,
|
||||||
|
config,
|
||||||
|
}));
|
||||||
|
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
|
||||||
|
changed: true,
|
||||||
|
allowFrom: ["123", "789"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true, config: true },
|
||||||
|
channels: { telegram: { allowFrom: ["123"] } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
const params = buildParams("/allowlist add dm 789", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
channels: { telegram: { allowFrom: ["123", "789"] } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
|
||||||
|
channel: "telegram",
|
||||||
|
entry: "789",
|
||||||
|
});
|
||||||
|
expect(result.reply?.text).toContain("DM allowlist added");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
import {
|
||||||
|
readConfigFileSnapshot,
|
||||||
|
validateConfigObjectWithPlugins,
|
||||||
|
writeConfigFile,
|
||||||
|
} from "../../config/config.js";
|
||||||
|
import { resolveChannelConfigWrites } from "../../channels/plugins/config-writes.js";
|
||||||
|
import { getChannelDock } from "../../channels/dock.js";
|
||||||
|
import { normalizeChannelId } from "../../channels/registry.js";
|
||||||
|
import { listPairingChannels } from "../../channels/plugins/pairing.js";
|
||||||
|
import { logVerbose } from "../../globals.js";
|
||||||
|
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||||
|
import { resolveDiscordAccount } from "../../discord/accounts.js";
|
||||||
|
import { resolveIMessageAccount } from "../../imessage/accounts.js";
|
||||||
|
import { resolveSignalAccount } from "../../signal/accounts.js";
|
||||||
|
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||||
|
import { resolveTelegramAccount } from "../../telegram/accounts.js";
|
||||||
|
import { resolveWhatsAppAccount } from "../../web/accounts.js";
|
||||||
|
import { resolveSlackUserAllowlist } from "../../slack/resolve-users.js";
|
||||||
|
import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js";
|
||||||
|
import {
|
||||||
|
addChannelAllowFromStoreEntry,
|
||||||
|
readChannelAllowFromStore,
|
||||||
|
removeChannelAllowFromStoreEntry,
|
||||||
|
} from "../../pairing/pairing-store.js";
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { ChannelId } from "../../channels/plugins/types.js";
|
||||||
|
import type { CommandHandler } from "./commands-types.js";
|
||||||
|
|
||||||
|
type AllowlistScope = "dm" | "group" | "all";
|
||||||
|
type AllowlistAction = "list" | "add" | "remove";
|
||||||
|
type AllowlistTarget = "both" | "config" | "store";
|
||||||
|
|
||||||
|
type AllowlistCommand =
|
||||||
|
| {
|
||||||
|
action: "list";
|
||||||
|
scope: AllowlistScope;
|
||||||
|
channel?: string;
|
||||||
|
account?: string;
|
||||||
|
resolve?: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
action: "add" | "remove";
|
||||||
|
scope: AllowlistScope;
|
||||||
|
channel?: string;
|
||||||
|
account?: string;
|
||||||
|
entry: string;
|
||||||
|
resolve?: boolean;
|
||||||
|
target: AllowlistTarget;
|
||||||
|
}
|
||||||
|
| { action: "error"; message: string };
|
||||||
|
|
||||||
|
const ACTIONS = new Set(["list", "add", "remove"]);
|
||||||
|
const SCOPES = new Set<AllowlistScope>(["dm", "group", "all"]);
|
||||||
|
|
||||||
|
function parseAllowlistCommand(raw: string): AllowlistCommand | null {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed.toLowerCase().startsWith("/allowlist")) return null;
|
||||||
|
const rest = trimmed.slice("/allowlist".length).trim();
|
||||||
|
if (!rest) return { action: "list", scope: "dm" };
|
||||||
|
|
||||||
|
const tokens = rest.split(/\s+/);
|
||||||
|
let action: AllowlistAction = "list";
|
||||||
|
let scope: AllowlistScope = "dm";
|
||||||
|
let resolve = false;
|
||||||
|
let target: AllowlistTarget = "both";
|
||||||
|
let channel: string | undefined;
|
||||||
|
let account: string | undefined;
|
||||||
|
const entryTokens: string[] = [];
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
if (tokens[i] && ACTIONS.has(tokens[i].toLowerCase())) {
|
||||||
|
action = tokens[i].toLowerCase() as AllowlistAction;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if (tokens[i] && SCOPES.has(tokens[i].toLowerCase() as AllowlistScope)) {
|
||||||
|
scope = tokens[i].toLowerCase() as AllowlistScope;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (; i < tokens.length; i += 1) {
|
||||||
|
const token = tokens[i];
|
||||||
|
const lowered = token.toLowerCase();
|
||||||
|
if (lowered === "--resolve" || lowered === "resolve") {
|
||||||
|
resolve = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lowered === "--config" || lowered === "config") {
|
||||||
|
target = "config";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lowered === "--store" || lowered === "store") {
|
||||||
|
target = "store";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lowered === "--channel" && tokens[i + 1]) {
|
||||||
|
channel = tokens[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (lowered === "--account" && tokens[i + 1]) {
|
||||||
|
account = tokens[i + 1];
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const kv = token.split("=");
|
||||||
|
if (kv.length === 2) {
|
||||||
|
const key = kv[0]?.trim().toLowerCase();
|
||||||
|
const value = kv[1]?.trim();
|
||||||
|
if (key === "channel") {
|
||||||
|
if (value) channel = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === "account") {
|
||||||
|
if (value) account = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === "scope" && value && SCOPES.has(value.toLowerCase() as AllowlistScope)) {
|
||||||
|
scope = value.toLowerCase() as AllowlistScope;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entryTokens.push(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "add" || action === "remove") {
|
||||||
|
const entry = entryTokens.join(" ").trim();
|
||||||
|
if (!entry) {
|
||||||
|
return { action: "error", message: "Usage: /allowlist add|remove <entry>" };
|
||||||
|
}
|
||||||
|
return { action, scope, entry, channel, account, resolve, target };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: "list", scope, channel, account, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAllowFrom(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
channelId: ChannelId;
|
||||||
|
accountId?: string | null;
|
||||||
|
values: Array<string | number>;
|
||||||
|
}): string[] {
|
||||||
|
const dock = getChannelDock(params.channelId);
|
||||||
|
if (dock?.config?.formatAllowFrom) {
|
||||||
|
return dock.config.formatAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
accountId: params.accountId,
|
||||||
|
allowFrom: params.values,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return params.values.map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEntryList(entries: string[], resolved?: Map<string, string>): string {
|
||||||
|
if (entries.length === 0) return "(none)";
|
||||||
|
return entries
|
||||||
|
.map((entry) => {
|
||||||
|
const name = resolved?.get(entry);
|
||||||
|
return name ? `${entry} (${name})` : entry;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAccountTarget(
|
||||||
|
parsed: Record<string, unknown>,
|
||||||
|
channelId: ChannelId,
|
||||||
|
accountId?: string | null,
|
||||||
|
) {
|
||||||
|
const channels = (parsed.channels ??= {}) as Record<string, unknown>;
|
||||||
|
const channel = (channels[channelId] ??= {}) as Record<string, unknown>;
|
||||||
|
const normalizedAccountId = normalizeAccountId(accountId);
|
||||||
|
const hasAccounts = Boolean(channel.accounts && typeof channel.accounts === "object");
|
||||||
|
const useAccount = normalizedAccountId !== DEFAULT_ACCOUNT_ID || hasAccounts;
|
||||||
|
if (!useAccount) {
|
||||||
|
return { target: channel, pathPrefix: `channels.${channelId}`, accountId: normalizedAccountId };
|
||||||
|
}
|
||||||
|
const accounts = (channel.accounts ??= {}) as Record<string, unknown>;
|
||||||
|
const account = (accounts[normalizedAccountId] ??= {}) as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
target: account,
|
||||||
|
pathPrefix: `channels.${channelId}.accounts.${normalizedAccountId}`,
|
||||||
|
accountId: normalizedAccountId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNestedValue(root: Record<string, unknown>, path: string[]): unknown {
|
||||||
|
let current: unknown = root;
|
||||||
|
for (const key of path) {
|
||||||
|
if (!current || typeof current !== "object") return undefined;
|
||||||
|
current = (current as Record<string, unknown>)[key];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureNestedObject(
|
||||||
|
root: Record<string, unknown>,
|
||||||
|
path: string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
let current = root;
|
||||||
|
for (const key of path) {
|
||||||
|
const existing = current[key];
|
||||||
|
if (!existing || typeof existing !== "object") {
|
||||||
|
current[key] = {};
|
||||||
|
}
|
||||||
|
current = current[key] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNestedValue(root: Record<string, unknown>, path: string[], value: unknown) {
|
||||||
|
if (path.length === 0) return;
|
||||||
|
if (path.length === 1) {
|
||||||
|
root[path[0]] = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parent = ensureNestedObject(root, path.slice(0, -1));
|
||||||
|
parent[path[path.length - 1]] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteNestedValue(root: Record<string, unknown>, path: string[]) {
|
||||||
|
if (path.length === 0) return;
|
||||||
|
if (path.length === 1) {
|
||||||
|
delete root[path[0]];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parent = getNestedValue(root, path.slice(0, -1));
|
||||||
|
if (!parent || typeof parent !== "object") return;
|
||||||
|
delete (parent as Record<string, unknown>)[path[path.length - 1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveChannelAllowFromPaths(
|
||||||
|
channelId: ChannelId,
|
||||||
|
scope: AllowlistScope,
|
||||||
|
): string[] | null {
|
||||||
|
if (scope === "all") return null;
|
||||||
|
if (scope === "dm") {
|
||||||
|
if (channelId === "slack" || channelId === "discord") return ["dm", "allowFrom"];
|
||||||
|
if (
|
||||||
|
channelId === "telegram" ||
|
||||||
|
channelId === "whatsapp" ||
|
||||||
|
channelId === "signal" ||
|
||||||
|
channelId === "imessage"
|
||||||
|
) {
|
||||||
|
return ["allowFrom"];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (scope === "group") {
|
||||||
|
if (
|
||||||
|
channelId === "telegram" ||
|
||||||
|
channelId === "whatsapp" ||
|
||||||
|
channelId === "signal" ||
|
||||||
|
channelId === "imessage"
|
||||||
|
) {
|
||||||
|
return ["groupAllowFrom"];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSlackNames(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
entries: string[];
|
||||||
|
}) {
|
||||||
|
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||||
|
const token = account.config.userToken?.trim() || account.botToken?.trim();
|
||||||
|
if (!token) return new Map<string, string>();
|
||||||
|
const resolved = await resolveSlackUserAllowlist({ token, entries: params.entries });
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const entry of resolved) {
|
||||||
|
if (entry.resolved && entry.name) map.set(entry.input, entry.name);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveDiscordNames(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
entries: string[];
|
||||||
|
}) {
|
||||||
|
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||||
|
const token = account.token?.trim();
|
||||||
|
if (!token) return new Map<string, string>();
|
||||||
|
const resolved = await resolveDiscordUserAllowlist({ token, entries: params.entries });
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const entry of resolved) {
|
||||||
|
if (entry.resolved && entry.name) map.set(entry.input, entry.name);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handleAllowlistCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||||
|
if (!allowTextCommands) return null;
|
||||||
|
const parsed = parseAllowlistCommand(params.command.commandBodyNormalized);
|
||||||
|
if (!parsed) return null;
|
||||||
|
if (parsed.action === "error") {
|
||||||
|
return { shouldContinue: false, reply: { text: `⚠️ ${parsed.message}` } };
|
||||||
|
}
|
||||||
|
if (!params.command.isAuthorizedSender) {
|
||||||
|
logVerbose(
|
||||||
|
`Ignoring /allowlist from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||||
|
);
|
||||||
|
return { shouldContinue: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelId =
|
||||||
|
normalizeChannelId(parsed.channel) ??
|
||||||
|
params.command.channelId ??
|
||||||
|
normalizeChannelId(params.command.channel);
|
||||||
|
if (!channelId) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: "⚠️ Unknown channel. Add channel=<id> to the command." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const accountId = normalizeAccountId(parsed.account ?? params.ctx.AccountId);
|
||||||
|
const scope = parsed.scope;
|
||||||
|
|
||||||
|
if (parsed.action === "list") {
|
||||||
|
const pairingChannels = listPairingChannels();
|
||||||
|
const supportsStore = pairingChannels.includes(channelId);
|
||||||
|
const storeAllowFrom = supportsStore
|
||||||
|
? await readChannelAllowFromStore(channelId).catch(() => [])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
let dmAllowFrom: string[] = [];
|
||||||
|
let groupAllowFrom: string[] = [];
|
||||||
|
let groupOverrides: Array<{ label: string; entries: string[] }> = [];
|
||||||
|
let dmPolicy: string | undefined;
|
||||||
|
let groupPolicy: string | undefined;
|
||||||
|
|
||||||
|
if (channelId === "telegram") {
|
||||||
|
const account = resolveTelegramAccount({ cfg: params.cfg, accountId });
|
||||||
|
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
|
||||||
|
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
|
||||||
|
dmPolicy = account.config.dmPolicy;
|
||||||
|
groupPolicy = account.config.groupPolicy;
|
||||||
|
const groups = account.config.groups ?? {};
|
||||||
|
for (const [groupId, groupCfg] of Object.entries(groups)) {
|
||||||
|
const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean);
|
||||||
|
if (entries.length > 0) {
|
||||||
|
groupOverrides.push({ label: groupId, entries });
|
||||||
|
}
|
||||||
|
const topics = groupCfg?.topics ?? {};
|
||||||
|
for (const [topicId, topicCfg] of Object.entries(topics)) {
|
||||||
|
const topicEntries = (topicCfg?.allowFrom ?? []).map(String).filter(Boolean);
|
||||||
|
if (topicEntries.length > 0) {
|
||||||
|
groupOverrides.push({ label: `${groupId} topic ${topicId}`, entries: topicEntries });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (channelId === "whatsapp") {
|
||||||
|
const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId });
|
||||||
|
dmAllowFrom = (account.allowFrom ?? []).map(String);
|
||||||
|
groupAllowFrom = (account.groupAllowFrom ?? []).map(String);
|
||||||
|
dmPolicy = account.dmPolicy;
|
||||||
|
groupPolicy = account.groupPolicy;
|
||||||
|
} else if (channelId === "signal") {
|
||||||
|
const account = resolveSignalAccount({ cfg: params.cfg, accountId });
|
||||||
|
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
|
||||||
|
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
|
||||||
|
dmPolicy = account.config.dmPolicy;
|
||||||
|
groupPolicy = account.config.groupPolicy;
|
||||||
|
} else if (channelId === "imessage") {
|
||||||
|
const account = resolveIMessageAccount({ cfg: params.cfg, accountId });
|
||||||
|
dmAllowFrom = (account.config.allowFrom ?? []).map(String);
|
||||||
|
groupAllowFrom = (account.config.groupAllowFrom ?? []).map(String);
|
||||||
|
dmPolicy = account.config.dmPolicy;
|
||||||
|
groupPolicy = account.config.groupPolicy;
|
||||||
|
} else if (channelId === "slack") {
|
||||||
|
const account = resolveSlackAccount({ cfg: params.cfg, accountId });
|
||||||
|
dmAllowFrom = (account.dm?.allowFrom ?? []).map(String);
|
||||||
|
groupPolicy = account.groupPolicy;
|
||||||
|
const channels = account.channels ?? {};
|
||||||
|
groupOverrides = Object.entries(channels)
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const entries = (value?.users ?? []).map(String).filter(Boolean);
|
||||||
|
return entries.length > 0 ? { label: key, entries } : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as Array<{ label: string; entries: string[] }>;
|
||||||
|
} else if (channelId === "discord") {
|
||||||
|
const account = resolveDiscordAccount({ cfg: params.cfg, accountId });
|
||||||
|
dmAllowFrom = (account.config.dm?.allowFrom ?? []).map(String);
|
||||||
|
groupPolicy = account.config.groupPolicy;
|
||||||
|
const guilds = account.config.guilds ?? {};
|
||||||
|
for (const [guildKey, guildCfg] of Object.entries(guilds)) {
|
||||||
|
const entries = (guildCfg?.users ?? []).map(String).filter(Boolean);
|
||||||
|
if (entries.length > 0) {
|
||||||
|
groupOverrides.push({ label: `guild ${guildKey}`, entries });
|
||||||
|
}
|
||||||
|
const channels = guildCfg?.channels ?? {};
|
||||||
|
for (const [channelKey, channelCfg] of Object.entries(channels)) {
|
||||||
|
const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean);
|
||||||
|
if (channelEntries.length > 0) {
|
||||||
|
groupOverrides.push({
|
||||||
|
label: `guild ${guildKey} / channel ${channelKey}`,
|
||||||
|
entries: channelEntries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dmDisplay = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId,
|
||||||
|
values: dmAllowFrom,
|
||||||
|
});
|
||||||
|
const groupDisplay = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId,
|
||||||
|
values: groupAllowFrom,
|
||||||
|
});
|
||||||
|
const groupOverrideEntries = groupOverrides.flatMap((entry) => entry.entries);
|
||||||
|
const groupOverrideDisplay = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId,
|
||||||
|
values: groupOverrideEntries,
|
||||||
|
});
|
||||||
|
const resolvedDm =
|
||||||
|
parsed.resolve && dmDisplay.length > 0 && channelId === "slack"
|
||||||
|
? await resolveSlackNames({ cfg: params.cfg, accountId, entries: dmDisplay })
|
||||||
|
: parsed.resolve && dmDisplay.length > 0 && channelId === "discord"
|
||||||
|
? await resolveDiscordNames({ cfg: params.cfg, accountId, entries: dmDisplay })
|
||||||
|
: undefined;
|
||||||
|
const resolvedGroup =
|
||||||
|
parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "slack"
|
||||||
|
? await resolveSlackNames({
|
||||||
|
cfg: params.cfg,
|
||||||
|
accountId,
|
||||||
|
entries: groupOverrideDisplay,
|
||||||
|
})
|
||||||
|
: parsed.resolve && groupOverrideDisplay.length > 0 && channelId === "discord"
|
||||||
|
? await resolveDiscordNames({
|
||||||
|
cfg: params.cfg,
|
||||||
|
accountId,
|
||||||
|
entries: groupOverrideDisplay,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const lines: string[] = ["🧾 Allowlist"];
|
||||||
|
lines.push(`Channel: ${channelId}${accountId ? ` (account ${accountId})` : ""}`);
|
||||||
|
if (dmPolicy) lines.push(`DM policy: ${dmPolicy}`);
|
||||||
|
if (groupPolicy) lines.push(`Group policy: ${groupPolicy}`);
|
||||||
|
|
||||||
|
const showDm = scope === "dm" || scope === "all";
|
||||||
|
const showGroup = scope === "group" || scope === "all";
|
||||||
|
if (showDm) {
|
||||||
|
lines.push(`DM allowFrom (config): ${formatEntryList(dmDisplay, resolvedDm)}`);
|
||||||
|
}
|
||||||
|
if (supportsStore && storeAllowFrom.length > 0) {
|
||||||
|
const storeLabel = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId,
|
||||||
|
values: storeAllowFrom,
|
||||||
|
});
|
||||||
|
lines.push(`Paired allowFrom (store): ${formatEntryList(storeLabel)}`);
|
||||||
|
}
|
||||||
|
if (showGroup) {
|
||||||
|
if (groupAllowFrom.length > 0) {
|
||||||
|
lines.push(`Group allowFrom (config): ${formatEntryList(groupDisplay)}`);
|
||||||
|
}
|
||||||
|
if (groupOverrides.length > 0) {
|
||||||
|
lines.push("Group overrides:");
|
||||||
|
for (const entry of groupOverrides) {
|
||||||
|
const normalized = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId,
|
||||||
|
values: entry.entries,
|
||||||
|
});
|
||||||
|
lines.push(`- ${entry.label}: ${formatEntryList(normalized, resolvedGroup)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { shouldContinue: false, reply: { text: lines.join("\n") } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.cfg.commands?.config !== true) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: "⚠️ /allowlist edits are disabled. Set commands.config=true to enable." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldUpdateConfig = parsed.target !== "store";
|
||||||
|
const shouldTouchStore = parsed.target !== "config" && listPairingChannels().includes(channelId);
|
||||||
|
|
||||||
|
if (shouldUpdateConfig) {
|
||||||
|
const allowWrites = resolveChannelConfigWrites({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId: params.ctx.AccountId,
|
||||||
|
});
|
||||||
|
if (!allowWrites) {
|
||||||
|
const hint = `channels.${channelId}.configWrites=true`;
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: `⚠️ Config writes are disabled for ${channelId}. Set ${hint} to enable.` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowlistPath = resolveChannelAllowFromPaths(channelId, scope);
|
||||||
|
if (!allowlistPath) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: {
|
||||||
|
text: `⚠️ ${channelId} does not support ${scope} allowlist edits via /allowlist.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await readConfigFileSnapshot();
|
||||||
|
if (!snapshot.valid || !snapshot.parsed || typeof snapshot.parsed !== "object") {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: "⚠️ Config file is invalid; fix it before using /allowlist." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const parsedConfig = structuredClone(snapshot.parsed as Record<string, unknown>);
|
||||||
|
const {
|
||||||
|
target,
|
||||||
|
pathPrefix,
|
||||||
|
accountId: normalizedAccountId,
|
||||||
|
} = resolveAccountTarget(parsedConfig, channelId, accountId);
|
||||||
|
const existingRaw = getNestedValue(target, allowlistPath);
|
||||||
|
const existing = Array.isArray(existingRaw)
|
||||||
|
? existingRaw.map((entry) => String(entry).trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const normalizedEntry = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId: normalizedAccountId,
|
||||||
|
values: [parsed.entry],
|
||||||
|
});
|
||||||
|
if (normalizedEntry.length === 0) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: "⚠️ Invalid allowlist entry." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingNormalized = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId: normalizedAccountId,
|
||||||
|
values: existing,
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldMatch = (value: string) => normalizedEntry.includes(value);
|
||||||
|
|
||||||
|
let configChanged = false;
|
||||||
|
let next = existing;
|
||||||
|
const configHasEntry = existingNormalized.some((value) => shouldMatch(value));
|
||||||
|
if (parsed.action === "add") {
|
||||||
|
if (!configHasEntry) {
|
||||||
|
next = [...existing, parsed.entry.trim()];
|
||||||
|
configChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.action === "remove") {
|
||||||
|
const keep: string[] = [];
|
||||||
|
for (const entry of existing) {
|
||||||
|
const normalized = normalizeAllowFrom({
|
||||||
|
cfg: params.cfg,
|
||||||
|
channelId,
|
||||||
|
accountId: normalizedAccountId,
|
||||||
|
values: [entry],
|
||||||
|
});
|
||||||
|
if (normalized.some((value) => shouldMatch(value))) {
|
||||||
|
configChanged = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
keep.push(entry);
|
||||||
|
}
|
||||||
|
next = keep;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configChanged) {
|
||||||
|
if (next.length === 0) {
|
||||||
|
deleteNestedValue(target, allowlistPath);
|
||||||
|
} else {
|
||||||
|
setNestedValue(target, allowlistPath, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configChanged) {
|
||||||
|
const validated = validateConfigObjectWithPlugins(parsedConfig);
|
||||||
|
if (!validated.ok) {
|
||||||
|
const issue = validated.issues[0];
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: `⚠️ Config invalid after update (${issue.path}: ${issue.message}).` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await writeConfigFile(validated.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!configChanged && !shouldTouchStore) {
|
||||||
|
const message = parsed.action === "add" ? "✅ Already allowlisted." : "⚠️ Entry not found.";
|
||||||
|
return { shouldContinue: false, reply: { text: message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldTouchStore) {
|
||||||
|
if (parsed.action === "add") {
|
||||||
|
await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||||
|
} else if (parsed.action === "remove") {
|
||||||
|
await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionLabel = parsed.action === "add" ? "added" : "removed";
|
||||||
|
const scopeLabel = scope === "dm" ? "DM" : "group";
|
||||||
|
const locations: string[] = [];
|
||||||
|
if (configChanged) {
|
||||||
|
locations.push(`${pathPrefix}.${allowlistPath.join(".")}`);
|
||||||
|
}
|
||||||
|
if (shouldTouchStore) {
|
||||||
|
locations.push("pairing store");
|
||||||
|
}
|
||||||
|
const targetLabel = locations.length > 0 ? locations.join(" + ") : "no-op";
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: {
|
||||||
|
text: `✅ ${scopeLabel} allowlist ${actionLabel}: ${targetLabel}.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldTouchStore) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: "⚠️ This channel does not support allowlist storage." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.action === "add") {
|
||||||
|
await addChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||||
|
} else if (parsed.action === "remove") {
|
||||||
|
await removeChannelAllowFromStoreEntry({ channel: channelId, entry: parsed.entry });
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionLabel = parsed.action === "add" ? "added" : "removed";
|
||||||
|
const scopeLabel = scope === "dm" ? "DM" : "group";
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: `✅ ${scopeLabel} allowlist ${actionLabel} in pairing store.` },
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
handleStatusCommand,
|
handleStatusCommand,
|
||||||
handleWhoamiCommand,
|
handleWhoamiCommand,
|
||||||
} from "./commands-info.js";
|
} from "./commands-info.js";
|
||||||
|
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
||||||
import { handleSubagentsCommand } from "./commands-subagents.js";
|
import { handleSubagentsCommand } from "./commands-subagents.js";
|
||||||
import {
|
import {
|
||||||
handleAbortTrigger,
|
handleAbortTrigger,
|
||||||
@@ -37,6 +38,7 @@ const HANDLERS: CommandHandler[] = [
|
|||||||
handleHelpCommand,
|
handleHelpCommand,
|
||||||
handleCommandsListCommand,
|
handleCommandsListCommand,
|
||||||
handleStatusCommand,
|
handleStatusCommand,
|
||||||
|
handleAllowlistCommand,
|
||||||
handleContextCommand,
|
handleContextCommand,
|
||||||
handleWhoamiCommand,
|
handleWhoamiCommand,
|
||||||
handleSubagentsCommand,
|
handleSubagentsCommand,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { type SessionEntry, updateSessionStore } from "../../config/sessions.js"
|
|||||||
import type { ExecAsk, ExecHost, ExecSecurity } from "../../infra/exec-approvals.js";
|
import type { ExecAsk, ExecHost, ExecSecurity } from "../../infra/exec-approvals.js";
|
||||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||||
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
|
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
|
||||||
|
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||||
import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js";
|
import { formatThinkingLevels, formatXHighModelHint, supportsXHighThinking } from "../thinking.js";
|
||||||
import type { ReplyPayload } from "../types.js";
|
import type { ReplyPayload } from "../types.js";
|
||||||
import {
|
import {
|
||||||
@@ -340,22 +341,11 @@ export async function handleDirectiveOnly(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (modelSelection) {
|
if (modelSelection) {
|
||||||
if (modelSelection.isDefault) {
|
applyModelOverrideToSessionEntry({
|
||||||
delete sessionEntry.providerOverride;
|
entry: sessionEntry,
|
||||||
delete sessionEntry.modelOverride;
|
selection: modelSelection,
|
||||||
} else {
|
profileOverride,
|
||||||
sessionEntry.providerOverride = modelSelection.provider;
|
});
|
||||||
sessionEntry.modelOverride = modelSelection.model;
|
|
||||||
}
|
|
||||||
if (profileOverride) {
|
|
||||||
sessionEntry.authProfileOverride = profileOverride;
|
|
||||||
sessionEntry.authProfileOverrideSource = "user";
|
|
||||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
|
||||||
} else if (directives.hasModelDirective) {
|
|
||||||
delete sessionEntry.authProfileOverride;
|
|
||||||
delete sessionEntry.authProfileOverrideSource;
|
|
||||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (directives.hasQueueDirective && directives.queueReset) {
|
if (directives.hasQueueDirective && directives.queueReset) {
|
||||||
delete sessionEntry.queueMode;
|
delete sessionEntry.queueMode;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { ClawdbotConfig } from "../../config/config.js";
|
|||||||
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||||
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
|
import { applyVerboseOverride } from "../../sessions/level-overrides.js";
|
||||||
|
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||||
import { resolveProfileOverride } from "./directive-handling.auth.js";
|
import { resolveProfileOverride } from "./directive-handling.auth.js";
|
||||||
import type { InlineDirectives } from "./directive-handling.parse.js";
|
import type { InlineDirectives } from "./directive-handling.parse.js";
|
||||||
import { formatElevatedEvent, formatReasoningEvent } from "./directive-handling.shared.js";
|
import { formatElevatedEvent, formatReasoningEvent } from "./directive-handling.shared.js";
|
||||||
@@ -164,22 +165,15 @@ export async function persistInlineDirectives(params: {
|
|||||||
}
|
}
|
||||||
const isDefault =
|
const isDefault =
|
||||||
resolved.ref.provider === defaultProvider && resolved.ref.model === defaultModel;
|
resolved.ref.provider === defaultProvider && resolved.ref.model === defaultModel;
|
||||||
if (isDefault) {
|
const { updated: modelUpdated } = applyModelOverrideToSessionEntry({
|
||||||
delete sessionEntry.providerOverride;
|
entry: sessionEntry,
|
||||||
delete sessionEntry.modelOverride;
|
selection: {
|
||||||
} else {
|
provider: resolved.ref.provider,
|
||||||
sessionEntry.providerOverride = resolved.ref.provider;
|
model: resolved.ref.model,
|
||||||
sessionEntry.modelOverride = resolved.ref.model;
|
isDefault,
|
||||||
}
|
},
|
||||||
if (profileOverride) {
|
profileOverride,
|
||||||
sessionEntry.authProfileOverride = profileOverride;
|
});
|
||||||
sessionEntry.authProfileOverrideSource = "user";
|
|
||||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
|
||||||
} else if (directives.hasModelDirective) {
|
|
||||||
delete sessionEntry.authProfileOverride;
|
|
||||||
delete sessionEntry.authProfileOverrideSource;
|
|
||||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
|
||||||
}
|
|
||||||
provider = resolved.ref.provider;
|
provider = resolved.ref.provider;
|
||||||
model = resolved.ref.model;
|
model = resolved.ref.model;
|
||||||
const nextLabel = `${provider}/${model}`;
|
const nextLabel = `${provider}/${model}`;
|
||||||
@@ -189,7 +183,7 @@ export async function persistInlineDirectives(params: {
|
|||||||
contextKey: `model:${nextLabel}`,
|
contextKey: `model:${nextLabel}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
updated = true;
|
updated = updated || modelUpdated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,11 @@ import {
|
|||||||
isEmbeddedPiRunStreaming,
|
isEmbeddedPiRunStreaming,
|
||||||
resolveEmbeddedSessionLane,
|
resolveEmbeddedSessionLane,
|
||||||
} from "../../agents/pi-embedded.js";
|
} from "../../agents/pi-embedded.js";
|
||||||
import {
|
import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
|
||||||
ensureAuthProfileStore,
|
|
||||||
isProfileInCooldown,
|
|
||||||
resolveAuthProfileOrder,
|
|
||||||
} from "../../agents/auth-profiles.js";
|
|
||||||
import type { ExecToolDefaults } from "../../agents/bash-tools.js";
|
import type { ExecToolDefaults } from "../../agents/bash-tools.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import {
|
import {
|
||||||
resolveSessionFilePath,
|
resolveSessionFilePath,
|
||||||
saveSessionStore,
|
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
updateSessionStore,
|
updateSessionStore,
|
||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
@@ -108,92 +103,6 @@ type RunPreparedReplyParams = {
|
|||||||
abortedLastRun: boolean;
|
abortedLastRun: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function resolveSessionAuthProfileOverride(params: {
|
|
||||||
cfg: ClawdbotConfig;
|
|
||||||
provider: string;
|
|
||||||
agentDir: string;
|
|
||||||
sessionEntry?: SessionEntry;
|
|
||||||
sessionStore?: Record<string, SessionEntry>;
|
|
||||||
sessionKey?: string;
|
|
||||||
storePath?: string;
|
|
||||||
isNewSession: boolean;
|
|
||||||
}): Promise<string | undefined> {
|
|
||||||
const {
|
|
||||||
cfg,
|
|
||||||
provider,
|
|
||||||
agentDir,
|
|
||||||
sessionEntry,
|
|
||||||
sessionStore,
|
|
||||||
sessionKey,
|
|
||||||
storePath,
|
|
||||||
isNewSession,
|
|
||||||
} = params;
|
|
||||||
if (!sessionEntry || !sessionStore || !sessionKey) return sessionEntry?.authProfileOverride;
|
|
||||||
|
|
||||||
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
|
||||||
const order = resolveAuthProfileOrder({ cfg, store, provider });
|
|
||||||
if (order.length === 0) return sessionEntry.authProfileOverride;
|
|
||||||
|
|
||||||
const pickFirstAvailable = () =>
|
|
||||||
order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
|
|
||||||
const pickNextAvailable = (current: string) => {
|
|
||||||
const startIndex = order.indexOf(current);
|
|
||||||
if (startIndex < 0) return pickFirstAvailable();
|
|
||||||
for (let offset = 1; offset <= order.length; offset += 1) {
|
|
||||||
const candidate = order[(startIndex + offset) % order.length];
|
|
||||||
if (!isProfileInCooldown(store, candidate)) return candidate;
|
|
||||||
}
|
|
||||||
return order[startIndex] ?? order[0];
|
|
||||||
};
|
|
||||||
|
|
||||||
const compactionCount = sessionEntry.compactionCount ?? 0;
|
|
||||||
const storedCompaction =
|
|
||||||
typeof sessionEntry.authProfileOverrideCompactionCount === "number"
|
|
||||||
? sessionEntry.authProfileOverrideCompactionCount
|
|
||||||
: compactionCount;
|
|
||||||
|
|
||||||
let current = sessionEntry.authProfileOverride?.trim();
|
|
||||||
if (current && !order.includes(current)) current = undefined;
|
|
||||||
|
|
||||||
const source =
|
|
||||||
sessionEntry.authProfileOverrideSource ??
|
|
||||||
(typeof sessionEntry.authProfileOverrideCompactionCount === "number"
|
|
||||||
? "auto"
|
|
||||||
: current
|
|
||||||
? "user"
|
|
||||||
: undefined);
|
|
||||||
if (source === "user" && current && !isNewSession) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
let next = current;
|
|
||||||
if (isNewSession) {
|
|
||||||
next = current ? pickNextAvailable(current) : pickFirstAvailable();
|
|
||||||
} else if (current && compactionCount > storedCompaction) {
|
|
||||||
next = pickNextAvailable(current);
|
|
||||||
} else if (!current || isProfileInCooldown(store, current)) {
|
|
||||||
next = pickFirstAvailable();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!next) return current;
|
|
||||||
const shouldPersist =
|
|
||||||
next !== sessionEntry.authProfileOverride ||
|
|
||||||
sessionEntry.authProfileOverrideSource !== "auto" ||
|
|
||||||
sessionEntry.authProfileOverrideCompactionCount !== compactionCount;
|
|
||||||
if (shouldPersist) {
|
|
||||||
sessionEntry.authProfileOverride = next;
|
|
||||||
sessionEntry.authProfileOverrideSource = "auto";
|
|
||||||
sessionEntry.authProfileOverrideCompactionCount = compactionCount;
|
|
||||||
sessionEntry.updatedAt = Date.now();
|
|
||||||
sessionStore[sessionKey] = sessionEntry;
|
|
||||||
if (storePath) {
|
|
||||||
await saveSessionStore(storePath, sessionStore);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runPreparedReply(
|
export async function runPreparedReply(
|
||||||
params: RunPreparedReplyParams,
|
params: RunPreparedReplyParams,
|
||||||
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from "../../agents/model-selection.js";
|
} from "../../agents/model-selection.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
|
||||||
|
import { clearSessionAuthProfileOverride } from "../../agents/auth-profiles/session-override.js";
|
||||||
|
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||||
import type { ThinkLevel } from "./directives.js";
|
import type { ThinkLevel } from "./directives.js";
|
||||||
|
|
||||||
export type ModelDirectiveSelection = {
|
export type ModelDirectiveSelection = {
|
||||||
@@ -184,16 +186,19 @@ export async function createModelSelectionState(params: {
|
|||||||
if (overrideModel) {
|
if (overrideModel) {
|
||||||
const key = modelKey(overrideProvider, overrideModel);
|
const key = modelKey(overrideProvider, overrideModel);
|
||||||
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
|
if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) {
|
||||||
delete sessionEntry.providerOverride;
|
const { updated } = applyModelOverrideToSessionEntry({
|
||||||
delete sessionEntry.modelOverride;
|
entry: sessionEntry,
|
||||||
sessionEntry.updatedAt = Date.now();
|
selection: { provider: defaultProvider, model: defaultModel, isDefault: true },
|
||||||
sessionStore[sessionKey] = sessionEntry;
|
});
|
||||||
if (storePath) {
|
if (updated) {
|
||||||
await updateSessionStore(storePath, (store) => {
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
store[sessionKey] = sessionEntry;
|
if (storePath) {
|
||||||
});
|
await updateSessionStore(storePath, (store) => {
|
||||||
|
store[sessionKey] = sessionEntry;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
resetModelOverride = true;
|
resetModelOverride = updated;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,17 +220,14 @@ export async function createModelSelectionState(params: {
|
|||||||
allowKeychainPrompt: false,
|
allowKeychainPrompt: false,
|
||||||
});
|
});
|
||||||
const profile = store.profiles[sessionEntry.authProfileOverride];
|
const profile = store.profiles[sessionEntry.authProfileOverride];
|
||||||
if (!profile || profile.provider !== provider) {
|
const providerKey = normalizeProviderId(provider);
|
||||||
delete sessionEntry.authProfileOverride;
|
if (!profile || normalizeProviderId(profile.provider) !== providerKey) {
|
||||||
delete sessionEntry.authProfileOverrideSource;
|
await clearSessionAuthProfileOverride({
|
||||||
delete sessionEntry.authProfileOverrideCompactionCount;
|
sessionEntry,
|
||||||
sessionEntry.updatedAt = Date.now();
|
sessionStore,
|
||||||
sessionStore[sessionKey] = sessionEntry;
|
sessionKey,
|
||||||
if (storePath) {
|
storePath,
|
||||||
await updateSessionStore(storePath, (store) => {
|
});
|
||||||
store[sessionKey] = sessionEntry;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,22 @@ describe("routeReply", () => {
|
|||||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi", expect.any(Object));
|
expect(mocks.sendMessageSlack).toHaveBeenCalledWith("channel:C123", "hi", expect.any(Object));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses threadId for Slack when replyToId is missing", async () => {
|
||||||
|
mocks.sendMessageSlack.mockClear();
|
||||||
|
await routeReply({
|
||||||
|
payload: { text: "hi" },
|
||||||
|
channel: "slack",
|
||||||
|
to: "channel:C123",
|
||||||
|
threadId: "456.789",
|
||||||
|
cfg: {} as never,
|
||||||
|
});
|
||||||
|
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||||
|
"channel:C123",
|
||||||
|
"hi",
|
||||||
|
expect.objectContaining({ threadTs: "456.789" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("passes thread id to Telegram sends", async () => {
|
it("passes thread id to Telegram sends", async () => {
|
||||||
mocks.sendMessageTelegram.mockClear();
|
mocks.sendMessageTelegram.mockClear();
|
||||||
await routeReply({
|
await routeReply({
|
||||||
|
|||||||
@@ -42,6 +42,39 @@ describe("applyResetModelOverride", () => {
|
|||||||
expect(sessionCtx.BodyStripped).toBe("summarize");
|
expect(sessionCtx.BodyStripped).toBe("summarize");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears auth profile overrides when reset applies a model", async () => {
|
||||||
|
const cfg = {} as ClawdbotConfig;
|
||||||
|
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
||||||
|
const sessionEntry = {
|
||||||
|
sessionId: "s1",
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
authProfileOverride: "anthropic:default",
|
||||||
|
authProfileOverrideSource: "user",
|
||||||
|
authProfileOverrideCompactionCount: 2,
|
||||||
|
};
|
||||||
|
const sessionStore = { "agent:main:dm:1": sessionEntry };
|
||||||
|
const sessionCtx = { BodyStripped: "minimax summarize" };
|
||||||
|
const ctx = { ChatType: "direct" };
|
||||||
|
|
||||||
|
await applyResetModelOverride({
|
||||||
|
cfg,
|
||||||
|
resetTriggered: true,
|
||||||
|
bodyStripped: "minimax summarize",
|
||||||
|
sessionCtx,
|
||||||
|
ctx,
|
||||||
|
sessionEntry,
|
||||||
|
sessionStore,
|
||||||
|
sessionKey: "agent:main:dm:1",
|
||||||
|
defaultProvider: "openai",
|
||||||
|
defaultModel: "gpt-4o-mini",
|
||||||
|
aliasIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sessionEntry.authProfileOverride).toBeUndefined();
|
||||||
|
expect(sessionEntry.authProfileOverrideSource).toBeUndefined();
|
||||||
|
expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("skips when resetTriggered is false", async () => {
|
it("skips when resetTriggered is false", async () => {
|
||||||
const cfg = {} as ClawdbotConfig;
|
const cfg = {} as ClawdbotConfig;
|
||||||
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { updateSessionStore } from "../../config/sessions.js";
|
|||||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||||
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
|
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
|
||||||
import { resolveModelDirectiveSelection, type ModelDirectiveSelection } from "./model-selection.js";
|
import { resolveModelDirectiveSelection, type ModelDirectiveSelection } from "./model-selection.js";
|
||||||
|
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
|
||||||
|
|
||||||
type ResetModelResult = {
|
type ResetModelResult = {
|
||||||
selection?: ModelDirectiveSelection;
|
selection?: ModelDirectiveSelection;
|
||||||
@@ -62,25 +63,11 @@ function applySelectionToSession(params: {
|
|||||||
}) {
|
}) {
|
||||||
const { selection, sessionEntry, sessionStore, sessionKey, storePath } = params;
|
const { selection, sessionEntry, sessionStore, sessionKey, storePath } = params;
|
||||||
if (!sessionEntry || !sessionStore || !sessionKey) return;
|
if (!sessionEntry || !sessionStore || !sessionKey) return;
|
||||||
let updated = false;
|
const { updated } = applyModelOverrideToSessionEntry({
|
||||||
if (selection.isDefault) {
|
entry: sessionEntry,
|
||||||
if (sessionEntry.providerOverride || sessionEntry.modelOverride) {
|
selection,
|
||||||
delete sessionEntry.providerOverride;
|
});
|
||||||
delete sessionEntry.modelOverride;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (sessionEntry.providerOverride !== selection.provider) {
|
|
||||||
sessionEntry.providerOverride = selection.provider;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
if (sessionEntry.modelOverride !== selection.model) {
|
|
||||||
sessionEntry.modelOverride = selection.model;
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!updated) return;
|
if (!updated) return;
|
||||||
sessionEntry.updatedAt = Date.now();
|
|
||||||
sessionStore[sessionKey] = sessionEntry;
|
sessionStore[sessionKey] = sessionEntry;
|
||||||
if (storePath) {
|
if (storePath) {
|
||||||
updateSessionStore(storePath, (store) => {
|
updateSessionStore(storePath, (store) => {
|
||||||
|
|||||||
@@ -5,19 +5,23 @@ export const slackOutbound: ChannelOutboundAdapter = {
|
|||||||
deliveryMode: "direct",
|
deliveryMode: "direct",
|
||||||
chunker: null,
|
chunker: null,
|
||||||
textChunkLimit: 4000,
|
textChunkLimit: 4000,
|
||||||
sendText: async ({ to, text, accountId, deps, replyToId }) => {
|
sendText: async ({ to, text, accountId, deps, replyToId, threadId }) => {
|
||||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||||
|
// Use threadId fallback so routed tool notifications stay in the Slack thread.
|
||||||
|
const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined);
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
threadTs: replyToId ?? undefined,
|
threadTs,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
return { channel: "slack", ...result };
|
return { channel: "slack", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
|
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId }) => {
|
||||||
const send = deps?.sendSlack ?? sendMessageSlack;
|
const send = deps?.sendSlack ?? sendMessageSlack;
|
||||||
|
// Use threadId fallback so routed tool notifications stay in the Slack thread.
|
||||||
|
const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined);
|
||||||
const result = await send(to, text, {
|
const result = await send(to, text, {
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
threadTs: replyToId ?? undefined,
|
threadTs,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
});
|
});
|
||||||
return { channel: "slack", ...result };
|
return { channel: "slack", ...result };
|
||||||
|
|||||||
+17
-12
@@ -9,7 +9,6 @@ import { defaultRuntime } from "../runtime.js";
|
|||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
import { renderTable } from "../terminal/table.js";
|
import { renderTable } from "../terminal/table.js";
|
||||||
import type { ChannelDirectoryEntry } from "../channels/plugins/types.core.js";
|
|
||||||
|
|
||||||
function parseLimit(value: unknown): number | null {
|
function parseLimit(value: unknown): number | null {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
@@ -31,15 +30,6 @@ function buildRows(entries: Array<{ id: string; name?: string | undefined }>) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEntry(entry: ChannelDirectoryEntry): string {
|
|
||||||
const name = entry.name?.trim();
|
|
||||||
const handle = entry.handle?.trim();
|
|
||||||
const handleLabel = handle ? (handle.startsWith("@") ? handle : `@${handle}`) : null;
|
|
||||||
const label = [name, handleLabel].filter(Boolean).join(" ");
|
|
||||||
if (!label) return entry.id;
|
|
||||||
return `${label} ${theme.muted(`(${entry.id})`)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerDirectoryCli(program: Command) {
|
export function registerDirectoryCli(program: Command) {
|
||||||
const directory = program
|
const directory = program
|
||||||
.command("directory")
|
.command("directory")
|
||||||
@@ -230,9 +220,24 @@ export function registerDirectoryCli(program: Command) {
|
|||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const entry of result) {
|
if (result.length === 0) {
|
||||||
defaultRuntime.log(formatEntry(entry));
|
defaultRuntime.log(theme.muted("No group members found."));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||||
|
defaultRuntime.log(
|
||||||
|
`${theme.heading("Group Members")} ${theme.muted(`(${result.length})`)}`,
|
||||||
|
);
|
||||||
|
defaultRuntime.log(
|
||||||
|
renderTable({
|
||||||
|
width: tableWidth,
|
||||||
|
columns: [
|
||||||
|
{ key: "ID", header: "ID", minWidth: 16, flex: true },
|
||||||
|
{ key: "Name", header: "Name", minWidth: 18, flex: true },
|
||||||
|
],
|
||||||
|
rows: buildRows(result),
|
||||||
|
}).trimEnd(),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(String(err)));
|
defaultRuntime.error(danger(String(err)));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ describe("dns cli", () => {
|
|||||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
const program = buildProgram();
|
const program = buildProgram();
|
||||||
await program.parseAsync(["dns", "setup"], { from: "user" });
|
await program.parseAsync(["dns", "setup"], { from: "user" });
|
||||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("clawdbot.internal"));
|
const output = log.mock.calls.map((call) => call.join(" ")).join("\n");
|
||||||
|
expect(output).toContain("DNS setup");
|
||||||
|
expect(output).toContain("clawdbot.internal");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -11,14 +11,14 @@ export function formatHelpExampleLine(command: string, description: string): str
|
|||||||
return ` ${theme.command(command)} ${theme.muted(`# ${description}`)}`;
|
return ` ${theme.command(command)} ${theme.muted(`# ${description}`)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatHelpExamples(examples: readonly HelpExample[], inline = false): string {
|
export function formatHelpExamples(examples: ReadonlyArray<HelpExample>, inline = false): string {
|
||||||
const formatter = inline ? formatHelpExampleLine : formatHelpExample;
|
const formatter = inline ? formatHelpExampleLine : formatHelpExample;
|
||||||
return examples.map(([command, description]) => formatter(command, description)).join("\n");
|
return examples.map(([command, description]) => formatter(command, description)).join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatHelpExampleGroup(
|
export function formatHelpExampleGroup(
|
||||||
label: string,
|
label: string,
|
||||||
examples: readonly HelpExample[],
|
examples: ReadonlyArray<HelpExample>,
|
||||||
inline = false,
|
inline = false,
|
||||||
) {
|
) {
|
||||||
return `${theme.muted(label)}\n${formatHelpExamples(examples, inline)}`;
|
return `${theme.muted(label)}\n${formatHelpExamples(examples, inline)}`;
|
||||||
|
|||||||
@@ -46,12 +46,13 @@ function formatNodeVersions(node: {
|
|||||||
|
|
||||||
function parseSinceMs(raw: unknown, label: string): number | undefined {
|
function parseSinceMs(raw: unknown, label: string): number | undefined {
|
||||||
if (raw === undefined || raw === null) return undefined;
|
if (raw === undefined || raw === null) return undefined;
|
||||||
if (typeof raw !== "string" && typeof raw !== "number" && typeof raw !== "bigint") {
|
const value =
|
||||||
defaultRuntime.error(`${label}: invalid duration`);
|
typeof raw === "string" ? raw.trim() : typeof raw === "number" ? String(raw).trim() : null;
|
||||||
|
if (value === null) {
|
||||||
|
defaultRuntime.error(`${label}: invalid duration value`);
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const value = String(raw).trim();
|
|
||||||
if (!value) return undefined;
|
if (!value) return undefined;
|
||||||
try {
|
try {
|
||||||
return parseDurationMs(value);
|
return parseDurationMs(value);
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ describe("pairing cli", () => {
|
|||||||
await program.parseAsync(["pairing", "list", "--channel", "telegram"], {
|
await program.parseAsync(["pairing", "list", "--channel", "telegram"], {
|
||||||
from: "user",
|
from: "user",
|
||||||
});
|
});
|
||||||
const output = log.mock.calls.map(([value]) => String(value)).join("\n");
|
const output = log.mock.calls.map((call) => call.join(" ")).join("\n");
|
||||||
expect(output).toContain("telegramUserId");
|
expect(output).toContain("telegramUserId");
|
||||||
expect(output).toContain("123");
|
expect(output).toContain("123");
|
||||||
});
|
});
|
||||||
@@ -133,7 +133,7 @@ describe("pairing cli", () => {
|
|||||||
await program.parseAsync(["pairing", "list", "--channel", "discord"], {
|
await program.parseAsync(["pairing", "list", "--channel", "discord"], {
|
||||||
from: "user",
|
from: "user",
|
||||||
});
|
});
|
||||||
const output = log.mock.calls.map(([value]) => String(value)).join("\n");
|
const output = log.mock.calls.map((call) => call.join(" ")).join("\n");
|
||||||
expect(output).toContain("discordUserId");
|
expect(output).toContain("discordUserId");
|
||||||
expect(output).toContain("999");
|
expect(output).toContain("999");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -454,6 +454,10 @@ export function registerPluginsCli(program: Command) {
|
|||||||
const targets = opts.all ? Object.keys(installs) : id ? [id] : [];
|
const targets = opts.all ? Object.keys(installs) : id ? [id] : [];
|
||||||
|
|
||||||
if (targets.length === 0) {
|
if (targets.length === 0) {
|
||||||
|
if (opts.all) {
|
||||||
|
defaultRuntime.log("No npm-installed plugins to update.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
defaultRuntime.error("Provide a plugin id or use --all.");
|
defaultRuntime.error("Provide a plugin id or use --all.");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { emitCliBanner } from "../banner.js";
|
|||||||
import { getCommandPath, getVerboseFlag, hasHelpOrVersion } from "../argv.js";
|
import { getCommandPath, getVerboseFlag, hasHelpOrVersion } from "../argv.js";
|
||||||
import { ensureConfigReady } from "./config-guard.js";
|
import { ensureConfigReady } from "./config-guard.js";
|
||||||
import { ensurePluginRegistryLoaded } from "../plugin-registry.js";
|
import { ensurePluginRegistryLoaded } from "../plugin-registry.js";
|
||||||
|
import { isTruthyEnvValue } from "../../infra/env.js";
|
||||||
import { setVerbose } from "../../globals.js";
|
import { setVerbose } from "../../globals.js";
|
||||||
|
|
||||||
function setProcessTitleForCommand(actionCommand: Command) {
|
function setProcessTitleForCommand(actionCommand: Command) {
|
||||||
@@ -22,15 +23,21 @@ const PLUGIN_REQUIRED_COMMANDS = new Set(["message", "channels", "directory"]);
|
|||||||
export function registerPreActionHooks(program: Command, programVersion: string) {
|
export function registerPreActionHooks(program: Command, programVersion: string) {
|
||||||
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||||
setProcessTitleForCommand(actionCommand);
|
setProcessTitleForCommand(actionCommand);
|
||||||
emitCliBanner(programVersion);
|
|
||||||
const argv = process.argv;
|
const argv = process.argv;
|
||||||
if (hasHelpOrVersion(argv)) return;
|
if (hasHelpOrVersion(argv)) return;
|
||||||
|
const commandPath = getCommandPath(argv, 2);
|
||||||
|
const hideBanner =
|
||||||
|
isTruthyEnvValue(process.env.CLAWDBOT_HIDE_BANNER) ||
|
||||||
|
commandPath[0] === "update" ||
|
||||||
|
(commandPath[0] === "plugins" && commandPath[1] === "update");
|
||||||
|
if (!hideBanner) {
|
||||||
|
emitCliBanner(programVersion);
|
||||||
|
}
|
||||||
const verbose = getVerboseFlag(argv, { includeDebug: true });
|
const verbose = getVerboseFlag(argv, { includeDebug: true });
|
||||||
setVerbose(verbose);
|
setVerbose(verbose);
|
||||||
if (!verbose) {
|
if (!verbose) {
|
||||||
process.env.NODE_NO_WARNINGS ??= "1";
|
process.env.NODE_NO_WARNINGS ??= "1";
|
||||||
}
|
}
|
||||||
const commandPath = getCommandPath(argv, 2);
|
|
||||||
if (commandPath[0] === "doctor") return;
|
if (commandPath[0] === "doctor") return;
|
||||||
await ensureConfigReady({ runtime: defaultRuntime, commandPath });
|
await ensureConfigReady({ runtime: defaultRuntime, commandPath });
|
||||||
// Load plugins for commands that need channel access
|
// Load plugins for commands that need channel access
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ vi.mock("../infra/update-check.js", async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("../process/exec.js", () => ({
|
||||||
|
runCommandWithTimeout: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock doctor (heavy module; should not run in unit tests)
|
// Mock doctor (heavy module; should not run in unit tests)
|
||||||
vi.mock("../commands/doctor.js", () => ({
|
vi.mock("../commands/doctor.js", () => ({
|
||||||
doctorCommand: vi.fn(),
|
doctorCommand: vi.fn(),
|
||||||
@@ -76,6 +80,7 @@ describe("update-cli", () => {
|
|||||||
const { readConfigFileSnapshot } = await import("../config/config.js");
|
const { readConfigFileSnapshot } = await import("../config/config.js");
|
||||||
const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } =
|
const { checkUpdateStatus, fetchNpmTagVersion, resolveNpmChannelTag } =
|
||||||
await import("../infra/update-check.js");
|
await import("../infra/update-check.js");
|
||||||
|
const { runCommandWithTimeout } = await import("../process/exec.js");
|
||||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd());
|
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(process.cwd());
|
||||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot);
|
vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot);
|
||||||
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
|
vi.mocked(fetchNpmTagVersion).mockResolvedValue({
|
||||||
@@ -111,6 +116,13 @@ describe("update-cli", () => {
|
|||||||
latestVersion: "1.2.3",
|
latestVersion: "1.2.3",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
vi.mocked(runCommandWithTimeout).mockResolvedValue({
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
code: 0,
|
||||||
|
signal: null,
|
||||||
|
killed: false,
|
||||||
|
});
|
||||||
setTty(false);
|
setTty(false);
|
||||||
setStdoutTty(false);
|
setStdoutTty(false);
|
||||||
});
|
});
|
||||||
@@ -202,9 +214,21 @@ describe("update-cli", () => {
|
|||||||
|
|
||||||
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
||||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
|
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||||
const { updateCommand } = await import("./update-cli.js");
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
|
|
||||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||||
|
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||||
|
root: tempDir,
|
||||||
|
installKind: "package",
|
||||||
|
packageManager: "npm",
|
||||||
|
deps: {
|
||||||
|
manager: "npm",
|
||||||
|
status: "ok",
|
||||||
|
lockfilePath: null,
|
||||||
|
markerPath: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
mode: "npm",
|
mode: "npm",
|
||||||
@@ -258,12 +282,24 @@ describe("update-cli", () => {
|
|||||||
const { resolveNpmChannelTag } = await import("../infra/update-check.js");
|
const { resolveNpmChannelTag } = await import("../infra/update-check.js");
|
||||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
const { updateCommand } = await import("./update-cli.js");
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
|
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||||
|
|
||||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
||||||
...baseSnapshot,
|
...baseSnapshot,
|
||||||
config: { update: { channel: "beta" } },
|
config: { update: { channel: "beta" } },
|
||||||
});
|
});
|
||||||
|
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||||
|
root: tempDir,
|
||||||
|
installKind: "package",
|
||||||
|
packageManager: "npm",
|
||||||
|
deps: {
|
||||||
|
manager: "npm",
|
||||||
|
status: "ok",
|
||||||
|
lockfilePath: null,
|
||||||
|
markerPath: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||||
tag: "latest",
|
tag: "latest",
|
||||||
version: "2026.1.20-1",
|
version: "2026.1.20-1",
|
||||||
@@ -459,8 +495,20 @@ describe("update-cli", () => {
|
|||||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
const { defaultRuntime } = await import("../runtime.js");
|
||||||
const { updateCommand } = await import("./update-cli.js");
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
|
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||||
|
|
||||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||||
|
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||||
|
root: tempDir,
|
||||||
|
installKind: "package",
|
||||||
|
packageManager: "npm",
|
||||||
|
deps: {
|
||||||
|
manager: "npm",
|
||||||
|
status: "ok",
|
||||||
|
lockfilePath: null,
|
||||||
|
markerPath: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||||
tag: "latest",
|
tag: "latest",
|
||||||
version: "0.0.1",
|
version: "0.0.1",
|
||||||
@@ -500,8 +548,20 @@ describe("update-cli", () => {
|
|||||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||||
const { defaultRuntime } = await import("../runtime.js");
|
const { defaultRuntime } = await import("../runtime.js");
|
||||||
const { updateCommand } = await import("./update-cli.js");
|
const { updateCommand } = await import("./update-cli.js");
|
||||||
|
const { checkUpdateStatus } = await import("../infra/update-check.js");
|
||||||
|
|
||||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||||
|
vi.mocked(checkUpdateStatus).mockResolvedValue({
|
||||||
|
root: tempDir,
|
||||||
|
installKind: "package",
|
||||||
|
packageManager: "npm",
|
||||||
|
deps: {
|
||||||
|
manager: "npm",
|
||||||
|
status: "ok",
|
||||||
|
lockfilePath: null,
|
||||||
|
markerPath: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||||
tag: "latest",
|
tag: "latest",
|
||||||
version: "0.0.1",
|
version: "0.0.1",
|
||||||
|
|||||||
+283
-13
@@ -1,5 +1,6 @@
|
|||||||
import { confirm, isCancel, spinner } from "@clack/prompts";
|
import { confirm, isCancel, spinner } from "@clack/prompts";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
|
||||||
@@ -16,8 +17,16 @@ import {
|
|||||||
runGatewayUpdate,
|
runGatewayUpdate,
|
||||||
type UpdateRunResult,
|
type UpdateRunResult,
|
||||||
type UpdateStepInfo,
|
type UpdateStepInfo,
|
||||||
|
type UpdateStepResult,
|
||||||
type UpdateStepProgress,
|
type UpdateStepProgress,
|
||||||
} from "../infra/update-runner.js";
|
} from "../infra/update-runner.js";
|
||||||
|
import {
|
||||||
|
detectGlobalInstallManagerByPresence,
|
||||||
|
detectGlobalInstallManagerForRoot,
|
||||||
|
globalInstallArgs,
|
||||||
|
resolveGlobalPackageRoot,
|
||||||
|
type GlobalInstallManager,
|
||||||
|
} from "../infra/update-global.js";
|
||||||
import {
|
import {
|
||||||
channelToNpmTag,
|
channelToNpmTag,
|
||||||
DEFAULT_GIT_CHANNEL,
|
DEFAULT_GIT_CHANNEL,
|
||||||
@@ -26,6 +35,7 @@ import {
|
|||||||
normalizeUpdateChannel,
|
normalizeUpdateChannel,
|
||||||
resolveEffectiveUpdateChannel,
|
resolveEffectiveUpdateChannel,
|
||||||
} from "../infra/update-channels.js";
|
} from "../infra/update-channels.js";
|
||||||
|
import { trimLogTail } from "../infra/restart-sentinel.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { formatCliCommand } from "./command-format.js";
|
import { formatCliCommand } from "./command-format.js";
|
||||||
@@ -39,6 +49,7 @@ import {
|
|||||||
resolveUpdateAvailability,
|
resolveUpdateAvailability,
|
||||||
} from "../commands/status.update.js";
|
} from "../commands/status.update.js";
|
||||||
import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../plugins/update.js";
|
import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../plugins/update.js";
|
||||||
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
|
|
||||||
export type UpdateCommandOptions = {
|
export type UpdateCommandOptions = {
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
@@ -58,12 +69,14 @@ const STEP_LABELS: Record<string, string> = {
|
|||||||
"upstream check": "Upstream branch exists",
|
"upstream check": "Upstream branch exists",
|
||||||
"git fetch": "Fetching latest changes",
|
"git fetch": "Fetching latest changes",
|
||||||
"git rebase": "Rebasing onto upstream",
|
"git rebase": "Rebasing onto upstream",
|
||||||
|
"git clone": "Cloning git checkout",
|
||||||
"deps install": "Installing dependencies",
|
"deps install": "Installing dependencies",
|
||||||
build: "Building",
|
build: "Building",
|
||||||
"ui:build": "Building UI",
|
"ui:build": "Building UI",
|
||||||
"clawdbot doctor": "Running doctor checks",
|
"clawdbot doctor": "Running doctor checks",
|
||||||
"git rev-parse HEAD (after)": "Verifying update",
|
"git rev-parse HEAD (after)": "Verifying update",
|
||||||
"global update": "Updating via package manager",
|
"global update": "Updating via package manager",
|
||||||
|
"global install": "Installing global package",
|
||||||
};
|
};
|
||||||
|
|
||||||
const UPDATE_QUIPS = [
|
const UPDATE_QUIPS = [
|
||||||
@@ -89,6 +102,10 @@ const UPDATE_QUIPS = [
|
|||||||
"Version bump! Same chaos energy, fewer crashes (probably).",
|
"Version bump! Same chaos energy, fewer crashes (probably).",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const MAX_LOG_CHARS = 8000;
|
||||||
|
const CLAWDBOT_REPO_URL = "https://github.com/clawdbot/clawdbot.git";
|
||||||
|
const DEFAULT_GIT_DIR = path.join(os.homedir(), "clawdbot");
|
||||||
|
|
||||||
function normalizeTag(value?: string | null): string | null {
|
function normalizeTag(value?: string | null): string | null {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -133,6 +150,146 @@ async function isGitCheckout(root: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isClawdbotPackage(root: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const raw = await fs.readFile(path.join(root, "package.json"), "utf-8");
|
||||||
|
const parsed = JSON.parse(raw) as { name?: string };
|
||||||
|
return parsed?.name === "clawdbot";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(targetPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.stat(targetPath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isEmptyDir(targetPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(targetPath);
|
||||||
|
return entries.length === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveGitInstallDir(): string {
|
||||||
|
const override = process.env.CLAWDBOT_GIT_DIR?.trim();
|
||||||
|
if (override) return path.resolve(override);
|
||||||
|
return DEFAULT_GIT_DIR;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNodeRunner(): string {
|
||||||
|
const base = path.basename(process.execPath).toLowerCase();
|
||||||
|
if (base === "node" || base === "node.exe") return process.execPath;
|
||||||
|
return "node";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runUpdateStep(params: {
|
||||||
|
name: string;
|
||||||
|
argv: string[];
|
||||||
|
cwd?: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
progress?: UpdateStepProgress;
|
||||||
|
}): Promise<UpdateStepResult> {
|
||||||
|
const command = params.argv.join(" ");
|
||||||
|
params.progress?.onStepStart?.({
|
||||||
|
name: params.name,
|
||||||
|
command,
|
||||||
|
index: 0,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
const started = Date.now();
|
||||||
|
const res = await runCommandWithTimeout(params.argv, {
|
||||||
|
cwd: params.cwd,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
});
|
||||||
|
const durationMs = Date.now() - started;
|
||||||
|
const stderrTail = trimLogTail(res.stderr, MAX_LOG_CHARS);
|
||||||
|
params.progress?.onStepComplete?.({
|
||||||
|
name: params.name,
|
||||||
|
command,
|
||||||
|
index: 0,
|
||||||
|
total: 0,
|
||||||
|
durationMs,
|
||||||
|
exitCode: res.code,
|
||||||
|
stderrTail,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
name: params.name,
|
||||||
|
command,
|
||||||
|
cwd: params.cwd ?? process.cwd(),
|
||||||
|
durationMs,
|
||||||
|
exitCode: res.code,
|
||||||
|
stdoutTail: trimLogTail(res.stdout, MAX_LOG_CHARS),
|
||||||
|
stderrTail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureGitCheckout(params: {
|
||||||
|
dir: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
progress?: UpdateStepProgress;
|
||||||
|
}): Promise<UpdateStepResult | null> {
|
||||||
|
const dirExists = await pathExists(params.dir);
|
||||||
|
if (!dirExists) {
|
||||||
|
return await runUpdateStep({
|
||||||
|
name: "git clone",
|
||||||
|
argv: ["git", "clone", CLAWDBOT_REPO_URL, params.dir],
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
progress: params.progress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await isGitCheckout(params.dir))) {
|
||||||
|
const empty = await isEmptyDir(params.dir);
|
||||||
|
if (!empty) {
|
||||||
|
throw new Error(
|
||||||
|
`CLAWDBOT_GIT_DIR points at a non-git directory: ${params.dir}. Set CLAWDBOT_GIT_DIR to an empty folder or a clawdbot checkout.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await runUpdateStep({
|
||||||
|
name: "git clone",
|
||||||
|
argv: ["git", "clone", CLAWDBOT_REPO_URL, params.dir],
|
||||||
|
cwd: params.dir,
|
||||||
|
timeoutMs: params.timeoutMs,
|
||||||
|
progress: params.progress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await isClawdbotPackage(params.dir))) {
|
||||||
|
throw new Error(`CLAWDBOT_GIT_DIR does not look like a clawdbot checkout: ${params.dir}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveGlobalManager(params: {
|
||||||
|
root: string;
|
||||||
|
installKind: "git" | "package" | "unknown";
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<GlobalInstallManager> {
|
||||||
|
const runCommand = async (argv: string[], options: { timeoutMs: number }) => {
|
||||||
|
const res = await runCommandWithTimeout(argv, options);
|
||||||
|
return { stdout: res.stdout, stderr: res.stderr, code: res.code };
|
||||||
|
};
|
||||||
|
if (params.installKind === "package") {
|
||||||
|
const detected = await detectGlobalInstallManagerForRoot(
|
||||||
|
runCommand,
|
||||||
|
params.root,
|
||||||
|
params.timeoutMs,
|
||||||
|
);
|
||||||
|
if (detected) return detected;
|
||||||
|
}
|
||||||
|
const byPresence = await detectGlobalInstallManagerByPresence(runCommand, params.timeoutMs);
|
||||||
|
return byPresence ?? "npm";
|
||||||
|
}
|
||||||
|
|
||||||
function formatGitStatusLine(params: {
|
function formatGitStatusLine(params: {
|
||||||
branch: string | null;
|
branch: string | null;
|
||||||
tag: string | null;
|
tag: string | null;
|
||||||
@@ -394,6 +551,13 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
})) ?? process.cwd();
|
})) ?? process.cwd();
|
||||||
|
|
||||||
|
const updateStatus = await checkUpdateStatus({
|
||||||
|
root,
|
||||||
|
timeoutMs: timeoutMs ?? 3500,
|
||||||
|
fetchGit: false,
|
||||||
|
includeRegistry: false,
|
||||||
|
});
|
||||||
|
|
||||||
const configSnapshot = await readConfigFileSnapshot();
|
const configSnapshot = await readConfigFileSnapshot();
|
||||||
let activeConfig = configSnapshot.valid ? configSnapshot.config : null;
|
let activeConfig = configSnapshot.valid ? configSnapshot.config : null;
|
||||||
const storedChannel = configSnapshot.valid
|
const storedChannel = configSnapshot.valid
|
||||||
@@ -413,13 +577,18 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gitCheckout = await isGitCheckout(root);
|
const installKind = updateStatus.installKind;
|
||||||
const defaultChannel = gitCheckout ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL;
|
const switchToGit = requestedChannel === "dev" && installKind !== "git";
|
||||||
|
const switchToPackage =
|
||||||
|
requestedChannel !== null && requestedChannel !== "dev" && installKind === "git";
|
||||||
|
const updateInstallKind = switchToGit ? "git" : switchToPackage ? "package" : installKind;
|
||||||
|
const defaultChannel =
|
||||||
|
updateInstallKind === "git" ? DEFAULT_GIT_CHANNEL : DEFAULT_PACKAGE_CHANNEL;
|
||||||
const channel = requestedChannel ?? storedChannel ?? defaultChannel;
|
const channel = requestedChannel ?? storedChannel ?? defaultChannel;
|
||||||
const explicitTag = normalizeTag(opts.tag);
|
const explicitTag = normalizeTag(opts.tag);
|
||||||
let tag = explicitTag ?? channelToNpmTag(channel);
|
let tag = explicitTag ?? channelToNpmTag(channel);
|
||||||
if (!gitCheckout) {
|
if (updateInstallKind !== "git") {
|
||||||
const currentVersion = await readPackageVersion(root);
|
const currentVersion = switchToPackage ? null : await readPackageVersion(root);
|
||||||
const targetVersion = explicitTag
|
const targetVersion = explicitTag
|
||||||
? await resolveTargetVersion(tag, timeoutMs)
|
? await resolveTargetVersion(tag, timeoutMs)
|
||||||
: await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
|
: await resolveNpmChannelTag({ channel, timeoutMs }).then((resolved) => {
|
||||||
@@ -487,14 +656,114 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
|
|
||||||
const { progress, stop } = createUpdateProgress(showProgress);
|
const { progress, stop } = createUpdateProgress(showProgress);
|
||||||
|
|
||||||
const result = await runGatewayUpdate({
|
const startedAt = Date.now();
|
||||||
cwd: root,
|
let result: UpdateRunResult;
|
||||||
argv1: process.argv[1],
|
|
||||||
timeoutMs,
|
if (switchToPackage) {
|
||||||
progress,
|
const manager = await resolveGlobalManager({
|
||||||
channel,
|
root,
|
||||||
tag,
|
installKind,
|
||||||
});
|
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||||
|
});
|
||||||
|
const runCommand = async (argv: string[], options: { timeoutMs: number }) => {
|
||||||
|
const res = await runCommandWithTimeout(argv, options);
|
||||||
|
return { stdout: res.stdout, stderr: res.stderr, code: res.code };
|
||||||
|
};
|
||||||
|
const pkgRoot = await resolveGlobalPackageRoot(manager, runCommand, timeoutMs ?? 20 * 60_000);
|
||||||
|
const beforeVersion = pkgRoot ? await readPackageVersion(pkgRoot) : null;
|
||||||
|
const updateStep = await runUpdateStep({
|
||||||
|
name: "global update",
|
||||||
|
argv: globalInstallArgs(manager, `clawdbot@${tag}`),
|
||||||
|
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||||
|
progress,
|
||||||
|
});
|
||||||
|
const steps = [updateStep];
|
||||||
|
let afterVersion = beforeVersion;
|
||||||
|
if (pkgRoot) {
|
||||||
|
afterVersion = await readPackageVersion(pkgRoot);
|
||||||
|
const entryPath = path.join(pkgRoot, "dist", "entry.js");
|
||||||
|
if (await pathExists(entryPath)) {
|
||||||
|
const doctorStep = await runUpdateStep({
|
||||||
|
name: "clawdbot doctor",
|
||||||
|
argv: [resolveNodeRunner(), entryPath, "doctor", "--non-interactive"],
|
||||||
|
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||||
|
progress,
|
||||||
|
});
|
||||||
|
steps.push(doctorStep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const failedStep = steps.find((step) => step.exitCode !== 0);
|
||||||
|
result = {
|
||||||
|
status: failedStep ? "error" : "ok",
|
||||||
|
mode: manager,
|
||||||
|
root: pkgRoot ?? root,
|
||||||
|
reason: failedStep ? failedStep.name : undefined,
|
||||||
|
before: { version: beforeVersion },
|
||||||
|
after: { version: afterVersion },
|
||||||
|
steps,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const updateRoot = switchToGit ? resolveGitInstallDir() : root;
|
||||||
|
const cloneStep = switchToGit
|
||||||
|
? await ensureGitCheckout({
|
||||||
|
dir: updateRoot,
|
||||||
|
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||||
|
progress,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (cloneStep && cloneStep.exitCode !== 0) {
|
||||||
|
result = {
|
||||||
|
status: "error",
|
||||||
|
mode: "git",
|
||||||
|
root: updateRoot,
|
||||||
|
reason: cloneStep.name,
|
||||||
|
steps: [cloneStep],
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
stop();
|
||||||
|
printResult(result, { ...opts, hideSteps: showProgress });
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const updateResult = await runGatewayUpdate({
|
||||||
|
cwd: updateRoot,
|
||||||
|
argv1: switchToGit ? undefined : process.argv[1],
|
||||||
|
timeoutMs,
|
||||||
|
progress,
|
||||||
|
channel,
|
||||||
|
tag,
|
||||||
|
});
|
||||||
|
const steps = [...(cloneStep ? [cloneStep] : []), ...updateResult.steps];
|
||||||
|
if (switchToGit && updateResult.status === "ok") {
|
||||||
|
const manager = await resolveGlobalManager({
|
||||||
|
root,
|
||||||
|
installKind,
|
||||||
|
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||||
|
});
|
||||||
|
const installStep = await runUpdateStep({
|
||||||
|
name: "global install",
|
||||||
|
argv: globalInstallArgs(manager, updateRoot),
|
||||||
|
cwd: updateRoot,
|
||||||
|
timeoutMs: timeoutMs ?? 20 * 60_000,
|
||||||
|
progress,
|
||||||
|
});
|
||||||
|
steps.push(installStep);
|
||||||
|
const failedStep = [installStep].find((step) => step.exitCode !== 0);
|
||||||
|
result = {
|
||||||
|
...updateResult,
|
||||||
|
status: updateResult.status === "ok" && !failedStep ? "ok" : "error",
|
||||||
|
steps,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
result = {
|
||||||
|
...updateResult,
|
||||||
|
steps,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stop();
|
stop();
|
||||||
|
|
||||||
@@ -623,7 +892,8 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
|||||||
process.env.CLAWDBOT_UPDATE_IN_PROGRESS = "1";
|
process.env.CLAWDBOT_UPDATE_IN_PROGRESS = "1";
|
||||||
try {
|
try {
|
||||||
const { doctorCommand } = await import("../commands/doctor.js");
|
const { doctorCommand } = await import("../commands/doctor.js");
|
||||||
await doctorCommand(defaultRuntime, { nonInteractive: true });
|
const interactiveDoctor = Boolean(process.stdin.isTTY) && !opts.json && opts.yes !== true;
|
||||||
|
await doctorCommand(defaultRuntime, { nonInteractive: !interactiveDoctor });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.log(theme.warn(`Doctor failed: ${String(err)}`));
|
defaultRuntime.log(theme.warn(`Doctor failed: ${String(err)}`));
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+16
-13
@@ -50,6 +50,8 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
|||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
import { applyVerboseOverride } from "../sessions/level-overrides.js";
|
import { applyVerboseOverride } from "../sessions/level-overrides.js";
|
||||||
import { resolveSendPolicy } from "../sessions/send-policy.js";
|
import { resolveSendPolicy } from "../sessions/send-policy.js";
|
||||||
|
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
|
||||||
|
import { clearSessionAuthProfileOverride } from "../agents/auth-profiles/session-override.js";
|
||||||
import { resolveMessageChannel } from "../utils/message-channel.js";
|
import { resolveMessageChannel } from "../utils/message-channel.js";
|
||||||
import { deliverAgentCommandResult } from "./agent/delivery.js";
|
import { deliverAgentCommandResult } from "./agent/delivery.js";
|
||||||
import { resolveAgentRunContext } from "./agent/run-context.js";
|
import { resolveAgentRunContext } from "./agent/run-context.js";
|
||||||
@@ -283,13 +285,16 @@ export async function agentCommand(
|
|||||||
allowedModelKeys.size > 0 &&
|
allowedModelKeys.size > 0 &&
|
||||||
!allowedModelKeys.has(key)
|
!allowedModelKeys.has(key)
|
||||||
) {
|
) {
|
||||||
delete entry.providerOverride;
|
const { updated } = applyModelOverrideToSessionEntry({
|
||||||
delete entry.modelOverride;
|
entry,
|
||||||
entry.updatedAt = Date.now();
|
selection: { provider: defaultProvider, model: defaultModel, isDefault: true },
|
||||||
sessionStore[sessionKey] = entry;
|
|
||||||
await updateSessionStore(storePath, (store) => {
|
|
||||||
store[sessionKey] = entry;
|
|
||||||
});
|
});
|
||||||
|
if (updated) {
|
||||||
|
sessionStore[sessionKey] = entry;
|
||||||
|
await updateSessionStore(storePath, (store) => {
|
||||||
|
store[sessionKey] = entry;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,14 +320,12 @@ export async function agentCommand(
|
|||||||
const store = ensureAuthProfileStore();
|
const store = ensureAuthProfileStore();
|
||||||
const profile = store.profiles[authProfileId];
|
const profile = store.profiles[authProfileId];
|
||||||
if (!profile || profile.provider !== provider) {
|
if (!profile || profile.provider !== provider) {
|
||||||
delete entry.authProfileOverride;
|
|
||||||
delete entry.authProfileOverrideSource;
|
|
||||||
delete entry.authProfileOverrideCompactionCount;
|
|
||||||
entry.updatedAt = Date.now();
|
|
||||||
if (sessionStore && sessionKey) {
|
if (sessionStore && sessionKey) {
|
||||||
sessionStore[sessionKey] = entry;
|
await clearSessionAuthProfileOverride({
|
||||||
await updateSessionStore(storePath, (store) => {
|
sessionEntry: entry,
|
||||||
store[sessionKey] = entry;
|
sessionStore,
|
||||||
|
sessionKey,
|
||||||
|
storePath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,10 +121,14 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
|||||||
options: DoctorOptions;
|
options: DoctorOptions;
|
||||||
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
|
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
void params.confirm;
|
|
||||||
const shouldRepair = params.options.repair === true || params.options.yes === true;
|
const shouldRepair = params.options.repair === true || params.options.yes === true;
|
||||||
const snapshot = await readConfigFileSnapshot();
|
const snapshot = await readConfigFileSnapshot();
|
||||||
let cfg: ClawdbotConfig = snapshot.config ?? {};
|
const baseCfg = snapshot.config ?? {};
|
||||||
|
let cfg: ClawdbotConfig = baseCfg;
|
||||||
|
let candidate = structuredClone(baseCfg) as ClawdbotConfig;
|
||||||
|
let pendingChanges = false;
|
||||||
|
let shouldWriteConfig = false;
|
||||||
|
const fixHints: string[] = [];
|
||||||
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
|
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
|
||||||
note("Config invalid; doctor will run with best-effort config.", "Config");
|
note("Config invalid; doctor will run with best-effort config.", "Config");
|
||||||
}
|
}
|
||||||
@@ -139,52 +143,76 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
|||||||
snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"),
|
snapshot.legacyIssues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"),
|
||||||
"Legacy config keys detected",
|
"Legacy config keys detected",
|
||||||
);
|
);
|
||||||
|
const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed);
|
||||||
|
if (changes.length > 0) {
|
||||||
|
note(changes.join("\n"), "Doctor changes");
|
||||||
|
}
|
||||||
|
if (migrated) {
|
||||||
|
candidate = migrated;
|
||||||
|
pendingChanges = pendingChanges || changes.length > 0;
|
||||||
|
}
|
||||||
if (shouldRepair) {
|
if (shouldRepair) {
|
||||||
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into channels.whatsapp.allowFrom.
|
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into channels.whatsapp.allowFrom.
|
||||||
const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed);
|
|
||||||
if (changes.length > 0) note(changes.join("\n"), "Doctor changes");
|
|
||||||
if (migrated) cfg = migrated;
|
if (migrated) cfg = migrated;
|
||||||
} else {
|
} else {
|
||||||
note(
|
fixHints.push(
|
||||||
`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply legacy migrations.`,
|
`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply legacy migrations.`,
|
||||||
"Doctor",
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = normalizeLegacyConfigValues(cfg);
|
const normalized = normalizeLegacyConfigValues(candidate);
|
||||||
if (normalized.changes.length > 0) {
|
if (normalized.changes.length > 0) {
|
||||||
note(normalized.changes.join("\n"), "Doctor changes");
|
note(normalized.changes.join("\n"), "Doctor changes");
|
||||||
|
candidate = normalized.config;
|
||||||
|
pendingChanges = true;
|
||||||
if (shouldRepair) {
|
if (shouldRepair) {
|
||||||
cfg = normalized.config;
|
cfg = normalized.config;
|
||||||
} else {
|
} else {
|
||||||
note(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply these changes.`, "Doctor");
|
fixHints.push(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply these changes.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoEnable = applyPluginAutoEnable({ config: cfg, env: process.env });
|
const autoEnable = applyPluginAutoEnable({ config: candidate, env: process.env });
|
||||||
if (autoEnable.changes.length > 0) {
|
if (autoEnable.changes.length > 0) {
|
||||||
note(autoEnable.changes.join("\n"), "Doctor changes");
|
note(autoEnable.changes.join("\n"), "Doctor changes");
|
||||||
|
candidate = autoEnable.config;
|
||||||
|
pendingChanges = true;
|
||||||
if (shouldRepair) {
|
if (shouldRepair) {
|
||||||
cfg = autoEnable.config;
|
cfg = autoEnable.config;
|
||||||
} else {
|
} else {
|
||||||
note(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply these changes.`, "Doctor");
|
fixHints.push(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply these changes.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const unknown = stripUnknownConfigKeys(cfg);
|
const unknown = stripUnknownConfigKeys(candidate);
|
||||||
if (unknown.removed.length > 0) {
|
if (unknown.removed.length > 0) {
|
||||||
const lines = unknown.removed.map((path) => `- ${path}`).join("\n");
|
const lines = unknown.removed.map((path) => `- ${path}`).join("\n");
|
||||||
|
candidate = unknown.config;
|
||||||
|
pendingChanges = true;
|
||||||
if (shouldRepair) {
|
if (shouldRepair) {
|
||||||
cfg = unknown.config;
|
cfg = unknown.config;
|
||||||
note(lines, "Doctor changes");
|
note(lines, "Doctor changes");
|
||||||
} else {
|
} else {
|
||||||
note(lines, "Unknown config keys");
|
note(lines, "Unknown config keys");
|
||||||
note('Run "clawdbot doctor --fix" to remove these keys.', "Doctor");
|
fixHints.push('Run "clawdbot doctor --fix" to remove these keys.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldRepair && pendingChanges) {
|
||||||
|
const shouldApply = await params.confirm({
|
||||||
|
message: "Apply recommended config repairs now?",
|
||||||
|
initialValue: true,
|
||||||
|
});
|
||||||
|
if (shouldApply) {
|
||||||
|
cfg = candidate;
|
||||||
|
shouldWriteConfig = true;
|
||||||
|
} else if (fixHints.length > 0) {
|
||||||
|
note(fixHints.join("\n"), "Doctor");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
noteOpencodeProviderOverrides(cfg);
|
noteOpencodeProviderOverrides(cfg);
|
||||||
|
|
||||||
return { cfg, path: snapshot.path ?? CONFIG_PATH_CLAWDBOT };
|
return { cfg, path: snapshot.path ?? CONFIG_PATH_CLAWDBOT, shouldWriteConfig };
|
||||||
}
|
}
|
||||||
@@ -250,7 +250,8 @@ export async function doctorCommand(
|
|||||||
healthOk,
|
healthOk,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (prompter.shouldRepair) {
|
const shouldWriteConfig = prompter.shouldRepair || configResult.shouldWriteConfig;
|
||||||
|
if (shouldWriteConfig) {
|
||||||
cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) });
|
cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) });
|
||||||
await writeConfigFile(cfg);
|
await writeConfigFile(cfg);
|
||||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const PROVIDERS = parseFilter(process.env.CLAWDBOT_LIVE_GATEWAY_PROVIDERS);
|
|||||||
const THINKING_LEVEL = "high";
|
const THINKING_LEVEL = "high";
|
||||||
const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\s*>/i;
|
const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\s*>/i;
|
||||||
const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/i;
|
const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/i;
|
||||||
|
const ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL = "ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL";
|
||||||
|
|
||||||
const describeLive = LIVE || GATEWAY_LIVE ? describe : describe.skip;
|
const describeLive = LIVE || GATEWAY_LIVE ? describe : describe.skip;
|
||||||
|
|
||||||
@@ -120,6 +121,73 @@ function isEmptyStreamText(text: string): boolean {
|
|||||||
return text.includes("request ended without sending any chunks");
|
return text.includes("request ended without sending any chunks");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildAnthropicRefusalToken(): string {
|
||||||
|
const suffix = randomUUID().replace(/-/g, "");
|
||||||
|
return `${ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL}_${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAnthropicRefusalProbe(params: {
|
||||||
|
client: GatewayClient;
|
||||||
|
sessionKey: string;
|
||||||
|
modelKey: string;
|
||||||
|
label: string;
|
||||||
|
thinkingLevel: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
logProgress(`${params.label}: refusal-probe`);
|
||||||
|
const magic = buildAnthropicRefusalToken();
|
||||||
|
const runId = randomUUID();
|
||||||
|
const probe = await params.client.request<AgentFinalPayload>(
|
||||||
|
"agent",
|
||||||
|
{
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
idempotencyKey: `idem-${runId}-refusal`,
|
||||||
|
message: `Reply with the single word ok. Test token: ${magic}`,
|
||||||
|
thinking: params.thinkingLevel,
|
||||||
|
deliver: false,
|
||||||
|
},
|
||||||
|
{ expectFinal: true },
|
||||||
|
);
|
||||||
|
if (probe?.status !== "ok") {
|
||||||
|
throw new Error(`refusal probe failed: status=${String(probe?.status)}`);
|
||||||
|
}
|
||||||
|
const probeText = extractPayloadText(probe?.result);
|
||||||
|
assertNoReasoningTags({
|
||||||
|
text: probeText,
|
||||||
|
model: params.modelKey,
|
||||||
|
phase: "refusal-probe",
|
||||||
|
label: params.label,
|
||||||
|
});
|
||||||
|
if (!/\bok\b/i.test(probeText)) {
|
||||||
|
throw new Error(`refusal probe missing ok: ${probeText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const followupId = randomUUID();
|
||||||
|
const followup = await params.client.request<AgentFinalPayload>(
|
||||||
|
"agent",
|
||||||
|
{
|
||||||
|
sessionKey: params.sessionKey,
|
||||||
|
idempotencyKey: `idem-${followupId}-refusal-followup`,
|
||||||
|
message: "Now reply with exactly: still ok.",
|
||||||
|
thinking: params.thinkingLevel,
|
||||||
|
deliver: false,
|
||||||
|
},
|
||||||
|
{ expectFinal: true },
|
||||||
|
);
|
||||||
|
if (followup?.status !== "ok") {
|
||||||
|
throw new Error(`refusal followup failed: status=${String(followup?.status)}`);
|
||||||
|
}
|
||||||
|
const followupText = extractPayloadText(followup?.result);
|
||||||
|
assertNoReasoningTags({
|
||||||
|
text: followupText,
|
||||||
|
model: params.modelKey,
|
||||||
|
phase: "refusal-followup",
|
||||||
|
label: params.label,
|
||||||
|
});
|
||||||
|
if (!/\bstill\b/i.test(followupText) || !/\bok\b/i.test(followupText)) {
|
||||||
|
throw new Error(`refusal followup missing expected text: ${followupText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function randomImageProbeCode(len = 6): string {
|
function randomImageProbeCode(len = 6): string {
|
||||||
// Chosen to avoid common OCR confusions in our 5x7 bitmap font.
|
// Chosen to avoid common OCR confusions in our 5x7 bitmap font.
|
||||||
// Notably: 0↔8, B↔8, 6↔9, 3↔B, D↔0.
|
// Notably: 0↔8, B↔8, 6↔9, 3↔B, D↔0.
|
||||||
@@ -736,6 +804,16 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (model.provider === "anthropic") {
|
||||||
|
await runAnthropicRefusalProbe({
|
||||||
|
client,
|
||||||
|
sessionKey,
|
||||||
|
modelKey,
|
||||||
|
label: progressLabel,
|
||||||
|
thinkingLevel: params.thinkingLevel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logProgress(`${progressLabel}: done`);
|
logProgress(`${progressLabel}: done`);
|
||||||
break;
|
break;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const GATEWAY_CLIENT_IDS = {
|
|||||||
CLI: "cli",
|
CLI: "cli",
|
||||||
GATEWAY_CLIENT: "gateway-client",
|
GATEWAY_CLIENT: "gateway-client",
|
||||||
MACOS_APP: "clawdbot-macos",
|
MACOS_APP: "clawdbot-macos",
|
||||||
|
IOS_APP: "clawdbot-ios",
|
||||||
|
ANDROID_APP: "clawdbot-android",
|
||||||
NODE_HOST: "node-host",
|
NODE_HOST: "node-host",
|
||||||
TEST: "test",
|
TEST: "test",
|
||||||
FINGERPRINT: "fingerprint",
|
FINGERPRINT: "fingerprint",
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { test } from "vitest";
|
||||||
|
import WebSocket from "ws";
|
||||||
|
|
||||||
|
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||||
|
import { getFreePort, onceMessage, startGatewayServer } from "./test-helpers.server.js";
|
||||||
|
|
||||||
|
function connectReq(
|
||||||
|
ws: WebSocket,
|
||||||
|
params: { clientId: string; platform: string; token?: string; password?: string },
|
||||||
|
): Promise<{ ok: boolean; error?: { message?: string } }> {
|
||||||
|
const id = `c-${Math.random().toString(16).slice(2)}`;
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "req",
|
||||||
|
id,
|
||||||
|
method: "connect",
|
||||||
|
params: {
|
||||||
|
minProtocol: PROTOCOL_VERSION,
|
||||||
|
maxProtocol: PROTOCOL_VERSION,
|
||||||
|
client: {
|
||||||
|
id: params.clientId,
|
||||||
|
version: "dev",
|
||||||
|
platform: params.platform,
|
||||||
|
mode: "node",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
token: params.token,
|
||||||
|
password: params.password,
|
||||||
|
},
|
||||||
|
role: "node",
|
||||||
|
scopes: [],
|
||||||
|
caps: ["canvas"],
|
||||||
|
commands: ["system.notify"],
|
||||||
|
permissions: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return onceMessage(
|
||||||
|
ws,
|
||||||
|
(o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("accepts clawdbot-ios as a valid gateway client id", async () => {
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startGatewayServer(port);
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
|
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||||
|
|
||||||
|
const res = await connectReq(ws, { clientId: "clawdbot-ios", platform: "ios" });
|
||||||
|
// We don't care if auth fails here; we only care that schema validation accepts the client id.
|
||||||
|
// A schema rejection would close the socket before sending a response.
|
||||||
|
if (!res.ok) {
|
||||||
|
// allow unauthorized error when gateway requires auth
|
||||||
|
// but reject schema validation errors
|
||||||
|
const message = String(res.error?.message ?? "");
|
||||||
|
if (message.includes("invalid connect params")) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts clawdbot-android as a valid gateway client id", async () => {
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startGatewayServer(port);
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
|
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||||
|
|
||||||
|
const res = await connectReq(ws, { clientId: "clawdbot-android", platform: "android" });
|
||||||
|
// We don't care if auth fails here; we only care that schema validation accepts the client id.
|
||||||
|
// A schema rejection would close the socket before sending a response.
|
||||||
|
if (!res.ok) {
|
||||||
|
// allow unauthorized error when gateway requires auth
|
||||||
|
// but reject schema validation errors
|
||||||
|
const message = String(res.error?.message ?? "");
|
||||||
|
if (message.includes("invalid connect params")) {
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
@@ -57,4 +57,32 @@ describe("gateway sessions patch", () => {
|
|||||||
if (res.ok) return;
|
if (res.ok) return;
|
||||||
expect(res.error.message).toContain("invalid elevatedLevel");
|
expect(res.error.message).toContain("invalid elevatedLevel");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("clears auth overrides when model patch changes", async () => {
|
||||||
|
const store: Record<string, SessionEntry> = {
|
||||||
|
"agent:main:main": {
|
||||||
|
sessionId: "sess",
|
||||||
|
updatedAt: 1,
|
||||||
|
providerOverride: "anthropic",
|
||||||
|
modelOverride: "claude-opus-4-5",
|
||||||
|
authProfileOverride: "anthropic:default",
|
||||||
|
authProfileOverrideSource: "user",
|
||||||
|
authProfileOverrideCompactionCount: 3,
|
||||||
|
} as SessionEntry,
|
||||||
|
};
|
||||||
|
const res = await applySessionsPatchToStore({
|
||||||
|
cfg: {} as ClawdbotConfig,
|
||||||
|
store,
|
||||||
|
storeKey: "agent:main:main",
|
||||||
|
patch: { model: "openai/gpt-5.2" },
|
||||||
|
loadGatewayModelCatalog: async () => [{ provider: "openai", id: "gpt-5.2" }],
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
if (!res.ok) return;
|
||||||
|
expect(res.entry.providerOverride).toBe("openai");
|
||||||
|
expect(res.entry.modelOverride).toBe("gpt-5.2");
|
||||||
|
expect(res.entry.authProfileOverride).toBeUndefined();
|
||||||
|
expect(res.entry.authProfileOverrideSource).toBeUndefined();
|
||||||
|
expect(res.entry.authProfileOverrideCompactionCount).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -19,6 +19,7 @@ import { isSubagentSessionKey } from "../routing/session-key.js";
|
|||||||
import { applyVerboseOverride, parseVerboseOverride } from "../sessions/level-overrides.js";
|
import { applyVerboseOverride, parseVerboseOverride } from "../sessions/level-overrides.js";
|
||||||
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
import { normalizeSendPolicy } from "../sessions/send-policy.js";
|
||||||
import { parseSessionLabel } from "../sessions/session-label.js";
|
import { parseSessionLabel } from "../sessions/session-label.js";
|
||||||
|
import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js";
|
||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
type ErrorShape,
|
type ErrorShape,
|
||||||
@@ -220,18 +221,23 @@ export async function applySessionsPatchToStore(params: {
|
|||||||
|
|
||||||
if ("model" in patch) {
|
if ("model" in patch) {
|
||||||
const raw = patch.model;
|
const raw = patch.model;
|
||||||
|
const resolvedDefault = resolveConfiguredModelRef({
|
||||||
|
cfg,
|
||||||
|
defaultProvider: DEFAULT_PROVIDER,
|
||||||
|
defaultModel: DEFAULT_MODEL,
|
||||||
|
});
|
||||||
if (raw === null) {
|
if (raw === null) {
|
||||||
delete next.providerOverride;
|
applyModelOverrideToSessionEntry({
|
||||||
delete next.modelOverride;
|
entry: next,
|
||||||
|
selection: {
|
||||||
|
provider: resolvedDefault.provider,
|
||||||
|
model: resolvedDefault.model,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
} else if (raw !== undefined) {
|
} else if (raw !== undefined) {
|
||||||
const trimmed = String(raw).trim();
|
const trimmed = String(raw).trim();
|
||||||
if (!trimmed) return invalid("invalid model: empty");
|
if (!trimmed) return invalid("invalid model: empty");
|
||||||
|
|
||||||
const resolvedDefault = resolveConfiguredModelRef({
|
|
||||||
cfg,
|
|
||||||
defaultProvider: DEFAULT_PROVIDER,
|
|
||||||
defaultModel: DEFAULT_MODEL,
|
|
||||||
});
|
|
||||||
if (!params.loadGatewayModelCatalog) {
|
if (!params.loadGatewayModelCatalog) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -249,16 +255,17 @@ export async function applySessionsPatchToStore(params: {
|
|||||||
if ("error" in resolved) {
|
if ("error" in resolved) {
|
||||||
return invalid(resolved.error);
|
return invalid(resolved.error);
|
||||||
}
|
}
|
||||||
if (
|
const isDefault =
|
||||||
resolved.ref.provider === resolvedDefault.provider &&
|
resolved.ref.provider === resolvedDefault.provider &&
|
||||||
resolved.ref.model === resolvedDefault.model
|
resolved.ref.model === resolvedDefault.model;
|
||||||
) {
|
applyModelOverrideToSessionEntry({
|
||||||
delete next.providerOverride;
|
entry: next,
|
||||||
delete next.modelOverride;
|
selection: {
|
||||||
} else {
|
provider: resolved.ref.provider,
|
||||||
next.providerOverride = resolved.ref.provider;
|
model: resolved.ref.model,
|
||||||
next.modelOverride = resolved.ref.model;
|
isDefault,
|
||||||
}
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export type GlobalInstallManager = "npm" | "pnpm" | "bun";
|
||||||
|
|
||||||
|
export type CommandRunner = (
|
||||||
|
argv: string[],
|
||||||
|
options: { timeoutMs: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
||||||
|
) => Promise<{ stdout: string; stderr: string; code: number | null }>;
|
||||||
|
|
||||||
|
async function pathExists(targetPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(targetPath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tryRealpath(targetPath: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await fs.realpath(targetPath);
|
||||||
|
} catch {
|
||||||
|
return path.resolve(targetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBunGlobalRoot(): string {
|
||||||
|
const bunInstall = process.env.BUN_INSTALL?.trim() || path.join(os.homedir(), ".bun");
|
||||||
|
return path.join(bunInstall, "install", "global", "node_modules");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveGlobalRoot(
|
||||||
|
manager: GlobalInstallManager,
|
||||||
|
runCommand: CommandRunner,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (manager === "bun") return resolveBunGlobalRoot();
|
||||||
|
const argv = manager === "pnpm" ? ["pnpm", "root", "-g"] : ["npm", "root", "-g"];
|
||||||
|
const res = await runCommand(argv, { timeoutMs }).catch(() => null);
|
||||||
|
if (!res || res.code !== 0) return null;
|
||||||
|
const root = res.stdout.trim();
|
||||||
|
return root || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveGlobalPackageRoot(
|
||||||
|
manager: GlobalInstallManager,
|
||||||
|
runCommand: CommandRunner,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const root = await resolveGlobalRoot(manager, runCommand, timeoutMs);
|
||||||
|
if (!root) return null;
|
||||||
|
return path.join(root, "clawdbot");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectGlobalInstallManagerForRoot(
|
||||||
|
runCommand: CommandRunner,
|
||||||
|
pkgRoot: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<GlobalInstallManager | null> {
|
||||||
|
const pkgReal = await tryRealpath(pkgRoot);
|
||||||
|
|
||||||
|
const candidates: Array<{
|
||||||
|
manager: "npm" | "pnpm";
|
||||||
|
argv: string[];
|
||||||
|
}> = [
|
||||||
|
{ manager: "npm", argv: ["npm", "root", "-g"] },
|
||||||
|
{ manager: "pnpm", argv: ["pnpm", "root", "-g"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { manager, argv } of candidates) {
|
||||||
|
const res = await runCommand(argv, { timeoutMs }).catch(() => null);
|
||||||
|
if (!res || res.code !== 0) continue;
|
||||||
|
const globalRoot = res.stdout.trim();
|
||||||
|
if (!globalRoot) continue;
|
||||||
|
const globalReal = await tryRealpath(globalRoot);
|
||||||
|
const expected = path.join(globalReal, "clawdbot");
|
||||||
|
if (path.resolve(expected) === path.resolve(pkgReal)) return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bunGlobalRoot = resolveBunGlobalRoot();
|
||||||
|
const bunGlobalReal = await tryRealpath(bunGlobalRoot);
|
||||||
|
const bunExpected = path.join(bunGlobalReal, "clawdbot");
|
||||||
|
if (path.resolve(bunExpected) === path.resolve(pkgReal)) return "bun";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectGlobalInstallManagerByPresence(
|
||||||
|
runCommand: CommandRunner,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<GlobalInstallManager | null> {
|
||||||
|
for (const manager of ["npm", "pnpm"] as const) {
|
||||||
|
const root = await resolveGlobalRoot(manager, runCommand, timeoutMs);
|
||||||
|
if (!root) continue;
|
||||||
|
if (await pathExists(path.join(root, "clawdbot"))) return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bunRoot = resolveBunGlobalRoot();
|
||||||
|
if (await pathExists(path.join(bunRoot, "clawdbot"))) return "bun";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function globalInstallArgs(manager: GlobalInstallManager, spec: string): string[] {
|
||||||
|
if (manager === "pnpm") return ["pnpm", "add", "-g", spec];
|
||||||
|
if (manager === "bun") return ["bun", "add", "-g", spec];
|
||||||
|
return ["npm", "i", "-g", spec];
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import os from "node:os";
|
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
|
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { compareSemverStrings } from "./update-check.js";
|
import { compareSemverStrings } from "./update-check.js";
|
||||||
import { DEV_BRANCH, isBetaTag, isStableTag, type UpdateChannel } from "./update-channels.js";
|
import { DEV_BRANCH, isBetaTag, isStableTag, type UpdateChannel } from "./update-channels.js";
|
||||||
|
import { detectGlobalInstallManagerForRoot, globalInstallArgs } from "./update-global.js";
|
||||||
import { trimLogTail } from "./restart-sentinel.js";
|
import { trimLogTail } from "./restart-sentinel.js";
|
||||||
|
|
||||||
export type UpdateStepResult = {
|
export type UpdateStepResult = {
|
||||||
@@ -210,52 +210,6 @@ async function detectPackageManager(root: string) {
|
|||||||
return "npm";
|
return "npm";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function tryRealpath(value: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
return await fs.realpath(value);
|
|
||||||
} catch {
|
|
||||||
return path.resolve(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function detectGlobalInstallManager(
|
|
||||||
runCommand: CommandRunner,
|
|
||||||
pkgRoot: string,
|
|
||||||
timeoutMs: number,
|
|
||||||
): Promise<"npm" | "pnpm" | "bun" | null> {
|
|
||||||
const pkgReal = await tryRealpath(pkgRoot);
|
|
||||||
|
|
||||||
const candidates: Array<{
|
|
||||||
manager: "npm" | "pnpm";
|
|
||||||
argv: string[];
|
|
||||||
}> = [
|
|
||||||
{ manager: "npm", argv: ["npm", "root", "-g"] },
|
|
||||||
{ manager: "pnpm", argv: ["pnpm", "root", "-g"] },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { manager, argv } of candidates) {
|
|
||||||
const res = await runCommand(argv, { timeoutMs }).catch(() => null);
|
|
||||||
if (!res) continue;
|
|
||||||
if (res.code !== 0) continue;
|
|
||||||
const globalRoot = res.stdout.trim();
|
|
||||||
if (!globalRoot) continue;
|
|
||||||
|
|
||||||
const globalReal = await tryRealpath(globalRoot);
|
|
||||||
const expected = path.join(globalReal, "clawdbot");
|
|
||||||
if (path.resolve(expected) === path.resolve(pkgReal)) return manager;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bun doesn't have an officially stable "global root" command across versions,
|
|
||||||
// so we check the common global install path (best-effort).
|
|
||||||
const bunInstall = process.env.BUN_INSTALL?.trim() || path.join(os.homedir(), ".bun");
|
|
||||||
const bunGlobalRoot = path.join(bunInstall, "install", "global", "node_modules");
|
|
||||||
const bunGlobalReal = await tryRealpath(bunGlobalRoot);
|
|
||||||
const bunExpected = path.join(bunGlobalReal, "clawdbot");
|
|
||||||
if (path.resolve(bunExpected) === path.resolve(pkgReal)) return "bun";
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type RunStepOptions = {
|
type RunStepOptions = {
|
||||||
runCommand: CommandRunner;
|
runCommand: CommandRunner;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -324,13 +278,6 @@ function normalizeTag(tag?: string) {
|
|||||||
return trimmed.startsWith("clawdbot@") ? trimmed.slice("clawdbot@".length) : trimmed;
|
return trimmed.startsWith("clawdbot@") ? trimmed.slice("clawdbot@".length) : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function globalUpdateArgs(manager: "pnpm" | "npm" | "bun", tag?: string) {
|
|
||||||
const spec = `clawdbot@${normalizeTag(tag)}`;
|
|
||||||
if (manager === "pnpm") return ["pnpm", "add", "-g", spec];
|
|
||||||
if (manager === "bun") return ["bun", "add", "-g", spec];
|
|
||||||
return ["npm", "i", "-g", spec];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<UpdateRunResult> {
|
export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<UpdateRunResult> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const runCommand =
|
const runCommand =
|
||||||
@@ -604,12 +551,13 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const beforeVersion = await readPackageVersion(pkgRoot);
|
const beforeVersion = await readPackageVersion(pkgRoot);
|
||||||
const globalManager = await detectGlobalInstallManager(runCommand, pkgRoot, timeoutMs);
|
const globalManager = await detectGlobalInstallManagerForRoot(runCommand, pkgRoot, timeoutMs);
|
||||||
if (globalManager) {
|
if (globalManager) {
|
||||||
|
const spec = `clawdbot@${normalizeTag(opts.tag)}`;
|
||||||
const updateStep = await runStep({
|
const updateStep = await runStep({
|
||||||
runCommand,
|
runCommand,
|
||||||
name: "global update",
|
name: "global update",
|
||||||
argv: globalUpdateArgs(globalManager, opts.tag),
|
argv: globalInstallArgs(globalManager, spec),
|
||||||
cwd: pkgRoot,
|
cwd: pkgRoot,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
progress,
|
progress,
|
||||||
|
|||||||
@@ -245,6 +245,37 @@ export async function addChannelAllowFromStoreEntry(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function removeChannelAllowFromStoreEntry(params: {
|
||||||
|
channel: PairingChannel;
|
||||||
|
entry: string | number;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): Promise<{ changed: boolean; allowFrom: string[] }> {
|
||||||
|
const env = params.env ?? process.env;
|
||||||
|
const filePath = resolveAllowFromPath(params.channel, env);
|
||||||
|
return await withFileLock(
|
||||||
|
filePath,
|
||||||
|
{ version: 1, allowFrom: [] } satisfies AllowFromStore,
|
||||||
|
async () => {
|
||||||
|
const { value } = await readJsonFile<AllowFromStore>(filePath, {
|
||||||
|
version: 1,
|
||||||
|
allowFrom: [],
|
||||||
|
});
|
||||||
|
const current = (Array.isArray(value.allowFrom) ? value.allowFrom : [])
|
||||||
|
.map((v) => normalizeAllowEntry(params.channel, String(v)))
|
||||||
|
.filter(Boolean);
|
||||||
|
const normalized = normalizeAllowEntry(params.channel, normalizeId(params.entry));
|
||||||
|
if (!normalized) return { changed: false, allowFrom: current };
|
||||||
|
const next = current.filter((entry) => entry !== normalized);
|
||||||
|
if (next.length === current.length) return { changed: false, allowFrom: current };
|
||||||
|
await writeJsonFile(filePath, {
|
||||||
|
version: 1,
|
||||||
|
allowFrom: next,
|
||||||
|
} satisfies AllowFromStore);
|
||||||
|
return { changed: true, allowFrom: next };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listChannelPairingRequests(
|
export async function listChannelPairingRequests(
|
||||||
channel: PairingChannel,
|
channel: PairingChannel,
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Command } from "commander";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
memoryRegister: vi.fn(),
|
||||||
|
otherRegister: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./loader.js", () => ({
|
||||||
|
loadClawdbotPlugins: () => ({
|
||||||
|
cliRegistrars: [
|
||||||
|
{
|
||||||
|
pluginId: "memory-core",
|
||||||
|
register: mocks.memoryRegister,
|
||||||
|
commands: ["memory"],
|
||||||
|
source: "bundled",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pluginId: "other",
|
||||||
|
register: mocks.otherRegister,
|
||||||
|
commands: ["other"],
|
||||||
|
source: "bundled",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { registerPluginCliCommands } from "./cli.js";
|
||||||
|
|
||||||
|
describe("registerPluginCliCommands", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.memoryRegister.mockClear();
|
||||||
|
mocks.otherRegister.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips plugin CLI registrars when commands already exist", () => {
|
||||||
|
const program = new Command();
|
||||||
|
program.command("memory");
|
||||||
|
|
||||||
|
registerPluginCliCommands(program, {} as any);
|
||||||
|
|
||||||
|
expect(mocks.memoryRegister).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.otherRegister).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,7 +24,20 @@ export function registerPluginCliCommands(program: Command, cfg?: ClawdbotConfig
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const existingCommands = new Set(program.commands.map((cmd) => cmd.name()));
|
||||||
|
|
||||||
for (const entry of registry.cliRegistrars) {
|
for (const entry of registry.cliRegistrars) {
|
||||||
|
if (entry.commands.length > 0) {
|
||||||
|
const overlaps = entry.commands.filter((command) => existingCommands.has(command));
|
||||||
|
if (overlaps.length > 0) {
|
||||||
|
log.debug(
|
||||||
|
`plugin CLI register skipped (${entry.pluginId}): command already registered (${overlaps.join(
|
||||||
|
", ",
|
||||||
|
)})`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const result = entry.register({
|
const result = entry.register({
|
||||||
program,
|
program,
|
||||||
@@ -37,6 +50,9 @@ export function registerPluginCliCommands(program: Command, cfg?: ClawdbotConfig
|
|||||||
log.warn(`plugin CLI register failed (${entry.pluginId}): ${String(err)}`);
|
log.warn(`plugin CLI register failed (${entry.pluginId}): ${String(err)}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
for (const command of entry.commands) {
|
||||||
|
existingCommands.add(command);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn(`plugin CLI register failed (${entry.pluginId}): ${String(err)}`);
|
log.warn(`plugin CLI register failed (${entry.pluginId}): ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { SessionEntry } from "../config/sessions.js";
|
||||||
|
|
||||||
|
export type ModelOverrideSelection = {
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
isDefault?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function applyModelOverrideToSessionEntry(params: {
|
||||||
|
entry: SessionEntry;
|
||||||
|
selection: ModelOverrideSelection;
|
||||||
|
profileOverride?: string;
|
||||||
|
profileOverrideSource?: "auto" | "user";
|
||||||
|
}): { updated: boolean } {
|
||||||
|
const { entry, selection, profileOverride } = params;
|
||||||
|
const profileOverrideSource = params.profileOverrideSource ?? "user";
|
||||||
|
let updated = false;
|
||||||
|
|
||||||
|
if (selection.isDefault) {
|
||||||
|
if (entry.providerOverride) {
|
||||||
|
delete entry.providerOverride;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
if (entry.modelOverride) {
|
||||||
|
delete entry.modelOverride;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (entry.providerOverride !== selection.provider) {
|
||||||
|
entry.providerOverride = selection.provider;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
if (entry.modelOverride !== selection.model) {
|
||||||
|
entry.modelOverride = selection.model;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileOverride) {
|
||||||
|
if (entry.authProfileOverride !== profileOverride) {
|
||||||
|
entry.authProfileOverride = profileOverride;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
if (entry.authProfileOverrideSource !== profileOverrideSource) {
|
||||||
|
entry.authProfileOverrideSource = profileOverrideSource;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
if (entry.authProfileOverrideCompactionCount !== undefined) {
|
||||||
|
delete entry.authProfileOverrideCompactionCount;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (entry.authProfileOverride) {
|
||||||
|
delete entry.authProfileOverride;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
if (entry.authProfileOverrideSource) {
|
||||||
|
delete entry.authProfileOverrideSource;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
if (entry.authProfileOverrideCompactionCount !== undefined) {
|
||||||
|
delete entry.authProfileOverrideCompactionCount;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
entry.updatedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { updated };
|
||||||
|
}
|
||||||
@@ -476,10 +476,11 @@ export async function prepareSlackMessage(params: {
|
|||||||
Surface: "slack" as const,
|
Surface: "slack" as const,
|
||||||
MessageSid: message.ts,
|
MessageSid: message.ts,
|
||||||
ReplyToId: message.thread_ts ?? message.ts,
|
ReplyToId: message.thread_ts ?? message.ts,
|
||||||
|
// Preserve thread context for routed tool notifications (thread replies only).
|
||||||
|
MessageThreadId: isThreadReply ? threadTs : undefined,
|
||||||
ParentSessionKey: threadKeys.parentSessionKey,
|
ParentSessionKey: threadKeys.parentSessionKey,
|
||||||
ThreadStarterBody: threadStarterBody,
|
ThreadStarterBody: threadStarterBody,
|
||||||
ThreadLabel: threadLabel,
|
ThreadLabel: threadLabel,
|
||||||
MessageThreadId: isThreadReply ? threadTs : undefined,
|
|
||||||
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
||||||
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
|
WasMentioned: isRoomish ? effectiveWasMentioned : undefined,
|
||||||
MediaPath: media?.path,
|
MediaPath: media?.path,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|
||||||
import SlackBoltDefault, * as SlackBoltModule from "@slack/bolt";
|
import SlackBolt from "@slack/bolt";
|
||||||
|
|
||||||
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||||
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
|
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../auto-reply/reply/history.js";
|
||||||
@@ -26,24 +26,14 @@ import { normalizeAllowList } from "./allow-list.js";
|
|||||||
|
|
||||||
import type { MonitorSlackOpts } from "./types.js";
|
import type { MonitorSlackOpts } from "./types.js";
|
||||||
|
|
||||||
type SlackBoltNamespace = typeof import("@slack/bolt");
|
const slackBoltModule = SlackBolt as typeof import("@slack/bolt") & {
|
||||||
type SlackBoltDefault = SlackBoltNamespace | SlackBoltNamespace["App"];
|
default?: typeof import("@slack/bolt");
|
||||||
|
};
|
||||||
const slackBoltDefaultImport = SlackBoltDefault as SlackBoltDefault | undefined;
|
// Bun allows named imports from CJS; Node ESM doesn't. Use default+fallback for compatibility.
|
||||||
const slackBoltModuleDefault = (SlackBoltModule as { default?: SlackBoltDefault }).default;
|
// Fix: Check if module has App property directly (Node 25.x ESM/CJS compat issue)
|
||||||
const slackBoltDefault = slackBoltDefaultImport ?? slackBoltModuleDefault;
|
const slackBolt =
|
||||||
const slackBoltNamespace =
|
(slackBoltModule.App ? slackBoltModule : slackBoltModule.default) ?? slackBoltModule;
|
||||||
typeof slackBoltDefault === "object" && slackBoltDefault
|
const { App, HTTPReceiver } = slackBolt;
|
||||||
? (slackBoltDefault as SlackBoltNamespace)
|
|
||||||
: typeof slackBoltModuleDefault === "object" && slackBoltModuleDefault
|
|
||||||
? (slackBoltModuleDefault as SlackBoltNamespace)
|
|
||||||
: undefined;
|
|
||||||
// Bun allows named imports from CJS; Node ESM doesn't. Resolve default/module shapes for compatibility.
|
|
||||||
const App = ((typeof slackBoltDefault === "function" ? slackBoltDefault : undefined) ??
|
|
||||||
slackBoltNamespace?.App ??
|
|
||||||
SlackBoltModule.App) as SlackBoltNamespace["App"];
|
|
||||||
const HTTPReceiver = (slackBoltNamespace?.HTTPReceiver ??
|
|
||||||
SlackBoltModule.HTTPReceiver) as SlackBoltNamespace["HTTPReceiver"];
|
|
||||||
function parseApiAppIdFromAppToken(raw?: string) {
|
function parseApiAppIdFromAppToken(raw?: string) {
|
||||||
const token = raw?.trim();
|
const token = raw?.trim();
|
||||||
if (!token) return undefined;
|
if (!token) return undefined;
|
||||||
@@ -133,13 +123,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
|
|||||||
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
|
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
|
||||||
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
|
||||||
|
|
||||||
if (!App) {
|
|
||||||
throw new Error("Slack Bolt App export missing; check @slack/bolt installation.");
|
|
||||||
}
|
|
||||||
if (slackMode === "http" && !HTTPReceiver) {
|
|
||||||
throw new Error("Slack Bolt HTTPReceiver export missing; check @slack/bolt installation.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const receiver =
|
const receiver =
|
||||||
slackMode === "http"
|
slackMode === "http"
|
||||||
? new HTTPReceiver({
|
? new HTTPReceiver({
|
||||||
|
|||||||
@@ -680,7 +680,6 @@
|
|||||||
|
|
||||||
.shell--chat .chat {
|
.shell--chat .chat {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-height: calc(100vh - 180px); /* Constrain height for sticky compose */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-header {
|
.chat-header {
|
||||||
|
|||||||
@@ -211,17 +211,6 @@ export function renderChat(props: ChatProps) {
|
|||||||
>
|
>
|
||||||
New session
|
New session
|
||||||
</button>
|
</button>
|
||||||
${props.onAbort
|
|
||||||
? html`
|
|
||||||
<button
|
|
||||||
class="btn danger"
|
|
||||||
?disabled=${!props.connected || !isBusy || props.canAbort === false}
|
|
||||||
@click=${props.onAbort}
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
`
|
|
||||||
: nothing}
|
|
||||||
<button
|
<button
|
||||||
class="btn primary"
|
class="btn primary"
|
||||||
?disabled=${!props.connected}
|
?disabled=${!props.connected}
|
||||||
|
|||||||
Reference in new issue
Block a user