Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c4c9c5edd | ||
|
|
0f34255359 | ||
|
|
de5fb65cb8 | ||
|
|
e773f84e39 | ||
|
|
b969c216fc | ||
|
|
f51a4d2aca | ||
|
|
30b3a9de30 | ||
|
|
0391f6553b | ||
|
|
d0c986c4f0 | ||
|
|
384028e12e | ||
|
|
4c14d6c8db | ||
|
|
6fa437613b | ||
|
|
949fa1051f | ||
|
|
4965727f39 | ||
|
|
7c34883267 | ||
|
|
072c3dc55c | ||
|
|
83b3875131 | ||
|
|
a35083808c | ||
|
|
3d3ec9d972 | ||
|
|
9838a2850f | ||
|
|
6c6bc6ff1c | ||
|
|
1791c1a765 | ||
|
|
6e53c061ff | ||
|
|
1773f8aea2 | ||
|
|
52bdf57743 | ||
|
|
b3ab24eb8e | ||
|
|
6ac1c1d6ea | ||
|
|
7655a501d0 | ||
|
|
3b1b14b0b1 | ||
|
|
bf15c87d2b | ||
|
|
2dae4d382f | ||
|
|
e9a47a02d1 | ||
|
|
8e80823b03 | ||
|
|
319afd192d | ||
|
|
6e0daf0936 |
No files matched your search
@@ -55,3 +55,5 @@ apps/ios/*.mobileprovision
|
||||
# Local untracked files
|
||||
.local/
|
||||
.vscode/
|
||||
IDENTITY.md
|
||||
USER.md
|
||||
+36
-14
@@ -1,50 +1,72 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.15 (unreleased)
|
||||
## 2026.1.15
|
||||
|
||||
### Highlights
|
||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||
- Browser: improve remote CDP/Browserless support (auth passthrough, `wss` upgrade, timeouts, clearer errors).
|
||||
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.
|
||||
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).
|
||||
|
||||
### Breaking
|
||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
||||
|
||||
### Changes
|
||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||
- Repo: ignore local identity files to avoid accidental commits. (#1001) — thanks @gerardward2007.
|
||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||
- Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.
|
||||
- TUI: show provider/model labels for the active session and default model.
|
||||
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
||||
- UI: show gateway auth guidance + doc link on unauthorized Control UI connections.
|
||||
- Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in `clawdbot security audit`.
|
||||
- Apps: store node auth tokens encrypted (Keychain/SecurePrefs).
|
||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
||||
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
||||
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
|
||||
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
|
||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
||||
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
||||
- Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in `/status` and `clawdbot models status`, and update docs.
|
||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
|
||||
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||
- Browser: `profile="chrome"` now defaults to host control and returns clearer “attach a tab” errors.
|
||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
|
||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||
- Browser: increase remote CDP reachability timeouts + add `remoteCdpTimeoutMs`/`remoteCdpHandshakeTimeoutMs`.
|
||||
- Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.
|
||||
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
||||
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.
|
||||
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
||||
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
||||
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
||||
|
||||
### Fixes
|
||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
|
||||
- Fix: persist `gateway.mode=local` after selecting Local run mode in `clawdbot configure`, even if no other sections are chosen.
|
||||
- Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.
|
||||
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
||||
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
|
||||
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
|
||||
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
|
||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||
- Browser: upgrade `ws` → `wss` when remote CDP uses `https` (fixes Browserless handshake).
|
||||
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
||||
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
||||
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
||||
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
|
||||
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
|
||||
|
||||
## 2026.1.14-1
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
ENV CLAWDBOT_PREFER_PNPM=1
|
||||
RUN pnpm ui:install
|
||||
RUN pnpm ui:build
|
||||
|
||||
|
||||
+81
-17
@@ -2,6 +2,87 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.15</title>
|
||||
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5998</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.15</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.15</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||
<li>Browser: improve remote CDP/Browserless support (auth passthrough, <code>wss</code> upgrade, timeouts, clearer errors).</li>
|
||||
<li>Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.</li>
|
||||
<li>Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)</li>
|
||||
<li><strong>BREAKING:</strong> Microsoft Teams is now a plugin; install <code>@clawdbot/msteams</code> via <code>clawdbot plugins install @clawdbot/msteams</code>.</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>CLI: set process titles to <code>clawdbot-<command></code> for clearer process listings.</li>
|
||||
<li>CLI/macOS: sync remote SSH target/identity to config and let <code>gateway status</code> auto-infer SSH targets (ssh-config aware).</li>
|
||||
<li>Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.</li>
|
||||
<li>Sessions/Security: add <code>session.dmScope</code> for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.</li>
|
||||
<li>Plugins: add provider auth registry + <code>clawdbot models auth login</code> for plugin-driven OAuth/API key flows.</li>
|
||||
<li>Onboarding: switch channels setup to a single-select loop with per-channel actions and disabled hints in the picker.</li>
|
||||
<li>TUI: show provider/model labels for the active session and default model.</li>
|
||||
<li>Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.</li>
|
||||
<li>UI: show gateway auth guidance + doc link on unauthorized Control UI connections.</li>
|
||||
<li>Security: warn on weak model tiers (Haiku, below GPT-5, below Claude 4.5) in <code>clawdbot security audit</code>.</li>
|
||||
<li>Apps: store node auth tokens encrypted (Keychain/SecurePrefs).</li>
|
||||
<li>Daemon: share profile/state-dir resolution across service helpers and honor <code>CLAWDBOT_STATE_DIR</code> for Windows task scripts.</li>
|
||||
<li>Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.</li>
|
||||
<li>Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).</li>
|
||||
<li>Tools: normalize Slack/Discord message timestamps with <code>timestampMs</code>/<code>timestampUtc</code> while keeping raw provider fields.</li>
|
||||
<li>macOS: add <code>system.which</code> for prompt-free remote skill discovery (with gateway fallback to <code>system.run</code>).</li>
|
||||
<li>Docs: add Date & Time guide and update prompt/timezone configuration docs.</li>
|
||||
<li>Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.</li>
|
||||
<li>Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.</li>
|
||||
<li>Auth/Status: keep auth profiles sticky per session (rotate on compaction/new), surface provider usage headers in <code>/status</code> and <code>clawdbot models status</code>, and update docs.</li>
|
||||
<li>CLI: add <code>--json</code> output for <code>clawdbot daemon</code> lifecycle/install commands.</li>
|
||||
<li>Memory: make <code>node-llama-cpp</code> an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.</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: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.</li>
|
||||
<li>Browser: increase remote CDP reachability timeouts + add <code>remoteCdpTimeoutMs</code>/<code>remoteCdpHandshakeTimeoutMs</code>.</li>
|
||||
<li>Browser: preserve auth/query tokens for remote CDP endpoints and pass Basic auth for CDP HTTP/WS. (#895) — thanks @mukhtharcm.</li>
|
||||
<li>Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.</li>
|
||||
<li>Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.</li>
|
||||
<li>Discord: allow allowlisted guilds without channel lists to receive messages when <code>groupPolicy="allowlist"</code>. — thanks @thewilloftheshadow.</li>
|
||||
<li>Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.</li>
|
||||
<li>Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.</li>
|
||||
<li>Fix: persist <code>gateway.mode=local</code> after selecting Local run mode in <code>clawdbot configure</code>, even if no other sections are chosen.</li>
|
||||
<li>Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter.</li>
|
||||
<li>Agents: avoid false positives when logging unsupported Google tool schema keywords.</li>
|
||||
<li>Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.</li>
|
||||
<li>Status: restore usage summary line for current provider when no OAuth profiles exist.</li>
|
||||
<li>Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.</li>
|
||||
<li>Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.</li>
|
||||
<li>Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.</li>
|
||||
<li>Fix: support MiniMax coding plan usage responses with <code>model_remains</code>/<code>current_interval_*</code> payloads.</li>
|
||||
<li>Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)</li>
|
||||
<li>Browser: extension mode recovers when only one tab is attached (stale targetId fallback).</li>
|
||||
<li>Browser: fix <code>tab not found</code> for extension relay snapshots/actions when Playwright blocks <code>newCDPSession</code> (use the single available Page).</li>
|
||||
<li>Browser: upgrade <code>ws</code> → <code>wss</code> when remote CDP uses <code>https</code> (fixes Browserless handshake).</li>
|
||||
<li>Telegram: skip <code>message_thread_id=1</code> for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.</li>
|
||||
<li>Fix: sanitize user-facing error text + strip <code><final></code> tags across reply pipelines. (#975) — thanks @ThomsenDrake.</li>
|
||||
<li>Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.</li>
|
||||
<li>Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.</li>
|
||||
<li>Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)</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.15/Clawdbot-2026.1.15.zip" length="12127276" type="application/octet-stream" sparkle:edSignature="o79vwTbtW/d91NQFRVfUDhsv6D4zIw7IkhY0N1iLImMu94BURgLcecA6z7Smy3bMobPwOyzN8yfm6mA/Rt8FCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.14-1</title>
|
||||
<pubDate>Thu, 15 Jan 2026 11:14:40 +0000</pubDate>
|
||||
@@ -190,22 +271,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.11-3</title>
|
||||
<pubDate>Mon, 12 Jan 2026 10:40:23 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>5212</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.11-3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.11-3</h2>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>CLI: avoid top-level await warnings in the entrypoint on fresh installs.</li>
|
||||
<li>CLI: show a commit hash in the banner for npm installs (package.json gitHead fallback).</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.11-3/Clawdbot-2026.1.11-3.zip" length="19860758" type="application/octet-stream" sparkle:edSignature="LbvGUSjc3jGO7aVo2UVA0nEkaJbb3O4iwRBo1TBqoapdTtxnDlS3s6N+Z4vOSLRAoAm22EoZOwbpK9085c7HAQ=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -26,7 +26,8 @@ Sources/Voice/VoiceTab.swift
|
||||
Sources/Voice/VoiceWakeManager.swift
|
||||
Sources/Voice/VoiceWakePreferences.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatComposer.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownSplitter.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownRenderer.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMarkdownPreprocessor.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatMessageViews.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatModels.swift
|
||||
../shared/ClawdbotKit/Sources/ClawdbotChatUI/ChatPayloadDecoding.swift
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Clawdbot
|
||||
options:
|
||||
bundleIdPrefix: com.clawdbot
|
||||
deploymentTarget:
|
||||
iOS: "17.0"
|
||||
iOS: "18.0"
|
||||
xcodeVersion: "16.0"
|
||||
|
||||
settings:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "9de32b5fc115432dadd84c3ab4d67d2fed22ffaf5675a77033d69ea194ac3862",
|
||||
"originHash" : "7eec77e2b399c480e76fdfc7dc3162652f5c775530e9fc282953de38ef2de79b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "elevenlabskit",
|
||||
@@ -73,6 +73,15 @@
|
||||
"revision" : "8e5e4a8f3617283b556064574651fc0869943c9a"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-concurrency-extras",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
|
||||
"state" : {
|
||||
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
|
||||
"version" : "1.3.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-configuration",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -144,6 +153,24 @@
|
||||
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
|
||||
"version" : "1.6.3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swiftui-math",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swiftui-math",
|
||||
"state" : {
|
||||
"revision" : "0b5c2cfaaec8d6193db206f675048eeb5ce95f71",
|
||||
"version" : "0.1.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "textual",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/textual",
|
||||
"state" : {
|
||||
"revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
|
||||
"version" : "0.2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
|
||||
@@ -191,8 +191,8 @@ extension CronJobEditor {
|
||||
func applyDeleteAfterRun(
|
||||
to root: inout [String: Any],
|
||||
scheduleKind: ScheduleKind? = nil,
|
||||
deleteAfterRun: Bool? = nil
|
||||
) {
|
||||
deleteAfterRun: Bool? = nil)
|
||||
{
|
||||
let resolvedSchedule = scheduleKind ?? self.scheduleKind
|
||||
let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun
|
||||
if resolvedSchedule == .at {
|
||||
|
||||
@@ -395,7 +395,7 @@ extension InstancesSettings {
|
||||
host: "phone",
|
||||
ip: "10.0.0.3",
|
||||
version: "2.0.0",
|
||||
platform: "iOS 17.2",
|
||||
platform: "iOS 18.0",
|
||||
deviceFamily: "iPhone",
|
||||
modelIdentifier: nil,
|
||||
lastInputSeconds: 35,
|
||||
@@ -446,7 +446,7 @@ extension InstancesSettings {
|
||||
_ = view.platformIcon("watchOS 10")
|
||||
_ = view.platformIcon("unknown 1.0")
|
||||
_ = view.prettyPlatform("macOS 14.2")
|
||||
_ = view.prettyPlatform("iOS 17")
|
||||
_ = view.prettyPlatform("iOS 18")
|
||||
_ = view.prettyPlatform("ipados 17.1")
|
||||
_ = view.prettyPlatform("linux")
|
||||
_ = view.prettyPlatform(" ")
|
||||
|
||||
@@ -39,7 +39,7 @@ struct InstancesSettingsSmokeTests {
|
||||
host: "gateway",
|
||||
ip: "10.0.0.4",
|
||||
version: "3.0.0",
|
||||
platform: "iOS 17",
|
||||
platform: "iOS 18",
|
||||
deviceFamily: nil,
|
||||
modelIdentifier: nil,
|
||||
lastInputSeconds: nil,
|
||||
|
||||
@@ -8,7 +8,7 @@ import Testing
|
||||
let wav = makeWav16Mono(sampleRate: 8000, samples: 80)
|
||||
defer { _ = TalkAudioPlayer.shared.stop() }
|
||||
|
||||
_ = try await withTimeout(seconds: 2.0) {
|
||||
_ = try await withTimeout(seconds: 4.0) {
|
||||
await TalkAudioPlayer.shared.play(data: wav)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import Testing
|
||||
await Task.yield()
|
||||
_ = await TalkAudioPlayer.shared.play(data: wav)
|
||||
|
||||
_ = try await withTimeout(seconds: 2.0) {
|
||||
_ = try await withTimeout(seconds: 4.0) {
|
||||
await first.value
|
||||
}
|
||||
#expect(true)
|
||||
|
||||
@@ -5,7 +5,7 @@ import PackageDescription
|
||||
let package = Package(
|
||||
name: "ClawdbotKit",
|
||||
platforms: [
|
||||
.iOS(.v17),
|
||||
.iOS(.v18),
|
||||
.macOS(.v15),
|
||||
],
|
||||
products: [
|
||||
@@ -14,6 +14,7 @@ let package = Package(
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
|
||||
.package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
@@ -29,7 +30,13 @@ let package = Package(
|
||||
]),
|
||||
.target(
|
||||
name: "ClawdbotChatUI",
|
||||
dependencies: ["ClawdbotKit"],
|
||||
dependencies: [
|
||||
"ClawdbotKit",
|
||||
.product(
|
||||
name: "Textual",
|
||||
package: "textual",
|
||||
condition: .when(platforms: [.macOS, .iOS])),
|
||||
],
|
||||
swiftSettings: [
|
||||
.enableUpcomingFeature("StrictConcurrency"),
|
||||
]),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import Foundation
|
||||
|
||||
enum ChatMarkdownPreprocessor {
|
||||
struct InlineImage: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
let image: ClawdbotPlatformImage?
|
||||
}
|
||||
|
||||
struct Result {
|
||||
let cleaned: String
|
||||
let images: [InlineImage]
|
||||
}
|
||||
|
||||
static func preprocess(markdown raw: String) -> Result {
|
||||
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
|
||||
guard let re = try? NSRegularExpression(pattern: pattern) else {
|
||||
return Result(cleaned: raw, images: [])
|
||||
}
|
||||
|
||||
let ns = raw as NSString
|
||||
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
|
||||
if matches.isEmpty { return Result(cleaned: raw, images: []) }
|
||||
|
||||
var images: [InlineImage] = []
|
||||
var cleaned = raw
|
||||
|
||||
for match in matches.reversed() {
|
||||
guard match.numberOfRanges >= 3 else { continue }
|
||||
let label = ns.substring(with: match.range(at: 1))
|
||||
let dataURL = ns.substring(with: match.range(at: 2))
|
||||
|
||||
let image: ClawdbotPlatformImage? = {
|
||||
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
|
||||
let b64 = String(dataURL[dataURL.index(after: comma)...])
|
||||
guard let data = Data(base64Encoded: b64) else { return nil }
|
||||
return ClawdbotPlatformImage(data: data)
|
||||
}()
|
||||
images.append(InlineImage(label: label, image: image))
|
||||
|
||||
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
|
||||
let end = cleaned.index(start, offsetBy: match.range.length)
|
||||
cleaned.replaceSubrange(start..<end, with: "")
|
||||
}
|
||||
|
||||
let normalized = cleaned
|
||||
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Result(cleaned: normalized, images: images.reversed())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import SwiftUI
|
||||
import Textual
|
||||
|
||||
public enum ChatMarkdownVariant: String, CaseIterable, Sendable {
|
||||
case standard
|
||||
case compact
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ChatMarkdownRenderer: View {
|
||||
enum Context {
|
||||
case user
|
||||
case assistant
|
||||
}
|
||||
|
||||
let text: String
|
||||
let context: Context
|
||||
let variant: ChatMarkdownVariant
|
||||
let font: Font
|
||||
let textColor: Color
|
||||
|
||||
var body: some View {
|
||||
let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
StructuredText(markdown: processed.cleaned)
|
||||
.modifier(ChatMarkdownStyle(
|
||||
variant: self.variant,
|
||||
context: self.context,
|
||||
font: self.font,
|
||||
textColor: self.textColor))
|
||||
|
||||
if !processed.images.isEmpty {
|
||||
InlineImageList(images: processed.images)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatMarkdownStyle: ViewModifier {
|
||||
let variant: ChatMarkdownVariant
|
||||
let context: ChatMarkdownRenderer.Context
|
||||
let font: Font
|
||||
let textColor: Color
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
Group {
|
||||
if self.variant == .compact {
|
||||
content.textual.structuredTextStyle(.default)
|
||||
} else {
|
||||
content.textual.structuredTextStyle(.gitHub)
|
||||
}
|
||||
}
|
||||
.font(self.font)
|
||||
.foregroundStyle(self.textColor)
|
||||
.textual.inlineStyle(self.inlineStyle)
|
||||
.textual.textSelection(.enabled)
|
||||
}
|
||||
|
||||
private var inlineStyle: InlineStyle {
|
||||
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
|
||||
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
|
||||
return InlineStyle()
|
||||
.code(.monospaced, .fontScale(codeScale))
|
||||
.link(.foregroundColor(linkColor))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct InlineImageList: View {
|
||||
let images: [ChatMarkdownPreprocessor.InlineImage]
|
||||
|
||||
var body: some View {
|
||||
ForEach(images, id: \.id) { item in
|
||||
if let img = item.image {
|
||||
ClawdbotPlatformImageFactory.image(img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
||||
} else {
|
||||
Text(item.label.isEmpty ? "Image" : item.label)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum ChatMarkdownSplitter {
|
||||
struct InlineImage: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
let image: ClawdbotPlatformImage?
|
||||
}
|
||||
|
||||
struct Block: Identifiable {
|
||||
enum Kind: Equatable {
|
||||
case text
|
||||
case code(language: String?)
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
let kind: Kind
|
||||
let text: String
|
||||
}
|
||||
|
||||
struct SplitResult {
|
||||
let blocks: [Block]
|
||||
let images: [InlineImage]
|
||||
}
|
||||
|
||||
static func split(markdown raw: String) -> SplitResult {
|
||||
let extracted = self.extractInlineImages(from: raw)
|
||||
let blocks = self.splitCodeBlocks(from: extracted.cleaned)
|
||||
return SplitResult(blocks: blocks, images: extracted.images)
|
||||
}
|
||||
|
||||
private static func splitCodeBlocks(from raw: String) -> [Block] {
|
||||
var blocks: [Block] = []
|
||||
var buffer: [String] = []
|
||||
var inCode = false
|
||||
var codeLang: String?
|
||||
var codeLines: [String] = []
|
||||
|
||||
for line in raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
|
||||
if line.hasPrefix("```") {
|
||||
if inCode {
|
||||
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
|
||||
codeLines.removeAll(keepingCapacity: true)
|
||||
inCode = false
|
||||
codeLang = nil
|
||||
} else {
|
||||
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !text.isEmpty {
|
||||
blocks.append(Block(kind: .text, text: text))
|
||||
}
|
||||
buffer.removeAll(keepingCapacity: true)
|
||||
inCode = true
|
||||
codeLang = line.dropFirst(3).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if codeLang?.isEmpty == true { codeLang = nil }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if inCode {
|
||||
codeLines.append(line)
|
||||
} else {
|
||||
buffer.append(line)
|
||||
}
|
||||
}
|
||||
|
||||
if inCode {
|
||||
blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n")))
|
||||
} else {
|
||||
let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !text.isEmpty {
|
||||
blocks.append(Block(kind: .text, text: text))
|
||||
}
|
||||
}
|
||||
|
||||
return blocks.isEmpty ? [Block(kind: .text, text: raw)] : blocks
|
||||
}
|
||||
|
||||
private static func extractInlineImages(from raw: String) -> (cleaned: String, images: [InlineImage]) {
|
||||
let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"#
|
||||
guard let re = try? NSRegularExpression(pattern: pattern) else {
|
||||
return (raw, [])
|
||||
}
|
||||
|
||||
let ns = raw as NSString
|
||||
let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length))
|
||||
if matches.isEmpty { return (raw, []) }
|
||||
|
||||
var images: [InlineImage] = []
|
||||
var cleaned = raw
|
||||
|
||||
for match in matches.reversed() {
|
||||
guard match.numberOfRanges >= 3 else { continue }
|
||||
let label = ns.substring(with: match.range(at: 1))
|
||||
let dataURL = ns.substring(with: match.range(at: 2))
|
||||
|
||||
let image: ClawdbotPlatformImage? = {
|
||||
guard let comma = dataURL.firstIndex(of: ",") else { return nil }
|
||||
let b64 = String(dataURL[dataURL.index(after: comma)...])
|
||||
guard let data = Data(base64Encoded: b64) else { return nil }
|
||||
return ClawdbotPlatformImage(data: data)
|
||||
}()
|
||||
images.append(InlineImage(label: label, image: image))
|
||||
|
||||
let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location)
|
||||
let end = cleaned.index(start, offsetBy: match.range.length)
|
||||
cleaned.replaceSubrange(start..<end, with: "")
|
||||
}
|
||||
|
||||
let normalized = cleaned
|
||||
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (normalized, images.reversed())
|
||||
}
|
||||
}
|
||||
@@ -137,10 +137,16 @@ private struct ChatBubbleShape: InsettableShape {
|
||||
struct ChatMessageBubble: View {
|
||||
let message: ClawdbotChatMessage
|
||||
let style: ClawdbotChatView.Style
|
||||
let markdownVariant: ChatMarkdownVariant
|
||||
let userAccent: Color?
|
||||
|
||||
var body: some View {
|
||||
ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style, userAccent: self.userAccent)
|
||||
ChatMessageBody(
|
||||
message: self.message,
|
||||
isUser: self.isUser,
|
||||
style: self.style,
|
||||
markdownVariant: self.markdownVariant,
|
||||
userAccent: self.userAccent)
|
||||
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
|
||||
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
|
||||
.padding(.horizontal, 2)
|
||||
@@ -154,6 +160,7 @@ private struct ChatMessageBody: View {
|
||||
let message: ClawdbotChatMessage
|
||||
let isUser: Bool
|
||||
let style: ClawdbotChatView.Style
|
||||
let markdownVariant: ChatMarkdownVariant
|
||||
let userAccent: Color?
|
||||
|
||||
var body: some View {
|
||||
@@ -169,39 +176,14 @@ private struct ChatMessageBody: View {
|
||||
isUser: self.isUser)
|
||||
}
|
||||
} else if self.isUser {
|
||||
let split = ChatMarkdownSplitter.split(markdown: text)
|
||||
ForEach(split.blocks) { block in
|
||||
switch block.kind {
|
||||
case .text:
|
||||
MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14))
|
||||
case let .code(language):
|
||||
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
|
||||
}
|
||||
}
|
||||
|
||||
if !split.images.isEmpty {
|
||||
ForEach(
|
||||
split.images,
|
||||
id: \ChatMarkdownSplitter.InlineImage.id)
|
||||
{ (item: ChatMarkdownSplitter.InlineImage) in
|
||||
if let img = item.image {
|
||||
ClawdbotPlatformImageFactory.image(img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
||||
} else {
|
||||
Text(item.label.isEmpty ? "Image" : item.label)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
ChatMarkdownRenderer(
|
||||
text: text,
|
||||
context: .user,
|
||||
variant: self.markdownVariant,
|
||||
font: .system(size: 14),
|
||||
textColor: textColor)
|
||||
} else {
|
||||
ChatAssistantTextBody(text: text)
|
||||
ChatAssistantTextBody(text: text, markdownVariant: self.markdownVariant)
|
||||
}
|
||||
|
||||
if !self.inlineAttachments.isEmpty {
|
||||
@@ -505,10 +487,11 @@ extension ChatTypingIndicatorBubble: @MainActor Equatable {
|
||||
@MainActor
|
||||
struct ChatStreamingAssistantBubble: View {
|
||||
let text: String
|
||||
let markdownVariant: ChatMarkdownVariant
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ChatAssistantTextBody(text: self.text)
|
||||
ChatAssistantTextBody(text: self.text, markdownVariant: self.markdownVariant)
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
@@ -612,114 +595,22 @@ private struct TypingDots: View {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct MarkdownTextView: View {
|
||||
let text: String
|
||||
let textColor: Color
|
||||
let font: Font
|
||||
|
||||
var body: some View {
|
||||
let normalized = self.text.replacingOccurrences(
|
||||
of: "(?<!\\n)\\n(?!\\n)",
|
||||
with: " ",
|
||||
options: .regularExpression)
|
||||
let options = AttributedString.MarkdownParsingOptions(
|
||||
interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
if let attributed = try? AttributedString(markdown: normalized, options: options) {
|
||||
Text(attributed)
|
||||
.font(self.font)
|
||||
.foregroundStyle(self.textColor)
|
||||
} else {
|
||||
Text(normalized)
|
||||
.font(self.font)
|
||||
.foregroundStyle(self.textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct ChatAssistantTextBody: View {
|
||||
let text: String
|
||||
let markdownVariant: ChatMarkdownVariant
|
||||
|
||||
var body: some View {
|
||||
let segments = AssistantTextParser.segments(from: self.text)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(segments) { segment in
|
||||
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
|
||||
ChatMarkdownBody(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font)
|
||||
ChatMarkdownRenderer(
|
||||
text: segment.text,
|
||||
context: .assistant,
|
||||
variant: self.markdownVariant,
|
||||
font: font,
|
||||
textColor: ClawdbotChatTheme.assistantText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct ChatMarkdownBody: View {
|
||||
let text: String
|
||||
let textColor: Color
|
||||
let font: Font
|
||||
|
||||
var body: some View {
|
||||
let split = ChatMarkdownSplitter.split(markdown: self.text)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(split.blocks) { block in
|
||||
switch block.kind {
|
||||
case .text:
|
||||
MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font)
|
||||
case let .code(language):
|
||||
CodeBlockView(code: block.text, language: language, isUser: false)
|
||||
}
|
||||
}
|
||||
|
||||
if !split.images.isEmpty {
|
||||
ForEach(
|
||||
split.images,
|
||||
id: \ChatMarkdownSplitter.InlineImage.id)
|
||||
{ (item: ChatMarkdownSplitter.InlineImage) in
|
||||
if let img = item.image {
|
||||
ClawdbotPlatformImageFactory.image(img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(maxHeight: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
||||
} else {
|
||||
Text(item.label.isEmpty ? "Image" : item.label)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct CodeBlockView: View {
|
||||
let code: String
|
||||
let language: String?
|
||||
let isUser: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let language, !language.isEmpty {
|
||||
Text(language)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text(self.code)
|
||||
.font(.system(size: 13, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(self.isUser ? .white : .primary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(self.isUser ? Color.white.opacity(0.16) : Color.black.opacity(0.06))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public struct ClawdbotChatView: View {
|
||||
@State private var hasPerformedInitialScroll = false
|
||||
private let showsSessionSwitcher: Bool
|
||||
private let style: Style
|
||||
private let markdownVariant: ChatMarkdownVariant
|
||||
private let userAccent: Color?
|
||||
|
||||
private enum Layout {
|
||||
@@ -42,11 +43,13 @@ public struct ClawdbotChatView: View {
|
||||
viewModel: ClawdbotChatViewModel,
|
||||
showsSessionSwitcher: Bool = false,
|
||||
style: Style = .standard,
|
||||
markdownVariant: ChatMarkdownVariant = .standard,
|
||||
userAccent: Color? = nil)
|
||||
{
|
||||
self._viewModel = State(initialValue: viewModel)
|
||||
self.showsSessionSwitcher = showsSessionSwitcher
|
||||
self.style = style
|
||||
self.markdownVariant = markdownVariant
|
||||
self.userAccent = userAccent
|
||||
}
|
||||
|
||||
@@ -151,7 +154,11 @@ public struct ClawdbotChatView: View {
|
||||
@ViewBuilder
|
||||
private var messageListRows: some View {
|
||||
ForEach(self.visibleMessages) { msg in
|
||||
ChatMessageBubble(message: msg, style: self.style, userAccent: self.userAccent)
|
||||
ChatMessageBubble(
|
||||
message: msg,
|
||||
style: self.style,
|
||||
markdownVariant: self.markdownVariant,
|
||||
userAccent: self.userAccent)
|
||||
.frame(
|
||||
maxWidth: .infinity,
|
||||
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
|
||||
@@ -172,7 +179,7 @@ public struct ClawdbotChatView: View {
|
||||
}
|
||||
|
||||
if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) {
|
||||
ChatStreamingAssistantBubble(text: text)
|
||||
ChatStreamingAssistantBubble(text: text, markdownVariant: self.markdownVariant)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import Testing
|
||||
@testable import ClawdbotChatUI
|
||||
|
||||
@Suite("ChatMarkdownPreprocessor")
|
||||
struct ChatMarkdownPreprocessorTests {
|
||||
@Test func extractsDataURLImages() {
|
||||
let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4////GQAJ+wP/2hN8NwAAAABJRU5ErkJggg=="
|
||||
let markdown = """
|
||||
Hello
|
||||
|
||||
)
|
||||
"""
|
||||
|
||||
let result = ChatMarkdownPreprocessor.preprocess(markdown: markdown)
|
||||
|
||||
#expect(result.cleaned == "Hello")
|
||||
#expect(result.images.count == 1)
|
||||
#expect(result.images.first?.image != nil)
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,15 @@ Even with strong system prompts, **prompt injection is not solved**. What helps
|
||||
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
|
||||
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
|
||||
|
||||
### Model strength (security note)
|
||||
|
||||
Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts.
|
||||
|
||||
Recommendations:
|
||||
- **Use the latest generation, best-tier model** for any bot that can run tools or touch files/networks.
|
||||
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
|
||||
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
|
||||
|
||||
## Reasoning & verbose output in groups
|
||||
|
||||
`/reasoning` and `/verbose` can expose internal reasoning or tool output that
|
||||
|
||||
@@ -1355,9 +1355,11 @@ Facts (from code):
|
||||
|
||||
Fix:
|
||||
- Fastest: `clawdbot dashboard` (prints + copies tokenized link, tries to open; shows SSH hint if headless).
|
||||
- If you don’t have a token yet: `clawdbot doctor --generate-gateway-token`.
|
||||
- If remote, tunnel first: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/?token=...`.
|
||||
- Set `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`) on the gateway host.
|
||||
- In the Control UI settings, paste the same token (or refresh with a one-time `?token=...` link).
|
||||
- Still stuck? Run `clawdbot status --all` and follow [Troubleshooting](/gateway/troubleshooting). See [Dashboard](/web/dashboard) for auth details.
|
||||
|
||||
### I set `gateway.bind: "tailnet"` but it can’t bind / nothing listens
|
||||
|
||||
|
||||
@@ -59,6 +59,8 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
|
||||
enabled: true, // default: true
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
cdpUrl: "http://127.0.0.1:18792", // defaults to controlUrl + 1
|
||||
remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms)
|
||||
remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms)
|
||||
defaultProfile: "chrome",
|
||||
color: "#FF4500",
|
||||
headless: false,
|
||||
@@ -79,6 +81,8 @@ Notes:
|
||||
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
|
||||
the default browser ports shift to stay in the same “family” (control = gateway + 2).
|
||||
- `cdpUrl` defaults to `controlUrl + 1` when unset.
|
||||
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
|
||||
- `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks.
|
||||
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
|
||||
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
|
||||
- Default profile is `chrome` (extension relay). Use `defaultProfile: "clawd"` for the managed browser.
|
||||
@@ -154,6 +158,42 @@ Example:
|
||||
Use `profiles.<name>.cdpUrl` for **remote CDP** if you want the Gateway to talk
|
||||
directly to a Chromium-based browser instance without a remote control server.
|
||||
|
||||
Remote CDP URLs can include auth:
|
||||
- Query tokens (e.g., `https://provider.example?token=<token>`)
|
||||
- HTTP Basic auth (e.g., `https://user:pass@provider.example`)
|
||||
|
||||
Clawdbot preserves the auth when calling `/json/*` endpoints and when connecting
|
||||
to the CDP WebSocket. Prefer environment variables or secrets managers for
|
||||
tokens instead of committing them to config files.
|
||||
|
||||
### Browserless (hosted remote CDP)
|
||||
|
||||
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
|
||||
CDP endpoints over HTTPS. You can point a Clawdbot browser profile at a
|
||||
Browserless region endpoint and authenticate with your API key.
|
||||
|
||||
Example:
|
||||
```json5
|
||||
{
|
||||
browser: {
|
||||
enabled: true,
|
||||
defaultProfile: "browserless",
|
||||
remoteCdpTimeoutMs: 2000,
|
||||
remoteCdpHandshakeTimeoutMs: 4000,
|
||||
profiles: {
|
||||
browserless: {
|
||||
cdpUrl: "https://production-sfo.browserless.io?token=<BROWSERLESS_API_KEY>",
|
||||
color: "#00AA00"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Replace `<BROWSERLESS_API_KEY>` with your real Browserless token.
|
||||
- Choose the region endpoint that matches your Browserless account (see their docs).
|
||||
|
||||
### Running the control server on the browser machine
|
||||
|
||||
Run a standalone browser control server (recommended when your Gateway is remote):
|
||||
|
||||
+11
-2
@@ -45,10 +45,19 @@ function which(cmd) {
|
||||
}
|
||||
|
||||
function resolveRunner() {
|
||||
const bun = which("bun");
|
||||
if (bun) return { cmd: bun, kind: "bun" };
|
||||
// CLAWDBOT_PREFER_PNPM=1 forces pnpm (useful in Docker on architectures where Bun fails)
|
||||
const preferPnpm = process.env.CLAWDBOT_PREFER_PNPM === "1";
|
||||
if (!preferPnpm) {
|
||||
const bun = which("bun");
|
||||
if (bun) return { cmd: bun, kind: "bun" };
|
||||
}
|
||||
const pnpm = which("pnpm");
|
||||
if (pnpm) return { cmd: pnpm, kind: "pnpm" };
|
||||
if (preferPnpm) {
|
||||
// Fallback to bun if pnpm not found even when preferring pnpm
|
||||
const bun = which("bun");
|
||||
if (bun) return { cmd: bun, kind: "bun" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,53 @@ open ~/Projects/tmp/openai-image-gen-*/index.html # if ~/Projects/tmp exists; e
|
||||
Useful flags:
|
||||
|
||||
```bash
|
||||
# GPT image models with various options
|
||||
python3 {baseDir}/scripts/gen.py --count 16 --model gpt-image-1
|
||||
python3 {baseDir}/scripts/gen.py --prompt "ultra-detailed studio photo of a lobster astronaut" --count 4
|
||||
python3 {baseDir}/scripts/gen.py --size 1536x1024 --quality high --out-dir ./out/images
|
||||
python3 {baseDir}/scripts/gen.py --model gpt-image-1.5 --background transparent --output-format webp
|
||||
|
||||
# DALL-E 3 (note: count is automatically limited to 1)
|
||||
python3 {baseDir}/scripts/gen.py --model dall-e-3 --quality hd --size 1792x1024 --style vivid
|
||||
python3 {baseDir}/scripts/gen.py --model dall-e-3 --style natural --prompt "serene mountain landscape"
|
||||
|
||||
# DALL-E 2
|
||||
python3 {baseDir}/scripts/gen.py --model dall-e-2 --size 512x512 --count 4
|
||||
```
|
||||
|
||||
## Model-Specific Parameters
|
||||
|
||||
Different models support different parameter values. The script automatically selects appropriate defaults based on the model.
|
||||
|
||||
### Size
|
||||
|
||||
- **GPT image models** (`gpt-image-1`, `gpt-image-1-mini`, `gpt-image-1.5`): `1024x1024`, `1536x1024` (landscape), `1024x1536` (portrait), or `auto`
|
||||
- Default: `1024x1024`
|
||||
- **dall-e-3**: `1024x1024`, `1792x1024`, or `1024x1792`
|
||||
- Default: `1024x1024`
|
||||
- **dall-e-2**: `256x256`, `512x512`, or `1024x1024`
|
||||
- Default: `1024x1024`
|
||||
|
||||
### Quality
|
||||
|
||||
- **GPT image models**: `auto`, `high`, `medium`, or `low`
|
||||
- Default: `high`
|
||||
- **dall-e-3**: `hd` or `standard`
|
||||
- Default: `standard`
|
||||
- **dall-e-2**: `standard` only
|
||||
- Default: `standard`
|
||||
|
||||
### Other Notable Differences
|
||||
|
||||
- **dall-e-3** only supports generating 1 image at a time (`n=1`). The script automatically limits count to 1 when using this model.
|
||||
- **GPT image models** support additional parameters:
|
||||
- `--background`: `transparent`, `opaque`, or `auto` (default)
|
||||
- `--output-format`: `png` (default), `jpeg`, or `webp`
|
||||
- Note: `stream` and `moderation` are available via API but not yet implemented in this script
|
||||
- **dall-e-3** has a `--style` parameter: `vivid` (hyper-real, dramatic) or `natural` (more natural looking)
|
||||
|
||||
## Output
|
||||
|
||||
- `*.png` images
|
||||
- `*.png`, `*.jpeg`, or `*.webp` images (output format depends on model + `--output-format`)
|
||||
- `prompts.json` (prompt → file mapping)
|
||||
- `index.html` (thumbnail gallery)
|
||||
@@ -62,24 +62,53 @@ def pick_prompts(count: int) -> list[str]:
|
||||
return prompts
|
||||
|
||||
|
||||
def get_model_defaults(model: str) -> tuple[str, str]:
|
||||
"""Return (default_size, default_quality) for the given model."""
|
||||
if model == "dall-e-2":
|
||||
# quality will be ignored
|
||||
return ("1024x1024", "standard")
|
||||
elif model == "dall-e-3":
|
||||
return ("1024x1024", "standard")
|
||||
else:
|
||||
# GPT image or future models
|
||||
return ("1024x1024", "high")
|
||||
|
||||
|
||||
def request_images(
|
||||
api_key: str,
|
||||
prompt: str,
|
||||
model: str,
|
||||
size: str,
|
||||
quality: str,
|
||||
background: str = "",
|
||||
output_format: str = "",
|
||||
style: str = "",
|
||||
) -> dict:
|
||||
url = "https://api.openai.com/v1/images/generations"
|
||||
body = json.dumps(
|
||||
{
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"quality": quality,
|
||||
"n": 1,
|
||||
"response_format": "b64_json",
|
||||
}
|
||||
).encode("utf-8")
|
||||
args = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"size": size,
|
||||
"n": 1,
|
||||
}
|
||||
|
||||
# Quality parameter - dall-e-2 doesn't accept this parameter
|
||||
if model != "dall-e-2":
|
||||
args["quality"] = quality
|
||||
|
||||
if model.startswith("dall-e"):
|
||||
args["response_format"] = "b64_json"
|
||||
|
||||
if model.startswith("gpt-image"):
|
||||
if background:
|
||||
args["background"] = background
|
||||
if output_format:
|
||||
args["output_format"] = output_format
|
||||
|
||||
if model == "dall-e-3" and style:
|
||||
args["style"] = style
|
||||
|
||||
body = json.dumps(args).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
method="POST",
|
||||
@@ -136,8 +165,11 @@ def main() -> int:
|
||||
ap.add_argument("--prompt", help="Single prompt. If omitted, random prompts are generated.")
|
||||
ap.add_argument("--count", type=int, default=8, help="How many images to generate.")
|
||||
ap.add_argument("--model", default="gpt-image-1", help="Image model id.")
|
||||
ap.add_argument("--size", default="1024x1024", help="Image size (e.g. 1024x1024, 1536x1024).")
|
||||
ap.add_argument("--quality", default="high", help="Image quality (varies by model).")
|
||||
ap.add_argument("--size", default="", help="Image size (e.g. 1024x1024, 1536x1024). Defaults based on model if not specified.")
|
||||
ap.add_argument("--quality", default="", help="Image quality (e.g. high, standard). Defaults based on model if not specified.")
|
||||
ap.add_argument("--background", default="", help="Background transparency (GPT models only): transparent, opaque, or auto.")
|
||||
ap.add_argument("--output-format", default="", help="Output format (GPT models only): png, jpeg, or webp.")
|
||||
ap.add_argument("--style", default="", help="Image style (dall-e-3 only): vivid or natural.")
|
||||
ap.add_argument("--out-dir", default="", help="Output directory (default: ./tmp/openai-image-gen-<ts>).")
|
||||
args = ap.parse_args()
|
||||
|
||||
@@ -146,21 +178,46 @@ def main() -> int:
|
||||
print("Missing OPENAI_API_KEY", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# Apply model-specific defaults if not specified
|
||||
default_size, default_quality = get_model_defaults(args.model)
|
||||
size = args.size or default_size
|
||||
quality = args.quality or default_quality
|
||||
|
||||
count = args.count
|
||||
if args.model == "dall-e-3" and count > 1:
|
||||
print(f"Warning: dall-e-3 only supports generating 1 image at a time. Reducing count from {count} to 1.", file=sys.stderr)
|
||||
count = 1
|
||||
|
||||
out_dir = Path(args.out_dir).expanduser() if args.out_dir else default_out_dir()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
prompts = [args.prompt] * args.count if args.prompt else pick_prompts(args.count)
|
||||
prompts = [args.prompt] * count if args.prompt else pick_prompts(count)
|
||||
|
||||
# Determine file extension based on output format
|
||||
if args.model.startswith("gpt-image") and args.output_format:
|
||||
file_ext = args.output_format
|
||||
else:
|
||||
file_ext = "png"
|
||||
|
||||
items: list[dict] = []
|
||||
for idx, prompt in enumerate(prompts, start=1):
|
||||
print(f"[{idx}/{len(prompts)}] {prompt}")
|
||||
res = request_images(api_key, prompt, args.model, args.size, args.quality)
|
||||
res = request_images(
|
||||
api_key,
|
||||
prompt,
|
||||
args.model,
|
||||
size,
|
||||
quality,
|
||||
args.background,
|
||||
args.output_format,
|
||||
args.style,
|
||||
)
|
||||
b64 = res.get("data", [{}])[0].get("b64_json")
|
||||
if not b64:
|
||||
raise RuntimeError(f"Unexpected response: {json.dumps(res)[:400]}")
|
||||
png = base64.b64decode(b64)
|
||||
filename = f"{idx:03d}-{slugify(prompt)[:40]}.png"
|
||||
(out_dir / filename).write_bytes(png)
|
||||
image_bytes = base64.b64decode(b64)
|
||||
filename = f"{idx:03d}-{slugify(prompt)[:40]}.{file_ext}"
|
||||
(out_dir / filename).write_bytes(image_bytes)
|
||||
items.append({"prompt": prompt, "file": filename})
|
||||
|
||||
(out_dir / "prompts.json").write_text(json.dumps(items, indent=2), encoding="utf-8")
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
|
||||
import { createExecTool } from "./bash-tools.exec";
|
||||
import {
|
||||
getFinishedSession,
|
||||
getSession,
|
||||
resetProcessRegistryForTests,
|
||||
} from "./bash-process-registry";
|
||||
import { killProcessTree } from "./shell-utils";
|
||||
|
||||
afterEach(() => {
|
||||
resetProcessRegistryForTests();
|
||||
});
|
||||
|
||||
test("background exec is not killed when tool signal aborts", async () => {
|
||||
const tool = createExecTool({ allowBackground: true, backgroundMs: 0 });
|
||||
const abortController = new AbortController();
|
||||
|
||||
const result = await tool.execute(
|
||||
"toolcall",
|
||||
{ command: 'node -e "setTimeout(() => {}, 5000)"', background: true },
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
expect(result.details.status).toBe("running");
|
||||
const sessionId = (result.details as { sessionId: string }).sessionId;
|
||||
|
||||
abortController.abort();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
|
||||
const running = getSession(sessionId);
|
||||
const finished = getFinishedSession(sessionId);
|
||||
|
||||
try {
|
||||
expect(finished).toBeUndefined();
|
||||
expect(running?.exited).toBe(false);
|
||||
} finally {
|
||||
const pid = running?.pid;
|
||||
if (pid) killProcessTree(pid);
|
||||
}
|
||||
});
|
||||
@@ -263,6 +263,7 @@ export function createExecTool(
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
if (yielded || session.backgrounded) return;
|
||||
killSession(session);
|
||||
};
|
||||
|
||||
|
||||
@@ -99,6 +99,7 @@ describe("session_status tool", () => {
|
||||
expect(details.ok).toBe(true);
|
||||
expect(details.statusText).toContain("Clawdbot");
|
||||
expect(details.statusText).toContain("🧠 Model:");
|
||||
expect(details.statusText).not.toContain("OAuth/token status");
|
||||
});
|
||||
|
||||
it("errors for unknown session keys", async () => {
|
||||
|
||||
@@ -58,9 +58,7 @@ export function stripThoughtSignatures<T>(
|
||||
if (!block || typeof block !== "object") return block;
|
||||
const rec = block as ContentBlockWithSignature;
|
||||
const stripSnake = shouldStripSignature(rec.thought_signature);
|
||||
const stripCamel = includeCamelCase
|
||||
? shouldStripSignature(rec.thoughtSignature)
|
||||
: false;
|
||||
const stripCamel = includeCamelCase ? shouldStripSignature(rec.thoughtSignature) : false;
|
||||
if (!stripSnake && !stripCamel) {
|
||||
return block;
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
arguments: { path: "/tmp/foo" },
|
||||
thoughtSignature: "{\"id\":1}",
|
||||
thoughtSignature: '{"id":1}',
|
||||
},
|
||||
{
|
||||
type: "toolCall",
|
||||
@@ -192,7 +192,7 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
||||
{ type: "text", text: "ok" },
|
||||
{
|
||||
type: "text",
|
||||
text: "[Tool Call: read (ID: call_1)]\nArguments: {\n \"path\": \"/tmp/foo\"\n}",
|
||||
text: '[Tool Call: read (ID: call_1)]\nArguments: {\n "path": "/tmp/foo"\n}',
|
||||
},
|
||||
{
|
||||
type: "toolCall",
|
||||
|
||||
+60
@@ -261,4 +261,64 @@ describe("runEmbeddedPiAgent", () => {
|
||||
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
|
||||
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
|
||||
}, 20_000);
|
||||
it("returns role ordering error when session ends with a user turn", async () => {
|
||||
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
||||
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile);
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "seed user 1" }],
|
||||
});
|
||||
sessionManager.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "seed assistant" }],
|
||||
stopReason: "stop",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
usage: {
|
||||
input: 1,
|
||||
output: 1,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 2,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "seed user 2" }],
|
||||
});
|
||||
|
||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||
await ensureModels(cfg, agentDir);
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
prompt: "hello",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
timeoutMs: 5_000,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
expect(result.meta.error?.kind).toBe("role_ordering");
|
||||
expect(result.meta.error?.message).toMatch(/incorrect role information|roles must alternate/i);
|
||||
expect(result.payloads?.[0]?.text).toContain("Message ordering conflict");
|
||||
});
|
||||
});
|
||||
@@ -11,9 +11,7 @@ vi.mock("./pi-embedded-helpers.js", async () => {
|
||||
...actual,
|
||||
isGoogleModelApi: vi.fn(),
|
||||
downgradeGeminiHistory: vi.fn(),
|
||||
sanitizeSessionMessagesImages: vi
|
||||
.fn()
|
||||
.mockImplementation(async (msgs) => msgs),
|
||||
sanitizeSessionMessagesImages: vi.fn().mockImplementation(async (msgs) => msgs),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -30,9 +28,7 @@ describe("sanitizeSessionHistory", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(
|
||||
async (msgs) => msgs,
|
||||
);
|
||||
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(async (msgs) => msgs);
|
||||
// Default mock implementation
|
||||
vi.mocked(helpers.downgradeGeminiHistory).mockImplementation((msgs) => {
|
||||
if (!msgs) return [];
|
||||
|
||||
@@ -82,12 +82,13 @@ function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[]
|
||||
return violations;
|
||||
}
|
||||
|
||||
export function sanitizeToolsForGoogle<TSchemaType extends TSchema = TSchema, TResult = unknown>(
|
||||
params: {
|
||||
tools: AgentTool<TSchemaType, TResult>[];
|
||||
provider: string;
|
||||
},
|
||||
): AgentTool<TSchemaType, TResult>[] {
|
||||
export function sanitizeToolsForGoogle<
|
||||
TSchemaType extends TSchema = TSchema,
|
||||
TResult = unknown,
|
||||
>(params: {
|
||||
tools: AgentTool<TSchemaType, TResult>[];
|
||||
provider: string;
|
||||
}): AgentTool<TSchemaType, TResult>[] {
|
||||
if (params.provider !== "google-antigravity" && params.provider !== "google-gemini-cli") {
|
||||
return params.tools;
|
||||
}
|
||||
@@ -95,7 +96,9 @@ export function sanitizeToolsForGoogle<TSchemaType extends TSchema = TSchema, TR
|
||||
if (!tool.parameters || typeof tool.parameters !== "object") return tool;
|
||||
return {
|
||||
...tool,
|
||||
parameters: cleanToolSchemaForGemini(tool.parameters as Record<string, unknown>) as TSchemaType,
|
||||
parameters: cleanToolSchemaForGemini(
|
||||
tool.parameters as Record<string, unknown>,
|
||||
) as TSchemaType,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -274,6 +274,8 @@ export async function runEmbeddedPiAgent(
|
||||
provider,
|
||||
model: model.id,
|
||||
},
|
||||
systemPromptReport: attempt.systemPromptReport,
|
||||
error: { kind: "role_ordering", message: errorText },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export type EmbeddedPiRunMeta = {
|
||||
aborted?: boolean;
|
||||
systemPromptReport?: SessionSystemPromptReport;
|
||||
error?: {
|
||||
kind: "context_overflow" | "compaction_failure";
|
||||
kind: "context_overflow" | "compaction_failure" | "role_ordering";
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -22,10 +22,7 @@ export type SubscribeEmbeddedPiSessionParams = {
|
||||
blockReplyChunking?: BlockReplyChunking;
|
||||
onPartialReply?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||
onAssistantMessageStart?: () => void | Promise<void>;
|
||||
onAgentEvent?: (evt: {
|
||||
stream: string;
|
||||
data: Record<string, unknown>;
|
||||
}) => void | Promise<void>;
|
||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void | Promise<void>;
|
||||
enforceFinalTag?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -51,6 +51,8 @@ function buildSandboxBrowserResolvedConfig(params: {
|
||||
cdpProtocol: "http",
|
||||
cdpHost,
|
||||
cdpIsLoopback: true,
|
||||
remoteCdpTimeoutMs: 1500,
|
||||
remoteCdpHandshakeTimeoutMs: 3000,
|
||||
color: DEFAULT_CLAWD_BROWSER_COLOR,
|
||||
executablePath: undefined,
|
||||
headless: params.headless,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { resolveAgentDir } from "../../agents/agent-scope.js";
|
||||
import { buildAuthHealthSummary, formatRemainingShort } from "../../agents/auth-health.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
resolveAuthProfileDisplayLabel,
|
||||
@@ -32,7 +31,6 @@ import {
|
||||
formatUsageWindowSummary,
|
||||
loadProviderUsageSummary,
|
||||
resolveUsageProviderId,
|
||||
type UsageProviderId,
|
||||
} from "../../infra/provider-usage.js";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
@@ -283,48 +281,31 @@ export function createSessionStatusTool(opts?: {
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
});
|
||||
const providerForCard = resolved.entry.providerOverride?.trim() || configured.provider;
|
||||
const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
|
||||
const authHealth = buildAuthHealthSummary({
|
||||
store: authStore,
|
||||
cfg,
|
||||
});
|
||||
const oauthProfiles = authHealth.profiles.filter(
|
||||
(profile) => profile.type === "oauth" || profile.type === "token",
|
||||
);
|
||||
|
||||
const usageProviders = Array.from(
|
||||
new Set(
|
||||
oauthProfiles
|
||||
.map((profile) => resolveUsageProviderId(profile.provider))
|
||||
.filter((provider): provider is UsageProviderId => Boolean(provider)),
|
||||
),
|
||||
);
|
||||
const usageByProvider = new Map<string, string>();
|
||||
if (usageProviders.length > 0) {
|
||||
const usageProvider = resolveUsageProviderId(providerForCard);
|
||||
let usageLine: string | undefined;
|
||||
if (usageProvider) {
|
||||
try {
|
||||
const usageSummary = await loadProviderUsageSummary({
|
||||
timeoutMs: 3500,
|
||||
providers: usageProviders,
|
||||
providers: [usageProvider],
|
||||
agentDir,
|
||||
});
|
||||
for (const snapshot of usageSummary.providers) {
|
||||
const snapshot = usageSummary.providers.find((entry) => entry.provider === usageProvider);
|
||||
if (snapshot) {
|
||||
const formatted = formatUsageWindowSummary(snapshot, {
|
||||
now: Date.now(),
|
||||
maxWindows: 2,
|
||||
includeResets: true,
|
||||
});
|
||||
if (formatted) usageByProvider.set(snapshot.provider, formatted);
|
||||
if (formatted && !formatted.startsWith("error:")) {
|
||||
usageLine = `📊 Usage: ${formatted}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const usageProvider = resolveUsageProviderId(providerForCard);
|
||||
const usageLine =
|
||||
oauthProfiles.length === 0 && usageProvider && usageByProvider.has(usageProvider)
|
||||
? `📊 Usage: ${usageByProvider.get(usageProvider)}`
|
||||
: undefined;
|
||||
|
||||
const isGroup =
|
||||
resolved.entry.chatType === "group" ||
|
||||
resolved.entry.chatType === "room" ||
|
||||
@@ -370,53 +351,13 @@ export function createSessionStatusTool(opts?: {
|
||||
includeTranscriptUsage: false,
|
||||
});
|
||||
|
||||
const authStatusLines = (() => {
|
||||
if (oauthProfiles.length === 0) return [];
|
||||
const formatStatus = (status: string) => {
|
||||
if (status === "ok") return "ok";
|
||||
if (status === "static") return "static";
|
||||
if (status === "expiring") return "expiring";
|
||||
if (status === "missing") return "unknown";
|
||||
return "expired";
|
||||
};
|
||||
const profilesByProvider = new Map<string, typeof oauthProfiles>();
|
||||
for (const profile of oauthProfiles) {
|
||||
const current = profilesByProvider.get(profile.provider);
|
||||
if (current) current.push(profile);
|
||||
else profilesByProvider.set(profile.provider, [profile]);
|
||||
}
|
||||
const lines: string[] = ["OAuth/token status"];
|
||||
for (const [provider, profiles] of profilesByProvider) {
|
||||
const usageKey = resolveUsageProviderId(provider);
|
||||
const usage = usageKey ? usageByProvider.get(usageKey) : undefined;
|
||||
const usageSuffix = usage ? ` — usage: ${usage}` : "";
|
||||
lines.push(`- ${provider}${usageSuffix}`);
|
||||
for (const profile of profiles) {
|
||||
const labelText = profile.label || profile.profileId;
|
||||
const status = formatStatus(profile.status);
|
||||
const expiry =
|
||||
profile.status === "static"
|
||||
? ""
|
||||
: profile.expiresAt
|
||||
? ` expires in ${formatRemainingShort(profile.remainingMs)}`
|
||||
: " expires unknown";
|
||||
const source = profile.source !== "store" ? ` (${profile.source})` : "";
|
||||
lines.push(` - ${labelText} ${status}${expiry}${source}`);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
})();
|
||||
|
||||
const fullStatusText =
|
||||
authStatusLines.length > 0 ? `${statusText}\n\n${authStatusLines.join("\n")}` : statusText;
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: fullStatusText }],
|
||||
content: [{ type: "text", text: statusText }],
|
||||
details: {
|
||||
ok: true,
|
||||
sessionKey: resolved.key,
|
||||
changedModel,
|
||||
statusText: fullStatusText,
|
||||
statusText,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
shouldEmitToolResult: () => boolean;
|
||||
pendingToolTasks: Set<Promise<void>>;
|
||||
resetSessionAfterCompactionFailure: (reason: string) => Promise<boolean>;
|
||||
resetSessionAfterRoleOrderingConflict: (reason: string) => Promise<boolean>;
|
||||
isHeartbeat: boolean;
|
||||
sessionKey?: string;
|
||||
getActiveSessionEntry: () => SessionEntry | undefined;
|
||||
@@ -376,6 +377,17 @@ export async function runAgentTurnWithFallback(params: {
|
||||
didResetAfterCompactionFailure = true;
|
||||
continue;
|
||||
}
|
||||
if (embeddedError?.kind === "role_ordering") {
|
||||
const didReset = await params.resetSessionAfterRoleOrderingConflict(embeddedError.message);
|
||||
if (didReset) {
|
||||
return {
|
||||
kind: "final",
|
||||
payload: {
|
||||
text: "⚠️ Message ordering conflict. I've reset the conversation - please try again.",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
} catch (err) {
|
||||
@@ -395,6 +407,17 @@ export async function runAgentTurnWithFallback(params: {
|
||||
didResetAfterCompactionFailure = true;
|
||||
continue;
|
||||
}
|
||||
if (isRoleOrderingError) {
|
||||
const didReset = await params.resetSessionAfterRoleOrderingConflict(message);
|
||||
if (didReset) {
|
||||
return {
|
||||
kind: "final",
|
||||
payload: {
|
||||
text: "⚠️ Message ordering conflict. I've reset the conversation - please try again.",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-recover from Gemini session corruption by resetting the session
|
||||
if (
|
||||
|
||||
+62
-2
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import * as sessions from "../../config/sessions.js";
|
||||
import type { TypingMode } from "../../config/types.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import type { GetReplyOptions } from "../types.js";
|
||||
@@ -127,11 +128,14 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
try {
|
||||
const sessionId = "session";
|
||||
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
const sessionEntry = { sessionId, updatedAt: Date.now() };
|
||||
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
|
||||
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
|
||||
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
||||
await fs.writeFile(transcriptPath, "ok", "utf-8");
|
||||
|
||||
runEmbeddedPiAgentMock
|
||||
.mockImplementationOnce(async () => {
|
||||
@@ -175,11 +179,14 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
try {
|
||||
const sessionId = "session";
|
||||
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
const sessionEntry = { sessionId, updatedAt: Date.now() };
|
||||
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
|
||||
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
|
||||
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
||||
await fs.writeFile(transcriptPath, "ok", "utf-8");
|
||||
|
||||
runEmbeddedPiAgentMock
|
||||
.mockImplementationOnce(async () => ({
|
||||
@@ -212,6 +219,59 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
expect(payload).toMatchObject({ text: "ok" });
|
||||
expect(sessionStore.main.sessionId).not.toBe(sessionId);
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
|
||||
} finally {
|
||||
if (prevStateDir) {
|
||||
process.env.CLAWDBOT_STATE_DIR = prevStateDir;
|
||||
} else {
|
||||
delete process.env.CLAWDBOT_STATE_DIR;
|
||||
}
|
||||
}
|
||||
});
|
||||
it("resets the session after role ordering payloads", async () => {
|
||||
const prevStateDir = process.env.CLAWDBOT_STATE_DIR;
|
||||
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "clawdbot-session-role-ordering-"));
|
||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||
try {
|
||||
const sessionId = "session";
|
||||
const storePath = path.join(stateDir, "sessions", "sessions.json");
|
||||
const transcriptPath = sessions.resolveSessionTranscriptPath(sessionId);
|
||||
const sessionEntry = { sessionId, updatedAt: Date.now(), sessionFile: transcriptPath };
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
await fs.mkdir(path.dirname(storePath), { recursive: true });
|
||||
await fs.writeFile(storePath, JSON.stringify(sessionStore), "utf-8");
|
||||
await fs.mkdir(path.dirname(transcriptPath), { recursive: true });
|
||||
await fs.writeFile(transcriptPath, "ok", "utf-8");
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(async () => ({
|
||||
payloads: [{ text: "Message ordering conflict - please try again.", isError: true }],
|
||||
meta: {
|
||||
durationMs: 1,
|
||||
error: {
|
||||
kind: "role_ordering",
|
||||
message: 'messages: roles must alternate between "user" and "assistant"',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
storePath,
|
||||
});
|
||||
const res = await run();
|
||||
|
||||
const payload = Array.isArray(res) ? res[0] : res;
|
||||
expect(payload).toMatchObject({
|
||||
text: expect.stringContaining("Message ordering conflict"),
|
||||
});
|
||||
expect(payload.text?.toLowerCase()).toContain("reset");
|
||||
expect(sessionStore.main.sessionId).not.toBe(sessionId);
|
||||
await expect(fs.access(transcriptPath)).rejects.toBeDefined();
|
||||
|
||||
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8"));
|
||||
expect(persisted.main.sessionId).toBe(sessionStore.main.sessionId);
|
||||
} finally {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { setCliSessionId } from "../../agents/cli-session.js";
|
||||
import { lookupContextTokens } from "../../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||
@@ -8,6 +9,7 @@ import { queueEmbeddedPiMessage } from "../../agents/pi-embedded.js";
|
||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||
import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveSessionFilePath,
|
||||
resolveSessionTranscriptPath,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
@@ -209,11 +211,23 @@ export async function runReplyAgent(params: {
|
||||
});
|
||||
|
||||
let responseUsageLine: string | undefined;
|
||||
const resetSessionAfterCompactionFailure = async (reason: string): Promise<boolean> => {
|
||||
type SessionResetOptions = {
|
||||
failureLabel: string;
|
||||
buildLogMessage: (nextSessionId: string) => string;
|
||||
cleanupTranscripts?: boolean;
|
||||
};
|
||||
const resetSession = async ({
|
||||
failureLabel,
|
||||
buildLogMessage,
|
||||
cleanupTranscripts,
|
||||
}: SessionResetOptions): Promise<boolean> => {
|
||||
if (!sessionKey || !activeSessionStore || !storePath) return false;
|
||||
const prevEntry = activeSessionStore[sessionKey] ?? activeSessionEntry;
|
||||
if (!prevEntry) return false;
|
||||
const prevSessionId = cleanupTranscripts ? prevEntry.sessionId : undefined;
|
||||
const nextSessionId = crypto.randomUUID();
|
||||
const nextEntry: SessionEntry = {
|
||||
...(activeSessionStore[sessionKey] ?? activeSessionEntry),
|
||||
...prevEntry,
|
||||
sessionId: nextSessionId,
|
||||
updatedAt: Date.now(),
|
||||
systemSent: false,
|
||||
@@ -233,18 +247,42 @@ export async function runReplyAgent(params: {
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
`Failed to persist session reset after compaction failure (${sessionKey}): ${String(err)}`,
|
||||
`Failed to persist session reset after ${failureLabel} (${sessionKey}): ${String(err)}`,
|
||||
);
|
||||
}
|
||||
followupRun.run.sessionId = nextSessionId;
|
||||
followupRun.run.sessionFile = nextSessionFile;
|
||||
activeSessionEntry = nextEntry;
|
||||
activeIsNewSession = true;
|
||||
defaultRuntime.error(
|
||||
`Auto-compaction failed (${reason}). Restarting session ${sessionKey} -> ${nextSessionId} and retrying.`,
|
||||
);
|
||||
defaultRuntime.error(buildLogMessage(nextSessionId));
|
||||
if (cleanupTranscripts && prevSessionId) {
|
||||
const transcriptCandidates = new Set<string>();
|
||||
const resolved = resolveSessionFilePath(prevSessionId, prevEntry, { agentId });
|
||||
if (resolved) transcriptCandidates.add(resolved);
|
||||
transcriptCandidates.add(resolveSessionTranscriptPath(prevSessionId, agentId));
|
||||
for (const candidate of transcriptCandidates) {
|
||||
try {
|
||||
fs.unlinkSync(candidate);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const resetSessionAfterCompactionFailure = async (reason: string): Promise<boolean> =>
|
||||
resetSession({
|
||||
failureLabel: "compaction failure",
|
||||
buildLogMessage: (nextSessionId) =>
|
||||
`Auto-compaction failed (${reason}). Restarting session ${sessionKey} -> ${nextSessionId} and retrying.`,
|
||||
});
|
||||
const resetSessionAfterRoleOrderingConflict = async (reason: string): Promise<boolean> =>
|
||||
resetSession({
|
||||
failureLabel: "role ordering conflict",
|
||||
buildLogMessage: (nextSessionId) =>
|
||||
`Role ordering conflict (${reason}). Restarting session ${sessionKey} -> ${nextSessionId}.`,
|
||||
cleanupTranscripts: true,
|
||||
});
|
||||
try {
|
||||
const runOutcome = await runAgentTurnWithFallback({
|
||||
commandBody,
|
||||
@@ -260,6 +298,7 @@ export async function runReplyAgent(params: {
|
||||
shouldEmitToolResult,
|
||||
pendingToolTasks,
|
||||
resetSessionAfterCompactionFailure,
|
||||
resetSessionAfterRoleOrderingConflict,
|
||||
isHeartbeat,
|
||||
sessionKey,
|
||||
getActiveSessionEntry: () => activeSessionEntry,
|
||||
|
||||
@@ -168,6 +168,7 @@ export async function buildStatusReply(params: {
|
||||
const formatted = formatUsageWindowSummary(snapshot, {
|
||||
now: Date.now(),
|
||||
maxWindows: 2,
|
||||
includeResets: true,
|
||||
});
|
||||
if (formatted) usageByProvider.set(snapshot.provider, formatted);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { appendCdpPath, getHeadersWithAuth } from "./cdp.helpers.js";
|
||||
|
||||
describe("cdp.helpers", () => {
|
||||
it("preserves query params when appending CDP paths", () => {
|
||||
const url = appendCdpPath("https://example.com?token=abc", "/json/version");
|
||||
expect(url).toBe("https://example.com/json/version?token=abc");
|
||||
});
|
||||
|
||||
it("appends paths under a base prefix", () => {
|
||||
const url = appendCdpPath("https://example.com/chrome/?token=abc", "json/list");
|
||||
expect(url).toBe("https://example.com/chrome/json/list?token=abc");
|
||||
});
|
||||
|
||||
it("adds basic auth headers when credentials are present", () => {
|
||||
const headers = getHeadersWithAuth("https://user:pass@example.com");
|
||||
expect(headers.Authorization).toBe(`Basic ${Buffer.from("user:pass").toString("base64")}`);
|
||||
});
|
||||
|
||||
it("keeps preexisting authorization headers", () => {
|
||||
const headers = getHeadersWithAuth("https://user:pass@example.com", {
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
expect(headers.Authorization).toBe("Bearer token");
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,29 @@ export function isLoopbackHost(host: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const hasAuthHeader = Object.keys(headers).some((key) => key.toLowerCase() === "authorization");
|
||||
if (hasAuthHeader) return headers;
|
||||
if (parsed.username || parsed.password) {
|
||||
const auth = Buffer.from(`${parsed.username}:${parsed.password}`).toString("base64");
|
||||
return { ...headers, Authorization: `Basic ${auth}` };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function appendCdpPath(cdpUrl: string, path: string): string {
|
||||
const url = new URL(cdpUrl);
|
||||
const basePath = url.pathname.replace(/\/$/, "");
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
url.pathname = `${basePath}${suffix}`;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function createCdpSender(ws: WebSocket) {
|
||||
let nextId = 1;
|
||||
const pending = new Map<number, Pending>();
|
||||
@@ -75,11 +98,12 @@ function createCdpSender(ws: WebSocket) {
|
||||
return { send, closeWithError };
|
||||
}
|
||||
|
||||
export async function fetchJson<T>(url: string, timeoutMs = 1500): Promise<T> {
|
||||
export async function fetchJson<T>(url: string, timeoutMs = 1500, init?: RequestInit): Promise<T> {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { signal: ctrl.signal });
|
||||
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
|
||||
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()) as T;
|
||||
} finally {
|
||||
@@ -87,11 +111,28 @@ export async function fetchJson<T>(url: string, timeoutMs = 1500): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOk(url: string, timeoutMs = 1500, init?: RequestInit): Promise<void> {
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
|
||||
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export async function withCdpSocket<T>(
|
||||
wsUrl: string,
|
||||
fn: (send: CdpSendFn) => Promise<T>,
|
||||
opts?: { headers?: Record<string, string> },
|
||||
): Promise<T> {
|
||||
const ws = new WebSocket(wsUrl, { handshakeTimeout: 5000 });
|
||||
const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {});
|
||||
const ws = new WebSocket(wsUrl, {
|
||||
handshakeTimeout: 5000,
|
||||
...(Object.keys(headers).length ? { headers } : {}),
|
||||
});
|
||||
const { send, closeWithError } = createCdpSender(ws);
|
||||
|
||||
const openPromise = new Promise<void>((resolve, reject) => {
|
||||
|
||||
@@ -165,4 +165,20 @@ describe("cdp", () => {
|
||||
);
|
||||
expect(normalized).toBe("ws://example.com:9222/devtools/browser/ABC");
|
||||
});
|
||||
|
||||
it("propagates auth and query params onto normalized websocket URLs", () => {
|
||||
const normalized = normalizeCdpWsUrl(
|
||||
"ws://127.0.0.1:9222/devtools/browser/ABC",
|
||||
"https://user:pass@example.com?token=abc",
|
||||
);
|
||||
expect(normalized).toBe("wss://user:pass@example.com/devtools/browser/ABC?token=abc");
|
||||
});
|
||||
|
||||
it("upgrades ws to wss when CDP uses https", () => {
|
||||
const normalized = normalizeCdpWsUrl(
|
||||
"ws://production-sfo.browserless.io",
|
||||
"https://production-sfo.browserless.io?token=abc",
|
||||
);
|
||||
expect(normalized).toBe("wss://production-sfo.browserless.io/?token=abc");
|
||||
});
|
||||
});
|
||||
+17
-3
@@ -1,4 +1,6 @@
|
||||
import { fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
|
||||
import { appendCdpPath, fetchJson, isLoopbackHost, withCdpSocket } from "./cdp.helpers.js";
|
||||
|
||||
export { appendCdpPath, fetchJson, fetchOk, getHeadersWithAuth } from "./cdp.helpers.js";
|
||||
|
||||
export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
|
||||
const ws = new URL(wsUrl);
|
||||
@@ -9,6 +11,16 @@ export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
|
||||
if (cdpPort) ws.port = cdpPort;
|
||||
ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:";
|
||||
}
|
||||
if (cdp.protocol === "https:" && ws.protocol === "ws:") {
|
||||
ws.protocol = "wss:";
|
||||
}
|
||||
if (!ws.username && !ws.password && (cdp.username || cdp.password)) {
|
||||
ws.username = cdp.username;
|
||||
ws.password = cdp.password;
|
||||
}
|
||||
for (const [key, value] of cdp.searchParams.entries()) {
|
||||
if (!ws.searchParams.has(key)) ws.searchParams.append(key, value);
|
||||
}
|
||||
return ws.toString();
|
||||
}
|
||||
|
||||
@@ -68,8 +80,10 @@ export async function createTargetViaCdp(opts: {
|
||||
cdpUrl: string;
|
||||
url: string;
|
||||
}): Promise<{ targetId: string }> {
|
||||
const base = opts.cdpUrl.replace(/\/$/, "");
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`${base}/json/version`, 1500);
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(
|
||||
appendCdpPath(opts.cdpUrl, "/json/version"),
|
||||
1500,
|
||||
);
|
||||
const wsUrlRaw = String(version?.webSocketDebuggerUrl ?? "").trim();
|
||||
const wsUrl = wsUrlRaw ? normalizeCdpWsUrl(wsUrlRaw, opts.cdpUrl) : "";
|
||||
if (!wsUrl) throw new Error("CDP /json/version missing webSocketDebuggerUrl");
|
||||
|
||||
@@ -24,10 +24,12 @@ describe("browser default executable detection", () => {
|
||||
it("prefers default Chromium browser on macOS", async () => {
|
||||
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
|
||||
const argsStr = Array.isArray(args) ? args.join(" ") : "";
|
||||
if (cmd === "/usr/bin/osascript" && argsStr.includes("id of application")) {
|
||||
return "com.google.Chrome";
|
||||
if (cmd === "/usr/bin/plutil" && argsStr.includes("LSHandlers")) {
|
||||
return JSON.stringify([
|
||||
{ LSHandlerURLScheme: "http", LSHandlerRoleAll: "com.google.Chrome" },
|
||||
]);
|
||||
}
|
||||
if (cmd === "/usr/bin/osascript" && argsStr.includes("POSIX path")) {
|
||||
if (cmd === "/usr/bin/osascript" && argsStr.includes("path to application id")) {
|
||||
return "/Applications/Google Chrome.app";
|
||||
}
|
||||
if (cmd === "/usr/bin/defaults") {
|
||||
@@ -35,9 +37,11 @@ describe("browser default executable detection", () => {
|
||||
}
|
||||
return "";
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) =>
|
||||
String(p).includes("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
||||
);
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
||||
const value = String(p);
|
||||
if (value.includes("com.apple.launchservices.secure.plist")) return true;
|
||||
return value.includes("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome");
|
||||
});
|
||||
|
||||
const { resolveBrowserExecutableForPlatform } = await import("./chrome.executables.js");
|
||||
const exe = resolveBrowserExecutableForPlatform(
|
||||
@@ -52,14 +56,18 @@ describe("browser default executable detection", () => {
|
||||
it("falls back when default browser is non-Chromium on macOS", async () => {
|
||||
vi.mocked(execFileSync).mockImplementation((cmd, args) => {
|
||||
const argsStr = Array.isArray(args) ? args.join(" ") : "";
|
||||
if (cmd === "/usr/bin/osascript" && argsStr.includes("id of application")) {
|
||||
return "com.apple.Safari";
|
||||
if (cmd === "/usr/bin/plutil" && argsStr.includes("LSHandlers")) {
|
||||
return JSON.stringify([
|
||||
{ LSHandlerURLScheme: "http", LSHandlerRoleAll: "com.apple.Safari" },
|
||||
]);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) =>
|
||||
String(p).includes("Google Chrome.app/Contents/MacOS/Google Chrome"),
|
||||
);
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) => {
|
||||
const value = String(p);
|
||||
if (value.includes("com.apple.launchservices.secure.plist")) return true;
|
||||
return value.includes("Google Chrome.app/Contents/MacOS/Google Chrome");
|
||||
});
|
||||
|
||||
const { resolveBrowserExecutableForPlatform } = await import("./chrome.executables.js");
|
||||
const exe = resolveBrowserExecutableForPlatform(
|
||||
|
||||
@@ -95,12 +95,17 @@ function exists(filePath: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function execText(command: string, args: string[], timeoutMs = 1200): string | null {
|
||||
function execText(
|
||||
command: string,
|
||||
args: string[],
|
||||
timeoutMs = 1200,
|
||||
maxBuffer = 1024 * 1024,
|
||||
): string | null {
|
||||
try {
|
||||
const output = execFileSync(command, args, {
|
||||
timeout: timeoutMs,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1024 * 1024,
|
||||
maxBuffer,
|
||||
});
|
||||
return String(output ?? "").trim() || null;
|
||||
} catch {
|
||||
@@ -114,7 +119,12 @@ function inferKindFromIdentifier(identifier: string): BrowserExecutable["kind"]
|
||||
if (id.includes("edge")) return "edge";
|
||||
if (id.includes("chromium")) return "chromium";
|
||||
if (id.includes("canary")) return "canary";
|
||||
if (id.includes("opera") || id.includes("vivaldi") || id.includes("yandex") || id.includes("thebrowser")) {
|
||||
if (
|
||||
id.includes("opera") ||
|
||||
id.includes("vivaldi") ||
|
||||
id.includes("yandex") ||
|
||||
id.includes("thebrowser")
|
||||
) {
|
||||
return "chromium";
|
||||
}
|
||||
return "chrome";
|
||||
@@ -126,13 +136,12 @@ function inferKindFromExecutableName(name: string): BrowserExecutable["kind"] {
|
||||
if (lower.includes("edge") || lower.includes("msedge")) return "edge";
|
||||
if (lower.includes("chromium")) return "chromium";
|
||||
if (lower.includes("canary") || lower.includes("sxs")) return "canary";
|
||||
if (lower.includes("opera") || lower.includes("vivaldi") || lower.includes("yandex")) return "chromium";
|
||||
if (lower.includes("opera") || lower.includes("vivaldi") || lower.includes("yandex"))
|
||||
return "chromium";
|
||||
return "chrome";
|
||||
}
|
||||
|
||||
function detectDefaultChromiumExecutable(
|
||||
platform: NodeJS.Platform,
|
||||
): BrowserExecutable | null {
|
||||
function detectDefaultChromiumExecutable(platform: NodeJS.Platform): BrowserExecutable | null {
|
||||
if (platform === "darwin") return detectDefaultChromiumExecutableMac();
|
||||
if (platform === "linux") return detectDefaultChromiumExecutableLinux();
|
||||
if (platform === "win32") return detectDefaultChromiumExecutableWindows();
|
||||
@@ -140,14 +149,12 @@ function detectDefaultChromiumExecutable(
|
||||
}
|
||||
|
||||
function detectDefaultChromiumExecutableMac(): BrowserExecutable | null {
|
||||
const bundleId = execText("/usr/bin/osascript", [
|
||||
"-e",
|
||||
'id of application (path to default application for URL "http://example.com")',
|
||||
]);
|
||||
if (!bundleId || !CHROMIUM_BUNDLE_IDS.has(bundleId.trim())) return null;
|
||||
const bundleId = detectDefaultBrowserBundleIdMac();
|
||||
if (!bundleId || !CHROMIUM_BUNDLE_IDS.has(bundleId)) return null;
|
||||
|
||||
const appPathRaw = execText("/usr/bin/osascript", [
|
||||
"-e",
|
||||
'POSIX path of (path to default application for URL "http://example.com")',
|
||||
`POSIX path of (path to application id "${bundleId}")`,
|
||||
]);
|
||||
if (!appPathRaw) return null;
|
||||
const appPath = appPathRaw.trim().replace(/\/$/, "");
|
||||
@@ -162,6 +169,45 @@ function detectDefaultChromiumExecutableMac(): BrowserExecutable | null {
|
||||
return { kind: inferKindFromIdentifier(bundleId), path: exePath };
|
||||
}
|
||||
|
||||
function detectDefaultBrowserBundleIdMac(): string | null {
|
||||
const plistPath = path.join(
|
||||
os.homedir(),
|
||||
"Library/Preferences/com.apple.LaunchServices/com.apple.launchservices.secure.plist",
|
||||
);
|
||||
if (!exists(plistPath)) return null;
|
||||
const handlersRaw = execText(
|
||||
"/usr/bin/plutil",
|
||||
["-extract", "LSHandlers", "json", "-o", "-", "--", plistPath],
|
||||
2000,
|
||||
5 * 1024 * 1024,
|
||||
);
|
||||
if (!handlersRaw) return null;
|
||||
let handlers: unknown;
|
||||
try {
|
||||
handlers = JSON.parse(handlersRaw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!Array.isArray(handlers)) return null;
|
||||
|
||||
const resolveScheme = (scheme: string) => {
|
||||
let candidate: string | null = null;
|
||||
for (const entry of handlers) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const record = entry as Record<string, unknown>;
|
||||
if (record.LSHandlerURLScheme !== scheme) continue;
|
||||
const role =
|
||||
(typeof record.LSHandlerRoleAll === "string" && record.LSHandlerRoleAll) ||
|
||||
(typeof record.LSHandlerRoleViewer === "string" && record.LSHandlerRoleViewer) ||
|
||||
null;
|
||||
if (role) candidate = role;
|
||||
}
|
||||
return candidate;
|
||||
};
|
||||
|
||||
return resolveScheme("http") ?? resolveScheme("https");
|
||||
}
|
||||
|
||||
function detectDefaultChromiumExecutableLinux(): BrowserExecutable | null {
|
||||
const desktopId =
|
||||
execText("xdg-settings", ["get", "default-web-browser"]) ||
|
||||
@@ -185,8 +231,7 @@ function detectDefaultChromiumExecutableLinux(): BrowserExecutable | null {
|
||||
function detectDefaultChromiumExecutableWindows(): BrowserExecutable | null {
|
||||
const progId = readWindowsProgId();
|
||||
const command =
|
||||
(progId ? readWindowsCommandForProgId(progId) : null) ||
|
||||
readWindowsCommandForProgId("http");
|
||||
(progId ? readWindowsCommandForProgId(progId) : null) || readWindowsCommandForProgId("http");
|
||||
if (!command) return null;
|
||||
const expanded = expandWindowsEnvVars(command);
|
||||
const exePath = extractWindowsExecutablePath(expanded);
|
||||
@@ -243,7 +288,7 @@ function splitExecLine(line: string): string[] {
|
||||
let quoteChar = "";
|
||||
for (let i = 0; i < line.length; i += 1) {
|
||||
const ch = line[i];
|
||||
if ((ch === "\"" || ch === "'") && (!inQuotes || ch === quoteChar)) {
|
||||
if ((ch === '"' || ch === "'") && (!inQuotes || ch === quoteChar)) {
|
||||
if (inQuotes) {
|
||||
inQuotes = false;
|
||||
quoteChar = "";
|
||||
@@ -300,7 +345,7 @@ function readWindowsCommandForProgId(progId: string): string | null {
|
||||
function expandWindowsEnvVars(value: string): string {
|
||||
return value.replace(/%([^%]+)%/g, (_match, name) => {
|
||||
const key = String(name ?? "").trim();
|
||||
return key ? process.env[key] ?? `%${key}%` : _match;
|
||||
return key ? (process.env[key] ?? `%${key}%`) : _match;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -7,7 +7,8 @@ import WebSocket from "ws";
|
||||
import { ensurePortAvailable } from "../infra/ports.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { CONFIG_DIR } from "../utils.js";
|
||||
import { normalizeCdpWsUrl } from "./cdp.js";
|
||||
import { getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
|
||||
import { appendCdpPath } from "./cdp.helpers.js";
|
||||
import {
|
||||
type BrowserExecutable,
|
||||
resolveBrowserExecutableForPlatform,
|
||||
@@ -71,9 +72,10 @@ async function fetchChromeVersion(cdpUrl: string, timeoutMs = 500): Promise<Chro
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const base = cdpUrl.replace(/\/$/, "");
|
||||
const res = await fetch(`${base}/json/version`, {
|
||||
const versionUrl = appendCdpPath(cdpUrl, "/json/version");
|
||||
const res = await fetch(versionUrl, {
|
||||
signal: ctrl.signal,
|
||||
headers: getHeadersWithAuth(versionUrl),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as ChromeVersion;
|
||||
@@ -98,7 +100,11 @@ export async function getChromeWebSocketUrl(
|
||||
|
||||
async function canOpenWebSocket(wsUrl: string, timeoutMs = 800): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const ws = new WebSocket(wsUrl, { handshakeTimeout: timeoutMs });
|
||||
const headers = getHeadersWithAuth(wsUrl);
|
||||
const ws = new WebSocket(wsUrl, {
|
||||
handshakeTimeout: timeoutMs,
|
||||
...(Object.keys(headers).length ? { headers } : {}),
|
||||
});
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
try {
|
||||
|
||||
@@ -19,6 +19,8 @@ describe("browser config", () => {
|
||||
expect(clawd?.driver).toBe("clawd");
|
||||
expect(clawd?.cdpPort).toBe(18800);
|
||||
expect(clawd?.cdpUrl).toBe("http://127.0.0.1:18800");
|
||||
expect(resolved.remoteCdpTimeoutMs).toBe(1500);
|
||||
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000);
|
||||
});
|
||||
|
||||
it("derives default ports from CLAWDBOT_GATEWAY_PORT when unset", () => {
|
||||
@@ -52,6 +54,16 @@ describe("browser config", () => {
|
||||
expect(resolved.color).toBe("#FF4500");
|
||||
});
|
||||
|
||||
it("supports custom remote CDP timeouts", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://127.0.0.1:18791",
|
||||
remoteCdpTimeoutMs: 2200,
|
||||
remoteCdpHandshakeTimeoutMs: 5000,
|
||||
});
|
||||
expect(resolved.remoteCdpTimeoutMs).toBe(2200);
|
||||
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(5000);
|
||||
});
|
||||
|
||||
it("falls back to default color for invalid hex", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
controlUrl: "http://localhost:18791",
|
||||
|
||||
@@ -21,6 +21,8 @@ export type ResolvedBrowserConfig = {
|
||||
cdpProtocol: "http" | "https";
|
||||
cdpHost: string;
|
||||
cdpIsLoopback: boolean;
|
||||
remoteCdpTimeoutMs: number;
|
||||
remoteCdpHandshakeTimeoutMs: number;
|
||||
color: string;
|
||||
executablePath?: string;
|
||||
headless: boolean;
|
||||
@@ -61,6 +63,11 @@ function normalizeHexColor(raw: string | undefined) {
|
||||
return normalized.toUpperCase();
|
||||
}
|
||||
|
||||
function normalizeTimeoutMs(raw: number | undefined, fallback: number) {
|
||||
const value = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : fallback;
|
||||
return value < 0 ? fallback : value;
|
||||
}
|
||||
|
||||
export function parseHttpUrl(raw: string, label: string) {
|
||||
const trimmed = raw.trim();
|
||||
const parsed = new URL(trimmed);
|
||||
@@ -149,6 +156,11 @@ export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBr
|
||||
);
|
||||
const controlPort = controlInfo.port;
|
||||
const defaultColor = normalizeHexColor(cfg?.color);
|
||||
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
|
||||
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
|
||||
cfg?.remoteCdpHandshakeTimeoutMs,
|
||||
Math.max(2000, remoteCdpTimeoutMs * 2),
|
||||
);
|
||||
|
||||
const derivedCdpRange = deriveDefaultBrowserCdpPortRange(controlPort);
|
||||
|
||||
@@ -206,6 +218,8 @@ export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBr
|
||||
cdpProtocol,
|
||||
cdpHost: cdpInfo.parsed.hostname,
|
||||
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
|
||||
remoteCdpTimeoutMs,
|
||||
remoteCdpHandshakeTimeoutMs,
|
||||
color: defaultColor,
|
||||
executablePath,
|
||||
headless,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "playwright-core";
|
||||
import { chromium } from "playwright-core";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { getHeadersWithAuth } from "./cdp.helpers.js";
|
||||
import { getChromeWebSocketUrl } from "./chrome.js";
|
||||
|
||||
export type BrowserConsoleMessage = {
|
||||
@@ -268,7 +269,8 @@ async function connectBrowser(cdpUrl: string): Promise<ConnectedBrowser> {
|
||||
const timeout = 5000 + attempt * 2000;
|
||||
const wsUrl = await getChromeWebSocketUrl(normalized, timeout).catch(() => null);
|
||||
const endpoint = wsUrl ?? normalized;
|
||||
const browser = await chromium.connectOverCDP(endpoint, { timeout });
|
||||
const headers = getHeadersWithAuth(endpoint);
|
||||
const browser = await chromium.connectOverCDP(endpoint, { timeout, headers });
|
||||
const connected: ConnectedBrowser = { browser, cdpUrl: normalized };
|
||||
cached = connected;
|
||||
observeBrowser(browser);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
|
||||
import { createTargetViaCdp, normalizeCdpWsUrl } from "./cdp.js";
|
||||
import { appendCdpPath, createTargetViaCdp, getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
|
||||
import {
|
||||
isChromeCdpReady,
|
||||
isChromeReachable,
|
||||
@@ -50,7 +50,8 @@ async function fetchJson<T>(url: string, timeoutMs = 1500, init?: RequestInit):
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
||||
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
|
||||
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return (await res.json()) as T;
|
||||
} finally {
|
||||
@@ -62,7 +63,8 @@ async function fetchOk(url: string, timeoutMs = 1500, init?: RequestInit): Promi
|
||||
const ctrl = new AbortController();
|
||||
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
||||
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
|
||||
const res = await fetch(url, { ...init, headers, signal: ctrl.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
@@ -106,7 +108,7 @@ function createProfileContext(
|
||||
webSocketDebuggerUrl?: string;
|
||||
type?: string;
|
||||
}>
|
||||
>(`${profile.cdpUrl.replace(/\/$/, "")}/json/list`);
|
||||
>(appendCdpPath(profile.cdpUrl, "/json/list"));
|
||||
return raw
|
||||
.map((t) => ({
|
||||
targetId: t.id ?? "",
|
||||
@@ -146,8 +148,13 @@ function createProfileContext(
|
||||
type?: string;
|
||||
};
|
||||
|
||||
const base = profile.cdpUrl.replace(/\/$/, "");
|
||||
const endpoint = `${base}/json/new?${encoded}`;
|
||||
const endpointUrl = new URL(appendCdpPath(profile.cdpUrl, "/json/new"));
|
||||
const endpoint = endpointUrl.search
|
||||
? (() => {
|
||||
endpointUrl.searchParams.set("url", url);
|
||||
return endpointUrl.toString();
|
||||
})()
|
||||
: `${endpointUrl.toString()}?${encoded}`;
|
||||
const created = await fetchJson<CdpTarget>(endpoint, 1500, {
|
||||
method: "PUT",
|
||||
}).catch(async (err) => {
|
||||
@@ -164,18 +171,41 @@ function createProfileContext(
|
||||
targetId: created.id,
|
||||
title: created.title ?? "",
|
||||
url: created.url ?? url,
|
||||
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, base),
|
||||
wsUrl: normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl),
|
||||
type: created.type,
|
||||
};
|
||||
};
|
||||
|
||||
const isReachable = async (timeoutMs = 300) => {
|
||||
const wsTimeout = Math.max(200, Math.min(2000, timeoutMs * 2));
|
||||
return await isChromeCdpReady(profile.cdpUrl, timeoutMs, wsTimeout);
|
||||
const resolveRemoteHttpTimeout = (timeoutMs: number | undefined) => {
|
||||
if (profile.cdpIsLoopback) return timeoutMs ?? 300;
|
||||
const resolved = state().resolved;
|
||||
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
|
||||
return Math.max(Math.floor(timeoutMs), resolved.remoteCdpTimeoutMs);
|
||||
}
|
||||
return resolved.remoteCdpTimeoutMs;
|
||||
};
|
||||
|
||||
const isHttpReachable = async (timeoutMs = 300) => {
|
||||
return await isChromeReachable(profile.cdpUrl, timeoutMs);
|
||||
const resolveRemoteWsTimeout = (timeoutMs: number | undefined) => {
|
||||
if (profile.cdpIsLoopback) {
|
||||
const base = timeoutMs ?? 300;
|
||||
return Math.max(200, Math.min(2000, base * 2));
|
||||
}
|
||||
const resolved = state().resolved;
|
||||
if (typeof timeoutMs === "number" && Number.isFinite(timeoutMs)) {
|
||||
return Math.max(Math.floor(timeoutMs) * 2, resolved.remoteCdpHandshakeTimeoutMs);
|
||||
}
|
||||
return resolved.remoteCdpHandshakeTimeoutMs;
|
||||
};
|
||||
|
||||
const isReachable = async (timeoutMs?: number) => {
|
||||
const httpTimeout = resolveRemoteHttpTimeout(timeoutMs);
|
||||
const wsTimeout = resolveRemoteWsTimeout(timeoutMs);
|
||||
return await isChromeCdpReady(profile.cdpUrl, httpTimeout, wsTimeout);
|
||||
};
|
||||
|
||||
const isHttpReachable = async (timeoutMs?: number) => {
|
||||
const httpTimeout = resolveRemoteHttpTimeout(timeoutMs);
|
||||
return await isChromeReachable(profile.cdpUrl, httpTimeout);
|
||||
};
|
||||
|
||||
const attachRunning = (running: NonNullable<ProfileRuntimeState["running"]>) => {
|
||||
@@ -327,7 +357,6 @@ function createProfileContext(
|
||||
};
|
||||
|
||||
const focusTab = async (targetId: string): Promise<void> => {
|
||||
const base = profile.cdpUrl.replace(/\/$/, "");
|
||||
const tabs = await listTabs();
|
||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||
if (!resolved.ok) {
|
||||
@@ -336,13 +365,12 @@ function createProfileContext(
|
||||
}
|
||||
throw new Error("tab not found");
|
||||
}
|
||||
await fetchOk(`${base}/json/activate/${resolved.targetId}`);
|
||||
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/activate/${resolved.targetId}`));
|
||||
const profileState = getProfileState();
|
||||
profileState.lastTargetId = resolved.targetId;
|
||||
};
|
||||
|
||||
const closeTab = async (targetId: string): Promise<void> => {
|
||||
const base = profile.cdpUrl.replace(/\/$/, "");
|
||||
const tabs = await listTabs();
|
||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||
if (!resolved.ok) {
|
||||
@@ -351,7 +379,7 @@ function createProfileContext(
|
||||
}
|
||||
throw new Error("tab not found");
|
||||
}
|
||||
await fetchOk(`${base}/json/close/${resolved.targetId}`);
|
||||
await fetchOk(appendCdpPath(profile.cdpUrl, `/json/close/${resolved.targetId}`));
|
||||
};
|
||||
|
||||
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
|
||||
|
||||
@@ -128,6 +128,12 @@ vi.mock("./cdp.js", () => ({
|
||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||
snapshotAria: cdpMocks.snapshotAria,
|
||||
getHeadersWithAuth: vi.fn(() => ({})),
|
||||
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
|
||||
const base = cdpUrl.replace(/\/$/, "");
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${base}${suffix}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai.js", () => pwMocks);
|
||||
|
||||
@@ -129,6 +129,12 @@ vi.mock("./cdp.js", () => ({
|
||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||
snapshotAria: cdpMocks.snapshotAria,
|
||||
getHeadersWithAuth: vi.fn(() => ({})),
|
||||
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
|
||||
const base = cdpUrl.replace(/\/$/, "");
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${base}${suffix}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai.js", () => pwMocks);
|
||||
|
||||
@@ -128,6 +128,12 @@ vi.mock("./cdp.js", () => ({
|
||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||
snapshotAria: cdpMocks.snapshotAria,
|
||||
getHeadersWithAuth: vi.fn(() => ({})),
|
||||
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
|
||||
const base = cdpUrl.replace(/\/$/, "");
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${base}${suffix}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai.js", () => pwMocks);
|
||||
|
||||
@@ -128,6 +128,12 @@ vi.mock("./cdp.js", () => ({
|
||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||
snapshotAria: cdpMocks.snapshotAria,
|
||||
getHeadersWithAuth: vi.fn(() => ({})),
|
||||
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
|
||||
const base = cdpUrl.replace(/\/$/, "");
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${base}${suffix}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai.js", () => pwMocks);
|
||||
|
||||
@@ -128,6 +128,12 @@ vi.mock("./cdp.js", () => ({
|
||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||
snapshotAria: cdpMocks.snapshotAria,
|
||||
getHeadersWithAuth: vi.fn(() => ({})),
|
||||
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
|
||||
const base = cdpUrl.replace(/\/$/, "");
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${base}${suffix}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai.js", () => pwMocks);
|
||||
|
||||
@@ -128,6 +128,12 @@ vi.mock("./cdp.js", () => ({
|
||||
createTargetViaCdp: cdpMocks.createTargetViaCdp,
|
||||
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
|
||||
snapshotAria: cdpMocks.snapshotAria,
|
||||
getHeadersWithAuth: vi.fn(() => ({})),
|
||||
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
|
||||
const base = cdpUrl.replace(/\/$/, "");
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${base}${suffix}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./pw-ai.js", () => pwMocks);
|
||||
|
||||
+17439
-15246
File diff suppressed because it is too large.
Load diff
@@ -238,7 +238,11 @@ export function registerConfigCli(program: Command) {
|
||||
defaultRuntime.log(JSON.stringify(res.value ?? null, null, 2));
|
||||
return;
|
||||
}
|
||||
if (typeof res.value === "string" || typeof res.value === "number" || typeof res.value === "boolean") {
|
||||
if (
|
||||
typeof res.value === "string" ||
|
||||
typeof res.value === "number" ||
|
||||
typeof res.value === "boolean"
|
||||
) {
|
||||
defaultRuntime.log(String(res.value));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -225,7 +225,11 @@ describe("daemon-cli coverage", () => {
|
||||
});
|
||||
|
||||
const jsonLine = runtimeLogs.find((line) => line.trim().startsWith("{"));
|
||||
const parsed = JSON.parse(jsonLine ?? "{}") as { ok?: boolean; action?: string; result?: string };
|
||||
const parsed = JSON.parse(jsonLine ?? "{}") as {
|
||||
ok?: boolean;
|
||||
action?: string;
|
||||
result?: string;
|
||||
};
|
||||
expect(parsed.ok).toBe(true);
|
||||
expect(parsed.action).toBe("install");
|
||||
expect(parsed.result).toBe("installed");
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
listPairingChannels,
|
||||
notifyPairingApproved,
|
||||
} from "../channels/plugins/pairing.js";
|
||||
import { listPairingChannels, notifyPairingApproved } from "../channels/plugins/pairing.js";
|
||||
import { normalizeChannelId } from "../channels/plugins/index.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
|
||||
|
||||
@@ -378,9 +378,7 @@ export function registerPluginsCli(program: Command) {
|
||||
continue;
|
||||
}
|
||||
if (record.source !== "npm") {
|
||||
defaultRuntime.log(
|
||||
chalk.yellow(`Skipping "${pluginId}" (source: ${record.source}).`),
|
||||
);
|
||||
defaultRuntime.log(chalk.yellow(`Skipping "${pluginId}" (source: ${record.source}).`));
|
||||
continue;
|
||||
}
|
||||
if (!record.spec) {
|
||||
@@ -412,9 +410,7 @@ export function registerPluginsCli(program: Command) {
|
||||
if (currentVersion && probe.version && currentVersion === probe.version) {
|
||||
defaultRuntime.log(`${pluginId} is up to date (${currentLabel}).`);
|
||||
} else {
|
||||
defaultRuntime.log(
|
||||
`Would update ${pluginId}: ${currentLabel} → ${nextVersion}.`,
|
||||
);
|
||||
defaultRuntime.log(`Would update ${pluginId}: ${currentLabel} → ${nextVersion}.`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -372,8 +372,6 @@ async function resolveSshTarget(
|
||||
});
|
||||
if (!target) return { target: rawTarget, identity: identity ?? undefined };
|
||||
const identityFile =
|
||||
identity ??
|
||||
config.identityFiles.find((entry) => entry.trim().length > 0)?.trim() ??
|
||||
undefined;
|
||||
identity ?? config.identityFiles.find((entry) => entry.trim().length > 0)?.trim() ?? undefined;
|
||||
return { target, identity: identityFile };
|
||||
}
|
||||
@@ -424,7 +424,11 @@ export async function modelsStatusCommand(
|
||||
timeoutMs: 3500,
|
||||
});
|
||||
for (const snapshot of usageSummary.providers) {
|
||||
const formatted = formatUsageWindowSummary(snapshot, { now: Date.now(), maxWindows: 2 });
|
||||
const formatted = formatUsageWindowSummary(snapshot, {
|
||||
now: Date.now(),
|
||||
maxWindows: 2,
|
||||
includeResets: true,
|
||||
});
|
||||
if (formatted) {
|
||||
usageByProvider.set(snapshot.provider, formatted);
|
||||
}
|
||||
|
||||
@@ -291,9 +291,7 @@ export async function statusAllCommand(
|
||||
: gatewayProbe?.error
|
||||
? `unreachable (${gatewayProbe.error})`
|
||||
: "unreachable";
|
||||
const gatewayAuth = gatewayReachable
|
||||
? ` · auth ${formatGatewayAuthUsed(probeAuth)}`
|
||||
: "";
|
||||
const gatewayAuth = gatewayReachable ? ` · auth ${formatGatewayAuthUsed(probeAuth)}` : "";
|
||||
const gatewaySelfLine =
|
||||
gatewaySelf?.host || gatewaySelf?.ip || gatewaySelf?.version || gatewaySelf?.platform
|
||||
? [
|
||||
|
||||
@@ -28,18 +28,18 @@ describe("config discord", () => {
|
||||
dm: {
|
||||
enabled: true,
|
||||
allowFrom: ["steipete"],
|
||||
groupEnabled: true,
|
||||
groupChannels: ["clawd-dm"],
|
||||
},
|
||||
actions: {
|
||||
emojiUploads: true,
|
||||
stickerUploads: false,
|
||||
channels: true,
|
||||
},
|
||||
guilds: {
|
||||
"123": {
|
||||
slug: "friends-of-clawd",
|
||||
requireMention: false,
|
||||
groupEnabled: true,
|
||||
groupChannels: ["clawd-dm"],
|
||||
},
|
||||
actions: {
|
||||
emojiUploads: true,
|
||||
stickerUploads: false,
|
||||
channels: true,
|
||||
},
|
||||
guilds: {
|
||||
"123": {
|
||||
slug: "friends-of-clawd",
|
||||
requireMention: false,
|
||||
users: ["steipete"],
|
||||
channels: {
|
||||
general: { allow: true },
|
||||
|
||||
@@ -167,6 +167,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"commands.useAccessGroups": "Use Access Groups",
|
||||
"ui.seamColor": "Accent Color",
|
||||
"browser.controlUrl": "Browser Control URL",
|
||||
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
|
||||
"browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)",
|
||||
"session.dmScope": "DM Session Scope",
|
||||
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
|
||||
"messages.ackReaction": "Ack Reaction Emoji",
|
||||
@@ -304,7 +306,8 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"plugins.installs.*.source": 'Install source ("npm", "archive", or "path").',
|
||||
"plugins.installs.*.spec": "Original npm spec used for install (if source is npm).",
|
||||
"plugins.installs.*.sourcePath": "Original archive/path used for install (if any).",
|
||||
"plugins.installs.*.installPath": "Resolved install directory (usually ~/.clawdbot/extensions/<id>).",
|
||||
"plugins.installs.*.installPath":
|
||||
"Resolved install directory (usually ~/.clawdbot/extensions/<id>).",
|
||||
"plugins.installs.*.version": "Version recorded at install time (if available).",
|
||||
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
|
||||
"agents.defaults.model.primary": "Primary model (provider/model).",
|
||||
|
||||
@@ -21,6 +21,10 @@ export type BrowserConfig = {
|
||||
controlToken?: string;
|
||||
/** Base URL of the CDP endpoint. Default: controlUrl with port + 1. */
|
||||
cdpUrl?: string;
|
||||
/** Remote CDP HTTP timeout (ms). Default: 1500. */
|
||||
remoteCdpTimeoutMs?: number;
|
||||
/** Remote CDP WebSocket handshake timeout (ms). Default: max(remoteCdpTimeoutMs * 2, 2000). */
|
||||
remoteCdpHandshakeTimeoutMs?: number;
|
||||
/** Accent color for the clawd browser profile (hex). Default: #FF4500 */
|
||||
color?: string;
|
||||
/** Override the browser executable path (all platforms). */
|
||||
|
||||
@@ -10,11 +10,9 @@ import {
|
||||
export const SessionSchema = z
|
||||
.object({
|
||||
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
||||
dmScope: z.union([
|
||||
z.literal("main"),
|
||||
z.literal("per-peer"),
|
||||
z.literal("per-channel-peer"),
|
||||
]).optional(),
|
||||
dmScope: z
|
||||
.union([z.literal("main"), z.literal("per-peer"), z.literal("per-channel-peer")])
|
||||
.optional(),
|
||||
resetTriggers: z.array(z.string()).optional(),
|
||||
idleMinutes: z.number().int().positive().optional(),
|
||||
heartbeatIdleMinutes: z.number().int().positive().optional(),
|
||||
|
||||
@@ -67,6 +67,8 @@ export const ClawdbotSchema = z
|
||||
controlUrl: z.string().optional(),
|
||||
controlToken: z.string().optional(),
|
||||
cdpUrl: z.string().optional(),
|
||||
remoteCdpTimeoutMs: z.number().int().nonnegative().optional(),
|
||||
remoteCdpHandshakeTimeoutMs: z.number().int().nonnegative().optional(),
|
||||
color: z.string().optional(),
|
||||
executablePath: z.string().optional(),
|
||||
headless: z.boolean().optional(),
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("parseSystemdExecStart", () => {
|
||||
});
|
||||
|
||||
it("preserves quoted arguments", () => {
|
||||
const execStart = "/usr/bin/clawdbot gateway start --name \"My Bot\"";
|
||||
const execStart = '/usr/bin/clawdbot gateway start --name "My Bot"';
|
||||
expect(parseSystemdExecStart(execStart)).toEqual([
|
||||
"/usr/bin/clawdbot",
|
||||
"gateway",
|
||||
|
||||
+12
-29
@@ -1,9 +1,6 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type {
|
||||
GatewayAuthConfig,
|
||||
GatewayTailscaleMode,
|
||||
} from "../config/config.js";
|
||||
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
||||
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
|
||||
|
||||
export type ResolvedGatewayAuth = {
|
||||
@@ -62,14 +59,13 @@ function isLocalDirectRequest(req?: IncomingMessage): boolean {
|
||||
if (!isLoopbackAddress(clientIp)) return false;
|
||||
|
||||
const host = getHostName(req.headers?.host);
|
||||
const hostIsLocal =
|
||||
host === "localhost" || host === "127.0.0.1" || host === "::1";
|
||||
const hostIsLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
||||
const hostIsTailscaleServe = host.endsWith(".ts.net");
|
||||
|
||||
const hasForwarded = Boolean(
|
||||
req.headers?.["x-forwarded-for"] ||
|
||||
req.headers?.["x-real-ip"] ||
|
||||
req.headers?.["x-forwarded-host"],
|
||||
req.headers?.["x-real-ip"] ||
|
||||
req.headers?.["x-forwarded-host"],
|
||||
);
|
||||
|
||||
return (hostIsLocal || hostIsTailscaleServe) && !hasForwarded;
|
||||
@@ -81,17 +77,11 @@ function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
||||
if (typeof login !== "string" || !login.trim()) return null;
|
||||
const nameRaw = req.headers["tailscale-user-name"];
|
||||
const profilePic = req.headers["tailscale-user-profile-pic"];
|
||||
const name =
|
||||
typeof nameRaw === "string" && nameRaw.trim()
|
||||
? nameRaw.trim()
|
||||
: login.trim();
|
||||
const name = typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : login.trim();
|
||||
return {
|
||||
login: login.trim(),
|
||||
name,
|
||||
profilePic:
|
||||
typeof profilePic === "string" && profilePic.trim()
|
||||
? profilePic.trim()
|
||||
: undefined,
|
||||
profilePic: typeof profilePic === "string" && profilePic.trim() ? profilePic.trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,17 +89,14 @@ function hasTailscaleProxyHeaders(req?: IncomingMessage): boolean {
|
||||
if (!req) return false;
|
||||
return Boolean(
|
||||
req.headers["x-forwarded-for"] &&
|
||||
req.headers["x-forwarded-proto"] &&
|
||||
req.headers["x-forwarded-host"],
|
||||
req.headers["x-forwarded-proto"] &&
|
||||
req.headers["x-forwarded-host"],
|
||||
);
|
||||
}
|
||||
|
||||
function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
|
||||
if (!req) return false;
|
||||
return (
|
||||
isLoopbackAddress(req.socket?.remoteAddress) &&
|
||||
hasTailscaleProxyHeaders(req)
|
||||
);
|
||||
return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req);
|
||||
}
|
||||
|
||||
export function resolveGatewayAuth(params: {
|
||||
@@ -120,13 +107,11 @@ export function resolveGatewayAuth(params: {
|
||||
const authConfig = params.authConfig ?? {};
|
||||
const env = params.env ?? process.env;
|
||||
const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
|
||||
const password =
|
||||
authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined;
|
||||
const password = authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined;
|
||||
const mode: ResolvedGatewayAuth["mode"] =
|
||||
authConfig.mode ?? (password ? "password" : token ? "token" : "none");
|
||||
const allowTailscale =
|
||||
authConfig.allowTailscale ??
|
||||
(params.tailscaleMode === "serve" && mode !== "password");
|
||||
authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password");
|
||||
return {
|
||||
mode,
|
||||
token,
|
||||
@@ -142,9 +127,7 @@ export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
|
||||
);
|
||||
}
|
||||
if (auth.mode === "password" && !auth.password) {
|
||||
throw new Error(
|
||||
"gateway auth mode is password, but no password was configured",
|
||||
);
|
||||
throw new Error("gateway auth mode is password, but no password was configured");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,9 @@ export async function startGatewayNodeBridge(params: {
|
||||
});
|
||||
if (started.port > 0) {
|
||||
const scheme = params.bridgeTls?.enabled ? "tls" : "tcp";
|
||||
params.logBridge.info(`listening on ${scheme}://${params.bridgeHost}:${started.port} (node)`);
|
||||
params.logBridge.info(
|
||||
`listening on ${scheme}://${params.bridgeHost}:${started.port} (node)`,
|
||||
);
|
||||
return { bridge: started, nodePresenceTimers };
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -48,7 +48,8 @@ export async function startNodeBridgeServer(opts: NodeBridgeServerOpts): Promise
|
||||
const loopbackHost = "127.0.0.1";
|
||||
|
||||
const listeners: Array<{ host: string; server: net.Server }> = [];
|
||||
const createServer = () => (opts.tls ? tls.createServer(opts.tls, onConnection) : net.createServer(onConnection));
|
||||
const createServer = () =>
|
||||
opts.tls ? tls.createServer(opts.tls, onConnection) : net.createServer(onConnection);
|
||||
const primary = createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => reject(err);
|
||||
|
||||
@@ -249,21 +249,32 @@ function deriveWindowLabel(payload: Record<string, unknown>): string {
|
||||
}
|
||||
|
||||
function deriveUsedPercent(payload: Record<string, unknown>): number | null {
|
||||
const percentRaw = pickNumber(payload, PERCENT_KEYS);
|
||||
if (percentRaw !== undefined) {
|
||||
const normalized = percentRaw <= 1 ? percentRaw * 100 : percentRaw;
|
||||
return clampPercent(normalized);
|
||||
const total = pickNumber(payload, TOTAL_KEYS);
|
||||
let used = pickNumber(payload, USED_KEYS);
|
||||
const remaining = pickNumber(payload, REMAINING_KEYS);
|
||||
if (used === undefined && remaining !== undefined && total !== undefined) {
|
||||
used = total - remaining;
|
||||
}
|
||||
|
||||
const total = pickNumber(payload, TOTAL_KEYS);
|
||||
if (!total || total <= 0) return null;
|
||||
let used = pickNumber(payload, USED_KEYS);
|
||||
if (used === undefined) {
|
||||
const remaining = pickNumber(payload, REMAINING_KEYS);
|
||||
if (remaining !== undefined) used = total - remaining;
|
||||
const fromCounts =
|
||||
total && total > 0 && used !== undefined && Number.isFinite(used)
|
||||
? clampPercent((used / total) * 100)
|
||||
: null;
|
||||
|
||||
const percentRaw = pickNumber(payload, PERCENT_KEYS);
|
||||
if (percentRaw !== undefined) {
|
||||
const normalized = clampPercent(percentRaw <= 1 ? percentRaw * 100 : percentRaw);
|
||||
if (fromCounts !== null) {
|
||||
const inverted = clampPercent(100 - normalized);
|
||||
if (Math.abs(normalized - fromCounts) <= 1 || Math.abs(inverted - fromCounts) <= 1) {
|
||||
return fromCounts;
|
||||
}
|
||||
return fromCounts;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
if (used === undefined || !Number.isFinite(used)) return null;
|
||||
return clampPercent((used / total) * 100);
|
||||
|
||||
return fromCounts;
|
||||
}
|
||||
|
||||
export async function fetchMinimaxUsage(
|
||||
|
||||
@@ -175,6 +175,41 @@ describe("provider usage loading", () => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers MiniMax count-based usage when percent looks inverted", async () => {
|
||||
const makeResponse = (status: number, body: unknown): Response => {
|
||||
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
|
||||
return new Response(payload, { status, headers });
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("api.minimax.io/v1/coding_plan/remains")) {
|
||||
return makeResponse(200, {
|
||||
base_resp: { status_code: 0, status_msg: "ok" },
|
||||
data: {
|
||||
prompt_limit: 200,
|
||||
prompt_remain: 150,
|
||||
usage_percent: 75,
|
||||
next_reset_time: "2026-01-07T05:00:00Z",
|
||||
},
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const summary = await loadProviderUsageSummary({
|
||||
now: Date.UTC(2026, 0, 7, 0, 0, 0),
|
||||
auth: [{ provider: "minimax", token: "token-1b" }],
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const minimax = summary.providers.find((p) => p.provider === "minimax");
|
||||
expect(minimax?.windows[0]?.usedPercent).toBe(25);
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles MiniMax model_remains usage payloads", async () => {
|
||||
const makeResponse = (status: number, body: unknown): Response => {
|
||||
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
||||
|
||||
@@ -145,9 +145,7 @@ function parseBinProbePayload(payloadJSON: string | null | undefined): string[]
|
||||
try {
|
||||
const parsed = JSON.parse(payloadJSON) as { stdout?: unknown; bins?: unknown };
|
||||
if (Array.isArray(parsed.bins)) {
|
||||
return parsed.bins
|
||||
.map((bin) => String(bin).trim())
|
||||
.filter(Boolean);
|
||||
return parsed.bins.map((bin) => String(bin).trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof parsed.stdout === "string") {
|
||||
return parsed.stdout
|
||||
|
||||
@@ -3,7 +3,10 @@ import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
|
||||
export type PluginInstallUpdate = PluginInstallRecord & { pluginId: string };
|
||||
|
||||
export function recordPluginInstall(cfg: ClawdbotConfig, update: PluginInstallUpdate): ClawdbotConfig {
|
||||
export function recordPluginInstall(
|
||||
cfg: ClawdbotConfig,
|
||||
update: PluginInstallUpdate,
|
||||
): ClawdbotConfig {
|
||||
const { pluginId, ...record } = update;
|
||||
const installs = {
|
||||
...cfg.plugins?.installs,
|
||||
|
||||
@@ -260,11 +260,57 @@ const LEGACY_MODEL_PATTERNS: Array<{ id: string; re: RegExp; label: string }> =
|
||||
{ id: "openai.gpt4_legacy", re: /\bgpt-4-(0314|0613)\b/i, label: "Legacy GPT-4 snapshots" },
|
||||
];
|
||||
|
||||
const WEAK_TIER_MODEL_PATTERNS: Array<{ id: string; re: RegExp; label: string }> = [
|
||||
{ id: "anthropic.haiku", re: /\bhaiku\b/i, label: "Haiku tier (smaller model)" },
|
||||
];
|
||||
|
||||
function isGptModel(id: string): boolean {
|
||||
return /\bgpt-/i.test(id);
|
||||
}
|
||||
|
||||
function isGpt5OrHigher(id: string): boolean {
|
||||
return /\bgpt-5(?:\b|[.-])/i.test(id);
|
||||
}
|
||||
|
||||
function isClaudeModel(id: string): boolean {
|
||||
return /\bclaude-/i.test(id);
|
||||
}
|
||||
|
||||
function isClaude45OrHigher(id: string): boolean {
|
||||
return /\bclaude-[^\s/]*?(?:-4-5\b|4\.5\b)/i.test(id);
|
||||
}
|
||||
|
||||
export function collectModelHygieneFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const models = collectModels(cfg);
|
||||
if (models.length === 0) return findings;
|
||||
|
||||
const weakMatches = new Map<string, { model: string; source: string; reasons: string[] }>();
|
||||
const addWeakMatch = (model: string, source: string, reason: string) => {
|
||||
const key = `${model}@@${source}`;
|
||||
const existing = weakMatches.get(key);
|
||||
if (!existing) {
|
||||
weakMatches.set(key, { model, source, reasons: [reason] });
|
||||
return;
|
||||
}
|
||||
if (!existing.reasons.includes(reason)) existing.reasons.push(reason);
|
||||
};
|
||||
|
||||
for (const entry of models) {
|
||||
for (const pat of WEAK_TIER_MODEL_PATTERNS) {
|
||||
if (pat.re.test(entry.id)) {
|
||||
addWeakMatch(entry.id, entry.source, pat.label);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isGptModel(entry.id) && !isGpt5OrHigher(entry.id)) {
|
||||
addWeakMatch(entry.id, entry.source, "Below GPT-5 family");
|
||||
}
|
||||
if (isClaudeModel(entry.id) && !isClaude45OrHigher(entry.id)) {
|
||||
addWeakMatch(entry.id, entry.source, "Below Claude 4.5");
|
||||
}
|
||||
}
|
||||
|
||||
const matches: Array<{ model: string; source: string; reason: string }> = [];
|
||||
for (const entry of models) {
|
||||
for (const pat of LEGACY_MODEL_PATTERNS) {
|
||||
@@ -293,6 +339,25 @@ export function collectModelHygieneFindings(cfg: ClawdbotConfig): SecurityAuditF
|
||||
});
|
||||
}
|
||||
|
||||
if (weakMatches.size > 0) {
|
||||
const lines = Array.from(weakMatches.values())
|
||||
.slice(0, 12)
|
||||
.map((m) => `- ${m.model} (${m.reasons.join("; ")}) @ ${m.source}`)
|
||||
.join("\n");
|
||||
const more = weakMatches.size > 12 ? `\n…${weakMatches.size - 12} more` : "";
|
||||
findings.push({
|
||||
checkId: "models.weak_tier",
|
||||
severity: "warn",
|
||||
title: "Some configured models are below recommended tiers",
|
||||
detail:
|
||||
"Smaller/older models are generally more susceptible to prompt injection and tool misuse.\n" +
|
||||
lines +
|
||||
more,
|
||||
remediation:
|
||||
"Use the latest, top-tier model for any bot with tools or untrusted inboxes. Avoid Haiku tiers; prefer GPT-5+ and Claude 4.5+.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
|
||||
@@ -269,6 +269,24 @@ describe("security audit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("warns on weak model tiers", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-haiku-4-5" } } },
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ checkId: "models.weak_tier", severity: "warn" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when hooks token looks short", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
hooks: { enabled: true, token: "short" },
|
||||
|
||||
@@ -70,11 +70,9 @@ export const registerTelegramNativeCommands = ({
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
if (typeof api.setMyCommands === "function") {
|
||||
api
|
||||
.setMyCommands(allCommands)
|
||||
.catch((err) => {
|
||||
runtime.error?.(danger(`telegram setMyCommands failed: ${String(err)}`));
|
||||
});
|
||||
api.setMyCommands(allCommands).catch((err) => {
|
||||
runtime.error?.(danger(`telegram setMyCommands failed: ${String(err)}`));
|
||||
});
|
||||
} else {
|
||||
logVerbose("telegram: setMyCommands unavailable; skipping registration");
|
||||
}
|
||||
|
||||
@@ -235,9 +235,7 @@ describe("createTelegramBot", () => {
|
||||
expect(nativeStatus).toBeDefined();
|
||||
expect(registered).toContainEqual({ command: "custom_backup", description: "Git backup" });
|
||||
expect(registered).not.toContainEqual({ command: "status", description: "Custom status" });
|
||||
expect(registered.filter((command) => command.command === "status")).toEqual([
|
||||
nativeStatus,
|
||||
]);
|
||||
expect(registered.filter((command) => command.command === "status")).toEqual([nativeStatus]);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { checkInboundAccessControl } from "./access-control.js";
|
||||
|
||||
const sendMessageMock = vi.fn();
|
||||
const readAllowFromStoreMock = vi.fn();
|
||||
const upsertPairingRequestMock = vi.fn();
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
|
||||
vi.mock("../../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => config,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
config = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
sendMessageMock.mockReset().mockResolvedValue(undefined);
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
});
|
||||
|
||||
describe("checkInboundAccessControl", () => {
|
||||
it("suppresses pairing replies for historical DMs on connect", async () => {
|
||||
const connectedAtMs = 1_000_000;
|
||||
const messageTimestampMs = connectedAtMs - 31_000;
|
||||
|
||||
const result = await checkInboundAccessControl({
|
||||
accountId: "default",
|
||||
from: "+15550001111",
|
||||
selfE164: "+15550009999",
|
||||
senderE164: "+15550001111",
|
||||
group: false,
|
||||
pushName: "Sam",
|
||||
isFromMe: false,
|
||||
messageTimestampMs,
|
||||
connectedAtMs,
|
||||
pairingGraceMs: 30_000,
|
||||
sock: { sendMessage: sendMessageMock },
|
||||
remoteJid: "15550001111@s.whatsapp.net",
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
|
||||
expect(sendMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends pairing replies for live DMs", async () => {
|
||||
const connectedAtMs = 1_000_000;
|
||||
const messageTimestampMs = connectedAtMs - 10_000;
|
||||
|
||||
const result = await checkInboundAccessControl({
|
||||
accountId: "default",
|
||||
from: "+15550001111",
|
||||
selfE164: "+15550009999",
|
||||
senderE164: "+15550001111",
|
||||
group: false,
|
||||
pushName: "Sam",
|
||||
isFromMe: false,
|
||||
messageTimestampMs,
|
||||
connectedAtMs,
|
||||
pairingGraceMs: 30_000,
|
||||
sock: { sendMessage: sendMessageMock },
|
||||
remoteJid: "15550001111@s.whatsapp.net",
|
||||
});
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalled();
|
||||
expect(sendMessageMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,8 @@ export type InboundAccessControlResult = {
|
||||
resolvedAccountId: string;
|
||||
};
|
||||
|
||||
const PAIRING_REPLY_HISTORY_GRACE_MS = 30_000;
|
||||
|
||||
export async function checkInboundAccessControl(params: {
|
||||
accountId: string;
|
||||
from: string;
|
||||
@@ -23,6 +25,9 @@ export async function checkInboundAccessControl(params: {
|
||||
group: boolean;
|
||||
pushName?: string;
|
||||
isFromMe: boolean;
|
||||
messageTimestampMs?: number;
|
||||
connectedAtMs?: number;
|
||||
pairingGraceMs?: number;
|
||||
sock: {
|
||||
sendMessage: (jid: string, content: { text: string }) => Promise<unknown>;
|
||||
};
|
||||
@@ -48,6 +53,14 @@ export async function checkInboundAccessControl(params: {
|
||||
(configuredAllowFrom && configuredAllowFrom.length > 0 ? configuredAllowFrom : undefined);
|
||||
const isSamePhone = params.from === params.selfE164;
|
||||
const isSelfChat = isSelfChatMode(params.selfE164, configuredAllowFrom);
|
||||
const pairingGraceMs =
|
||||
typeof params.pairingGraceMs === "number" && params.pairingGraceMs > 0
|
||||
? params.pairingGraceMs
|
||||
: PAIRING_REPLY_HISTORY_GRACE_MS;
|
||||
const suppressPairingReply =
|
||||
typeof params.connectedAtMs === "number" &&
|
||||
typeof params.messageTimestampMs === "number" &&
|
||||
params.messageTimestampMs < params.connectedAtMs - pairingGraceMs;
|
||||
|
||||
// Pre-compute normalized allowlists for filtering.
|
||||
const dmHasWildcard = allowFrom?.includes("*") ?? false;
|
||||
@@ -128,25 +141,29 @@ export async function checkInboundAccessControl(params: {
|
||||
(normalizedAllowFrom.length > 0 && normalizedAllowFrom.includes(candidate));
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await upsertChannelPairingRequest({
|
||||
channel: "whatsapp",
|
||||
id: candidate,
|
||||
meta: { name: (params.pushName ?? "").trim() || undefined },
|
||||
});
|
||||
if (created) {
|
||||
logVerbose(
|
||||
`whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`,
|
||||
);
|
||||
try {
|
||||
await params.sock.sendMessage(params.remoteJid, {
|
||||
text: buildPairingReply({
|
||||
channel: "whatsapp",
|
||||
idLine: `Your WhatsApp phone number: ${candidate}`,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`);
|
||||
if (suppressPairingReply) {
|
||||
logVerbose(`Skipping pairing reply for historical DM from ${candidate}.`);
|
||||
} else {
|
||||
const { code, created } = await upsertChannelPairingRequest({
|
||||
channel: "whatsapp",
|
||||
id: candidate,
|
||||
meta: { name: (params.pushName ?? "").trim() || undefined },
|
||||
});
|
||||
if (created) {
|
||||
logVerbose(
|
||||
`whatsapp pairing request sender=${candidate} name=${params.pushName ?? "unknown"}`,
|
||||
);
|
||||
try {
|
||||
await params.sock.sendMessage(params.remoteJid, {
|
||||
text: buildPairingReply({
|
||||
channel: "whatsapp",
|
||||
idLine: `Your WhatsApp phone number: ${candidate}`,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`whatsapp pairing reply failed for ${candidate}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -40,6 +40,7 @@ export async function monitorWebInbox(options: {
|
||||
authDir: options.authDir,
|
||||
});
|
||||
await waitForWaConnection(sock);
|
||||
const connectedAtMs = Date.now();
|
||||
|
||||
let onCloseResolve: ((reason: WebListenerCloseReason) => void) | null = null;
|
||||
const onClose = new Promise<WebListenerCloseReason>((resolve) => {
|
||||
@@ -171,6 +172,9 @@ export async function monitorWebInbox(options: {
|
||||
groupSubject = meta.subject;
|
||||
groupParticipants = meta.participants;
|
||||
}
|
||||
const messageTimestampMs = msg.messageTimestamp
|
||||
? Number(msg.messageTimestamp) * 1000
|
||||
: undefined;
|
||||
|
||||
const access = await checkInboundAccessControl({
|
||||
accountId: options.accountId,
|
||||
@@ -180,6 +184,8 @@ export async function monitorWebInbox(options: {
|
||||
group,
|
||||
pushName: msg.pushName ?? undefined,
|
||||
isFromMe: Boolean(msg.key?.fromMe),
|
||||
messageTimestampMs,
|
||||
connectedAtMs,
|
||||
sock: { sendMessage: (jid, content) => sock.sendMessage(jid, content) },
|
||||
remoteJid,
|
||||
});
|
||||
@@ -253,7 +259,7 @@ export async function monitorWebInbox(options: {
|
||||
const sendMedia = async (payload: AnyMessageContent) => {
|
||||
await sock.sendMessage(chatJid, payload);
|
||||
};
|
||||
const timestamp = msg.messageTimestamp ? Number(msg.messageTimestamp) * 1000 : undefined;
|
||||
const timestamp = messageTimestampMs;
|
||||
const mentionedJids = extractMentionedJids(msg.message as proto.IMessage | undefined);
|
||||
const senderName = msg.pushName ?? undefined;
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { monitorWebInbox, resetWebInboundDedupe } from "./inbound.js";
|
||||
|
||||
const ACCOUNT_ID = "default";
|
||||
const nowSeconds = (offsetMs = 0) => Math.floor((Date.now() + offsetMs) / 1000);
|
||||
let authDir: string;
|
||||
|
||||
describe("web monitor inbox", () => {
|
||||
@@ -112,7 +113,12 @@ describe("web monitor inbox", () => {
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
accountId: ACCOUNT_ID,
|
||||
authDir,
|
||||
onMessage,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
@@ -121,7 +127,7 @@ describe("web monitor inbox", () => {
|
||||
{
|
||||
key: { id: "auth1", fromMe: false, remoteJid: "999@s.whatsapp.net" },
|
||||
message: { conversation: "authorized message" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
messageTimestamp: nowSeconds(60_000),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -167,7 +173,12 @@ describe("web monitor inbox", () => {
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
accountId: ACCOUNT_ID,
|
||||
authDir,
|
||||
onMessage,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
|
||||
// Message from self (sock.user.id is "123@s.whatsapp.net" in mock)
|
||||
@@ -177,7 +188,7 @@ describe("web monitor inbox", () => {
|
||||
{
|
||||
key: { id: "self1", fromMe: false, remoteJid: "123@s.whatsapp.net" },
|
||||
message: { conversation: "self message" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
messageTimestamp: nowSeconds(60_000),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -210,7 +221,12 @@ describe("web monitor inbox", () => {
|
||||
.mockResolvedValueOnce({ code: "PAIRCODE", created: false });
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
accountId: ACCOUNT_ID,
|
||||
authDir,
|
||||
onMessage,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
|
||||
// Message from someone else should be blocked
|
||||
@@ -224,7 +240,7 @@ describe("web monitor inbox", () => {
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
messageTimestamp: nowSeconds(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -250,7 +266,7 @@ describe("web monitor inbox", () => {
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "ping again" },
|
||||
messageTimestamp: 1_700_000_002,
|
||||
messageTimestamp: nowSeconds(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -271,7 +287,7 @@ describe("web monitor inbox", () => {
|
||||
remoteJid: "123@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "self ping" },
|
||||
messageTimestamp: 1_700_000_001,
|
||||
messageTimestamp: nowSeconds(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -315,7 +331,12 @@ describe("web monitor inbox", () => {
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
accountId: ACCOUNT_ID,
|
||||
authDir,
|
||||
onMessage,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
@@ -328,7 +349,7 @@ describe("web monitor inbox", () => {
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "hello" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
messageTimestamp: nowSeconds(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -366,7 +387,12 @@ describe("web monitor inbox", () => {
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
accountId: ACCOUNT_ID,
|
||||
authDir,
|
||||
onMessage,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
@@ -379,7 +405,7 @@ describe("web monitor inbox", () => {
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "hello again" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
messageTimestamp: nowSeconds(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -404,7 +430,12 @@ describe("web monitor inbox", () => {
|
||||
|
||||
it("handles append messages by marking them read but skipping auto-reply", async () => {
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
accountId: ACCOUNT_ID,
|
||||
authDir,
|
||||
onMessage,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
@@ -417,7 +448,7 @@ describe("web monitor inbox", () => {
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "old message" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
messageTimestamp: nowSeconds(),
|
||||
pushName: "History Sender",
|
||||
},
|
||||
],
|
||||
|
||||
+60
-11
@@ -76,6 +76,7 @@ import { resetLogger, setLoggerOverride } from "../logging.js";
|
||||
import { monitorWebInbox, resetWebInboundDedupe } from "./inbound.js";
|
||||
|
||||
const _ACCOUNT_ID = "default";
|
||||
const nowSeconds = (offsetMs = 0) => Math.floor((Date.now() + offsetMs) / 1000);
|
||||
let authDir: string;
|
||||
|
||||
describe("web monitor inbox", () => {
|
||||
@@ -114,7 +115,12 @@ describe("web monitor inbox", () => {
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
accountId: _ACCOUNT_ID,
|
||||
authDir,
|
||||
onMessage,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
|
||||
// Message from unauthorized sender +999 (not in allowFrom)
|
||||
@@ -128,7 +134,7 @@ describe("web monitor inbox", () => {
|
||||
remoteJid: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "unauthorized message" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
messageTimestamp: nowSeconds(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -175,7 +181,12 @@ describe("web monitor inbox", () => {
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
accountId: _ACCOUNT_ID,
|
||||
authDir,
|
||||
onMessage,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
@@ -184,7 +195,7 @@ describe("web monitor inbox", () => {
|
||||
{
|
||||
key: { id: "self1", fromMe: false, remoteJid: "123@s.whatsapp.net" },
|
||||
message: { conversation: "self ping" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
messageTimestamp: nowSeconds(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -214,6 +225,8 @@ describe("web monitor inbox", () => {
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
accountId: _ACCOUNT_ID,
|
||||
authDir,
|
||||
onMessage,
|
||||
sendReadReceipts: false,
|
||||
});
|
||||
@@ -225,7 +238,7 @@ describe("web monitor inbox", () => {
|
||||
{
|
||||
key: { id: "rr-off-1", fromMe: false, remoteJid: "222@s.whatsapp.net" },
|
||||
message: { conversation: "read receipts off" },
|
||||
messageTimestamp: 1_700_000_000,
|
||||
messageTimestamp: nowSeconds(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -249,7 +262,12 @@ describe("web monitor inbox", () => {
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
accountId: _ACCOUNT_ID,
|
||||
authDir,
|
||||
onMessage,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
@@ -263,6 +281,7 @@ describe("web monitor inbox", () => {
|
||||
participant: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "unauthorized group message" },
|
||||
messageTimestamp: nowSeconds(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -289,7 +308,12 @@ describe("web monitor inbox", () => {
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
accountId: _ACCOUNT_ID,
|
||||
authDir,
|
||||
onMessage,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
@@ -303,6 +327,7 @@ describe("web monitor inbox", () => {
|
||||
participant: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "group message should be blocked" },
|
||||
messageTimestamp: nowSeconds(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -332,7 +357,12 @@ describe("web monitor inbox", () => {
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
accountId: _ACCOUNT_ID,
|
||||
authDir,
|
||||
onMessage,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
@@ -346,6 +376,7 @@ describe("web monitor inbox", () => {
|
||||
participant: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "unauthorized group sender" },
|
||||
messageTimestamp: nowSeconds(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -375,7 +406,12 @@ describe("web monitor inbox", () => {
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
accountId: _ACCOUNT_ID,
|
||||
authDir,
|
||||
onMessage,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
@@ -389,6 +425,7 @@ describe("web monitor inbox", () => {
|
||||
participant: "15551234567@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "authorized group sender" },
|
||||
messageTimestamp: nowSeconds(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -421,7 +458,12 @@ describe("web monitor inbox", () => {
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
accountId: _ACCOUNT_ID,
|
||||
authDir,
|
||||
onMessage,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
@@ -435,6 +477,7 @@ describe("web monitor inbox", () => {
|
||||
participant: "9999999999@s.whatsapp.net", // Random sender
|
||||
},
|
||||
message: { conversation: "wildcard group sender" },
|
||||
messageTimestamp: nowSeconds(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -465,7 +508,12 @@ describe("web monitor inbox", () => {
|
||||
});
|
||||
|
||||
const onMessage = vi.fn();
|
||||
const listener = await monitorWebInbox({ verbose: false, onMessage });
|
||||
const listener = await monitorWebInbox({
|
||||
verbose: false,
|
||||
accountId: _ACCOUNT_ID,
|
||||
authDir,
|
||||
onMessage,
|
||||
});
|
||||
const sock = await createWaSocket();
|
||||
|
||||
const upsert = {
|
||||
@@ -479,6 +527,7 @@ describe("web monitor inbox", () => {
|
||||
participant: "999@s.whatsapp.net",
|
||||
},
|
||||
message: { conversation: "blocked by empty allowlist" },
|
||||
messageTimestamp: nowSeconds(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -31,6 +31,52 @@ export function renderOverview(props: OverviewProps) {
|
||||
const tick = snapshot?.policy?.tickIntervalMs
|
||||
? `${snapshot.policy.tickIntervalMs}ms`
|
||||
: "n/a";
|
||||
const authHint = (() => {
|
||||
if (props.connected || !props.lastError) return null;
|
||||
const lower = props.lastError.toLowerCase();
|
||||
const authFailed = lower.includes("unauthorized") || lower.includes("connect failed");
|
||||
if (!authFailed) return null;
|
||||
const hasToken = Boolean(props.settings.token.trim());
|
||||
const hasPassword = Boolean(props.password.trim());
|
||||
if (!hasToken && !hasPassword) {
|
||||
return html`
|
||||
<div class="muted" style="margin-top: 8px;">
|
||||
This gateway requires auth. Add a token or password, then click Connect.
|
||||
<div style="margin-top: 6px;">
|
||||
<span class="mono">clawdbot dashboard --no-open</span> → tokenized URL<br />
|
||||
<span class="mono">clawdbot doctor --generate-gateway-token</span> → set token
|
||||
</div>
|
||||
<div style="margin-top: 6px;">
|
||||
<a
|
||||
class="session-link"
|
||||
href="https://docs.clawd.bot/web/dashboard"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Control UI auth docs (opens in new tab)"
|
||||
>Docs: Control UI auth</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<div class="muted" style="margin-top: 8px;">
|
||||
Auth failed. Re-copy a tokenized URL with
|
||||
<span class="mono">clawdbot dashboard --no-open</span>, or update the token,
|
||||
then click Connect.
|
||||
<div style="margin-top: 6px;">
|
||||
<a
|
||||
class="session-link"
|
||||
href="https://docs.clawd.bot/web/dashboard"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Control UI auth docs (opens in new tab)"
|
||||
>Docs: Control UI auth</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})();
|
||||
|
||||
return html`
|
||||
<section class="grid grid-cols-2">
|
||||
@@ -119,7 +165,8 @@ export function renderOverview(props: OverviewProps) {
|
||||
</div>
|
||||
${props.lastError
|
||||
? html`<div class="callout danger" style="margin-top: 14px;">
|
||||
${props.lastError}
|
||||
<div>${props.lastError}</div>
|
||||
${authHint ?? ""}
|
||||
</div>`
|
||||
: html`<div class="callout" style="margin-top: 14px;">
|
||||
Use Connections to link WhatsApp, Telegram, Discord, Signal, or iMessage.
|
||||
|
||||
Reference in new issue
Block a user