Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47ba85f36a | ||
|
|
e516e923ff | ||
|
|
fd36e05bf3 | ||
|
|
e8e226ddf3 | ||
|
|
dd150d69c6 | ||
|
|
9ceac415c5 | ||
|
|
ac00065727 | ||
|
|
30534c5c33 | ||
|
|
97755683c7 | ||
|
|
a4f6b3528a | ||
|
|
9f8e66359e | ||
|
|
8a2720db4c | ||
|
|
5330595a5a | ||
|
|
2c5141d7df | ||
|
|
483fba41b9 | ||
|
|
fe7436a1f6 | ||
|
|
a1ed671636 | ||
|
|
8c47d226ad | ||
|
|
e1942603e9 | ||
|
|
926c2647b8 | ||
|
|
c427f4a2fc | ||
|
|
f99f9a6b64 | ||
|
|
39d8c441eb | ||
|
|
40ef3b5d30 | ||
|
|
390b730b37 | ||
|
|
71457fa100 | ||
|
|
da7a45b3a5 | ||
|
|
15a9c21203 | ||
|
|
6d79c6cd26 | ||
|
|
bcedeb4e1f | ||
|
|
f076eba98a | ||
|
|
f3bd6bf342 | ||
|
|
c29c9a1e3e | ||
|
|
7a384ea07c | ||
|
|
5fc866e8fe | ||
|
|
437535ee94 | ||
|
|
3b929ff843 | ||
|
|
93737ee152 | ||
|
|
765626b492 | ||
|
|
42b8fce4e5 | ||
|
|
c27294133e | ||
|
|
94095386b3 | ||
|
|
7a524e8667 | ||
|
|
876bbb742a | ||
|
|
ef7971e3a4 | ||
|
|
9d742ba51f | ||
|
|
386d21b6d1 | ||
|
|
c8afa8207c | ||
|
|
d0e21f05a6 | ||
|
|
11f039ef85 | ||
|
|
e90e3ba954 | ||
|
|
0de7852d46 | ||
|
|
834663dfef | ||
|
|
a72d7a9f36 | ||
|
|
67e57e7c99 | ||
|
|
4c98d6c121 | ||
|
|
174a1cb68a | ||
|
|
a4d56bd06e | ||
|
|
c9e98376b3 | ||
|
|
62c9255b6a | ||
|
|
8b4e40c602 | ||
|
|
6a9d7f7a01 |
No files matched your search
@@ -0,0 +1,143 @@
|
||||
name: Docker Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# Build amd64 image
|
||||
build-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
image-digest: ${{ steps.build.outputs.digest }}
|
||||
image-metadata: ${{ steps.meta.outputs.json }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{version}},suffix=-amd64
|
||||
type=semver,pattern={{version}},suffix=-arm64
|
||||
type=ref,event=branch,suffix=-amd64
|
||||
type=ref,event=branch,suffix=-arm64
|
||||
|
||||
- name: Build and push amd64 image
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
# Build arm64 image
|
||||
build-arm64:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
image-digest: ${{ steps.build.outputs.digest }}
|
||||
image-metadata: ${{ steps.meta.outputs.json }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{version}},suffix=-amd64
|
||||
type=semver,pattern={{version}},suffix=-arm64
|
||||
type=ref,event=branch,suffix=-amd64
|
||||
type=ref,event=branch,suffix=-arm64
|
||||
|
||||
- name: Build and push arm64 image
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
# Create multi-platform manifest
|
||||
create-manifest:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
needs: [build-amd64, build-arm64]
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for manifest
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Create and push manifest
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
${{ needs.build-amd64.outputs.image-digest }} \
|
||||
${{ needs.build-arm64.outputs.image-digest }}
|
||||
env:
|
||||
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
|
||||
@@ -106,6 +106,7 @@
|
||||
## Agent-Specific Notes
|
||||
- Vocabulary: "makeup" = "mac app".
|
||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
||||
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/clawdbot && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
|
||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||
- Never update the Carbon dependency.
|
||||
|
||||
+72
-64
@@ -2,81 +2,89 @@
|
||||
|
||||
Docs: https://docs.clawd.bot
|
||||
|
||||
## 2026.1.23 (Unreleased)
|
||||
|
||||
### Highlights
|
||||
- TTS: allow model-driven TTS tags by default for expressive audio replies (laughter, singing cues, etc.).
|
||||
## 2026.1.24
|
||||
|
||||
### Changes
|
||||
- Gateway: add /tools/invoke HTTP endpoint for direct tool calls and document it. (#1575) Thanks @vignesh07.
|
||||
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits.
|
||||
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
|
||||
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
|
||||
- Heartbeat: add per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer.
|
||||
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
|
||||
- Docs: add emoji reaction guidance to AGENTS.md template. (#1591) Thanks @EnzeD.
|
||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
||||
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
||||
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.
|
||||
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
|
||||
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
|
||||
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.
|
||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
||||
- Docs: add verbose installer troubleshooting guidance.
|
||||
- Docs: update Fly.io guide notes.
|
||||
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
|
||||
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
|
||||
|
||||
### Fixes
|
||||
- BlueBubbles: preserve full message GUIDs in reply tags + reaction events (no part-prefix stripping). (#1641) Thanks @tyler6204.
|
||||
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||
- Heartbeat: normalize target identifiers for consistent routing.
|
||||
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
|
||||
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
|
||||
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
|
||||
- Agents: use the active auth profile for auto-compaction recovery.
|
||||
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
|
||||
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||
|
||||
## 2026.1.23-1
|
||||
|
||||
### Fixes
|
||||
- Packaging: include dist/tts output in npm tarball (fixes missing dist/tts/tts.js).
|
||||
|
||||
## 2026.1.23
|
||||
|
||||
### Highlights
|
||||
- TTS: move Telegram TTS into core + enable model-driven TTS tags by default for expressive audio replies. (#1559) Thanks @Glucksberg. https://docs.clawd.bot/tts
|
||||
- Gateway: add `/tools/invoke` HTTP endpoint for direct tool calls (auth + tool policy enforced). (#1575) Thanks @vignesh07. https://docs.clawd.bot/gateway/tools-invoke-http-api
|
||||
- Heartbeat: per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer. https://docs.clawd.bot/gateway/heartbeat
|
||||
- Deploy: add Fly.io deployment support + guide. (#1570) https://docs.clawd.bot/platforms/fly
|
||||
- Channels: add Tlon/Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. https://docs.clawd.bot/channels/tlon
|
||||
|
||||
### Changes
|
||||
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt. https://docs.clawd.bot/multi-agent-sandbox-tools
|
||||
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3. https://docs.clawd.bot/bedrock
|
||||
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`. (commit 71203829d) https://docs.clawd.bot/cli/system
|
||||
- CLI: add live auth probes to `clawdbot models status` for per-profile verification. (commit 40181afde) https://docs.clawd.bot/cli/models
|
||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. (commit 2c85b1b40)
|
||||
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node). (commit c3cb26f7c)
|
||||
- Plugins: add optional `llm-task` JSON-only tool for workflows. (#1498) Thanks @vignesh07. https://docs.clawd.bot/tools/llm-task
|
||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
|
||||
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
|
||||
- TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.
|
||||
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits. (commit 66eec295b)
|
||||
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
|
||||
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc. https://docs.clawd.bot/automation/cron-vs-heartbeat
|
||||
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc. https://docs.clawd.bot/gateway/heartbeat
|
||||
|
||||
### Fixes
|
||||
- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)
|
||||
- Routing/Cron: normalize agentId casing for bindings and cron payloads. (#1591)
|
||||
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
|
||||
- Messaging: mirror outbound sends into target session keys (threads + dmScope) and create session entries on send. (#1520)
|
||||
- Sessions: normalize session key casing to lowercase for consistent routing.
|
||||
- BlueBubbles: normalize group session keys for outbound mirroring. (#1520)
|
||||
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
|
||||
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
|
||||
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).
|
||||
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
|
||||
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
|
||||
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
||||
- Heartbeat: accept plugin channel ids for heartbeat target validation + UI hints.
|
||||
- Messaging/Sessions: mirror outbound sends into target session keys (threads + dmScope), create session entries on send, and normalize session key casing. (#1520, commit 4b6cdd1d3)
|
||||
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
|
||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
||||
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
|
||||
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.
|
||||
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
|
||||
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
|
||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes.
|
||||
- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp).
|
||||
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
|
||||
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
||||
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).
|
||||
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
||||
- TUI: include Gateway slash commands in autocomplete and `/help`.
|
||||
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
|
||||
- CLI: suppress diagnostic session/run noise during auth probes.
|
||||
- CLI: hide auth probe timeout warnings from embedded runs.
|
||||
- CLI: render auth probe results as a table in `clawdbot models status`.
|
||||
- CLI: suppress probe-only embedded logs unless `--verbose` is set.
|
||||
- CLI: move auth probe errors below the table to reduce wrapping.
|
||||
- CLI: prevent ANSI color bleed when table cells wrap.
|
||||
- CLI: explain when auth profiles are excluded by auth.order in probe details.
|
||||
- CLI: drop the em dash when the banner tagline wraps to a second line.
|
||||
- CLI: inline auth probe errors in status rows to reduce wrapping.
|
||||
- Telegram: render markdown in media captions. (#1478)
|
||||
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.
|
||||
- Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)
|
||||
- Daemon: use platform PATH delimiters when building minimal service paths.
|
||||
- Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.
|
||||
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts). (commit 5662a9cdf)
|
||||
- Daemon: use platform PATH delimiters when building minimal service paths. (commit a4e57d3ac)
|
||||
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
|
||||
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
|
||||
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
|
||||
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
||||
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops). (commit 8ea8801d0)
|
||||
- Agents: ignore IDENTITY.md template placeholders when parsing identity. (#1556)
|
||||
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
|
||||
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
|
||||
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
|
||||
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (commit 084002998)
|
||||
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
|
||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes. (commit f70ac0c7c)
|
||||
- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp). (commit d905ca0e0)
|
||||
- Telegram: render markdown in media captions. (#1478)
|
||||
- MS Teams: remove `.default` suffix from Graph scopes and Bot Framework probe scopes. (#1507, #1574) Thanks @Evizero.
|
||||
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
|
||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. (commit 69f645c66)
|
||||
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
|
||||
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank. (commit d57cb2e1a)
|
||||
- TUI: forward unknown slash commands, include Gateway commands in autocomplete, and render slash replies as system output. (commit 1af227b61, commit 8195497ce, commit 6fba598ea)
|
||||
- CLI: auth probe output polish (table output, inline errors, reduced noise, and wrap fixes in `clawdbot models status`). (commit da3f2b489, commit 00ae21bed, commit 31e59cd58, commit f7dc27f2d, commit 438e782f8, commit 886752217, commit aabe0bed3, commit 81535d512, commit c63144ab1)
|
||||
- Media: only parse `MEDIA:` tags when they start the line to avoid stripping prose mentions. (#1206)
|
||||
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
||||
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
|
||||
- MS Teams (plugin): remove `.default` suffix from Bot Framework probe scope to avoid double-appending. (#1574) Thanks @Evizero.
|
||||
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
|
||||
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
|
||||
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
|
||||
|
||||
## 2026.1.22
|
||||
|
||||
|
||||
+87
-190
@@ -2,6 +2,93 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>Clawdbot</title>
|
||||
<item>
|
||||
<title>2026.1.23</title>
|
||||
<pubDate>Sat, 24 Jan 2026 13:02:18 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>7750</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.23</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.23</h2>
|
||||
<h3>Highlights</h3>
|
||||
<ul>
|
||||
<li>TTS: allow model-driven TTS tags by default for expressive audio replies (laughter, singing cues, etc.).</li>
|
||||
</ul>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Gateway: add /tools/invoke HTTP endpoint for direct tool calls and document it. (#1575) Thanks @vignesh07.</li>
|
||||
<li>Agents: keep system prompt time zone-only and move current time to <code>session_status</code> for better cache hits.</li>
|
||||
<li>Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.</li>
|
||||
<li>Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).</li>
|
||||
<li>Heartbeat: add per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer.</li>
|
||||
<li>Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.</li>
|
||||
<li>CLI: restart the gateway by default after <code>clawdbot update</code>; add <code>--no-restart</code> to skip it.</li>
|
||||
<li>CLI: add live auth probes to <code>clawdbot models status</code> for per-profile verification.</li>
|
||||
<li>CLI: add <code>clawdbot system</code> for system events + heartbeat controls; remove standalone <code>wake</code>.</li>
|
||||
<li>Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.</li>
|
||||
<li>Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.</li>
|
||||
<li>Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.</li>
|
||||
<li>Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.</li>
|
||||
<li>Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.</li>
|
||||
<li>Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.</li>
|
||||
<li>TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)</li>
|
||||
<li>Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.</li>
|
||||
<li>Messaging: mirror outbound sends into target session keys (threads + dmScope) and create session entries on send. (#1520)</li>
|
||||
<li>Sessions: normalize session key casing to lowercase for consistent routing.</li>
|
||||
<li>BlueBubbles: normalize group session keys for outbound mirroring. (#1520)</li>
|
||||
<li>Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.</li>
|
||||
<li>Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.</li>
|
||||
<li>Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).</li>
|
||||
<li>Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)</li>
|
||||
<li>Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.</li>
|
||||
<li>Docker: update gateway command in docker-compose and Hetzner guide. (#1514)</li>
|
||||
<li>Sessions: reject array-backed session stores to prevent silent wipes. (#1469)</li>
|
||||
<li>Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.</li>
|
||||
<li>UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.</li>
|
||||
<li>UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.</li>
|
||||
<li>Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.</li>
|
||||
<li>Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.</li>
|
||||
<li>Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.</li>
|
||||
<li>Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes.</li>
|
||||
<li>Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp).</li>
|
||||
<li>Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.</li>
|
||||
<li>Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).</li>
|
||||
<li>TUI: forward unknown slash commands (for example, <code>/context</code>) to the Gateway.</li>
|
||||
<li>TUI: include Gateway slash commands in autocomplete and <code>/help</code>.</li>
|
||||
<li>CLI: skip usage lines in <code>clawdbot models status</code> when provider usage is unavailable.</li>
|
||||
<li>CLI: suppress diagnostic session/run noise during auth probes.</li>
|
||||
<li>CLI: hide auth probe timeout warnings from embedded runs.</li>
|
||||
<li>CLI: render auth probe results as a table in <code>clawdbot models status</code>.</li>
|
||||
<li>CLI: suppress probe-only embedded logs unless <code>--verbose</code> is set.</li>
|
||||
<li>CLI: move auth probe errors below the table to reduce wrapping.</li>
|
||||
<li>CLI: prevent ANSI color bleed when table cells wrap.</li>
|
||||
<li>CLI: explain when auth profiles are excluded by auth.order in probe details.</li>
|
||||
<li>CLI: drop the em dash when the banner tagline wraps to a second line.</li>
|
||||
<li>CLI: inline auth probe errors in status rows to reduce wrapping.</li>
|
||||
<li>Telegram: render markdown in media captions. (#1478)</li>
|
||||
<li>Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.</li>
|
||||
<li>Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)</li>
|
||||
<li>Daemon: use platform PATH delimiters when building minimal service paths.</li>
|
||||
<li>Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.</li>
|
||||
<li>Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.</li>
|
||||
<li>TUI: render Gateway slash-command replies as system output (for example, <code>/context</code>).</li>
|
||||
<li>Media: only parse <code>MEDIA:</code> tags when they start the line to avoid stripping prose mentions. (#1206)</li>
|
||||
<li>Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.</li>
|
||||
<li>Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)</li>
|
||||
<li>Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.</li>
|
||||
<li>MS Teams (plugin): remove <code>.default</code> suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.</li>
|
||||
<li>MS Teams (plugin): remove <code>.default</code> suffix from Bot Framework probe scope to avoid double-appending. (#1574) Thanks @Evizero.</li>
|
||||
<li>Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)</li>
|
||||
<li>Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)</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.23/Clawdbot-2026.1.23.zip" length="22326233" type="application/octet-stream" sparkle:edSignature="p40dFczUfmMpsif4BrEUYVqUPG2WiBXleWgefwu4WiqjuyXbw7CAaH5CpQKig/k2qRLlE59kX7AR/qJqmy+yCA=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.22</title>
|
||||
<pubDate>Fri, 23 Jan 2026 08:58:14 +0000</pubDate>
|
||||
@@ -124,195 +211,5 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.1.21</title>
|
||||
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
|
||||
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
|
||||
<sparkle:version>7116</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui</li>
|
||||
<li>TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui</li>
|
||||
<li>TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui</li>
|
||||
<li>TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui</li>
|
||||
<li>ACP: add <code>clawdbot acp</code> for IDE integrations. https://docs.clawd.bot/cli/acp</li>
|
||||
<li>ACP: add <code>clawdbot acp client</code> interactive harness for debugging. https://docs.clawd.bot/cli/acp</li>
|
||||
<li>Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills</li>
|
||||
<li>Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills</li>
|
||||
<li>Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add <code>--verbose</code> logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory</li>
|
||||
<li>Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser</li>
|
||||
<li>Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr</li>
|
||||
<li>Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix</li>
|
||||
<li>Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack</li>
|
||||
<li>Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram</li>
|
||||
<li>Discord: fall back to <code>/skill</code> when native command limits are exceeded. (#1287)</li>
|
||||
<li>Discord: expose <code>/skill</code> globally. (#1287)</li>
|
||||
<li>Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser</li>
|
||||
<li>Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest</li>
|
||||
<li>Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest</li>
|
||||
<li>Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest</li>
|
||||
<li>Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui</li>
|
||||
<li>Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools</li>
|
||||
<li>Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles</li>
|
||||
<li>Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.</li>
|
||||
<li>Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo</li>
|
||||
<li>Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser</li>
|
||||
<li>Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools</li>
|
||||
<li>Plugins: auto-enable bundled channel/provider plugins when configuration is present.</li>
|
||||
<li>Plugins: sync plugin sources on channel switches and update npm-installed plugins during <code>clawdbot update</code>.</li>
|
||||
<li>Plugins: share npm plugin update logic between <code>clawdbot update</code> and <code>clawdbot plugins update</code>.</li>
|
||||
<li>Gateway/API: add <code>/v1/responses</code> (OpenResponses) with item-based input + semantic streaming events. (#1229)</li>
|
||||
<li>Gateway/API: expand <code>/v1/responses</code> to support file/image inputs, tool_choice, usage, and output limits. (#1229)</li>
|
||||
<li>Usage: add <code>/usage cost</code> summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs</li>
|
||||
<li>Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security</li>
|
||||
<li>Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec</li>
|
||||
<li>Exec: add <code>/exec</code> directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec</li>
|
||||
<li>Exec approvals: migrate approvals to <code>~/.clawdbot/exec-approvals.json</code> with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals</li>
|
||||
<li>Nodes: add headless node host (<code>clawdbot node start</code>) for <code>system.run</code>/<code>system.which</code>. https://docs.clawd.bot/cli/node</li>
|
||||
<li>Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node</li>
|
||||
<li>Bridge: add <code>skills.bins</code> RPC to support node host auto-allow skill bins.</li>
|
||||
<li>Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session</li>
|
||||
<li>Sessions: allow <code>sessions_spawn</code> to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents</li>
|
||||
<li>Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups</li>
|
||||
<li>Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen</li>
|
||||
<li>Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding</li>
|
||||
<li>Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding</li>
|
||||
<li>Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android</li>
|
||||
<li>Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock</li>
|
||||
<li>Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp</li>
|
||||
<li>Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows</li>
|
||||
<li>Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login</li>
|
||||
<li>Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.</li>
|
||||
<li>Agents: clarify node_modules read-only guidance in agent instructions.</li>
|
||||
<li>Config: stamp last-touched metadata on write and warn if the config is newer than the running build.</li>
|
||||
<li>macOS: hide usage section when usage is unavailable instead of showing provider errors.</li>
|
||||
<li>Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.</li>
|
||||
<li>Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.</li>
|
||||
<li>Android: remove legacy bridge transport code now that nodes use the gateway protocol.</li>
|
||||
<li>Android: bump okhttp + dnsjava to satisfy lint dependency checks.</li>
|
||||
<li>Build: update workspace + core/plugin deps.</li>
|
||||
<li>Build: use tsgo for dev/watch builds by default (opt out with <code>CLAWDBOT_TS_COMPILER=tsc</code>).</li>
|
||||
<li>Repo: remove the Peekaboo git submodule now that the SPM release is used.</li>
|
||||
<li>macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.</li>
|
||||
<li>macOS: stop syncing Peekaboo in postinstall.</li>
|
||||
<li>Swabble: use the tagged Commander Swift package release.</li>
|
||||
</ul>
|
||||
<h3>Breaking</h3>
|
||||
<ul>
|
||||
<li><strong>BREAKING:</strong> Reject invalid/unknown config entries and refuse to start the gateway for safety. Run <code>clawdbot doctor --fix</code> to repair, then update plugins (<code>clawdbot plugins update</code>) if you use any.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Discovery: shorten Bonjour DNS-SD service type to <code>_clawdbot-gw._tcp</code> and update discovery clients/docs.</li>
|
||||
<li>Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.</li>
|
||||
<li>Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)</li>
|
||||
<li>Diagnostics: gate heartbeat/webhook logging. (#1244)</li>
|
||||
<li>Gateway: strip inbound envelope headers from chat history messages to keep clients clean.</li>
|
||||
<li>Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.</li>
|
||||
<li>Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)</li>
|
||||
<li>Gateway: clarify connect/validation errors for gateway params. (#1347)</li>
|
||||
<li>Gateway: preserve restart wake routing + thread replies across restarts. (#1337)</li>
|
||||
<li>Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.</li>
|
||||
<li>Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.</li>
|
||||
<li>Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)</li>
|
||||
<li>Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)</li>
|
||||
<li>Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)</li>
|
||||
<li>Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)</li>
|
||||
<li>Agents: sanitize oversized image payloads before send and surface image-dimension errors.</li>
|
||||
<li>Sessions: fall back to session labels when listing display names. (#1124)</li>
|
||||
<li>Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)</li>
|
||||
<li>Config: log invalid config issues once per run and keep invalid-config errors stackless.</li>
|
||||
<li>Config: allow Perplexity as a web_search provider in config validation. (#1230)</li>
|
||||
<li>Config: allow custom fields under <code>skills.entries.<name>.config</code> for skill credentials/config. (#1226)</li>
|
||||
<li>Doctor: clarify plugin auto-enable hint text in the startup banner.</li>
|
||||
<li>Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)</li>
|
||||
<li>Docs: make docs:list fail fast with a clear error if the docs directory is missing.</li>
|
||||
<li>Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)</li>
|
||||
<li>Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.</li>
|
||||
<li>CLI: preserve cron delivery settings when editing message payloads. (#1322)</li>
|
||||
<li>CLI: keep <code>clawdbot logs</code> output resilient to broken pipes while preserving progress output.</li>
|
||||
<li>CLI: avoid duplicating --profile/--dev flags when formatting commands.</li>
|
||||
<li>CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)</li>
|
||||
<li>CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)</li>
|
||||
<li>CLI: skip runner rebuilds when dist is fresh. (#1231)</li>
|
||||
<li>CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.</li>
|
||||
<li>Status: route native <code>/status</code> to the active agent so model selection reflects the correct profile. (#1301)</li>
|
||||
<li>Status: show both usage windows with reset hints when usage data is available. (#1101)</li>
|
||||
<li>UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)</li>
|
||||
<li>UI: preserve ordered list numbering in chat markdown. (#1341)</li>
|
||||
<li>UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)</li>
|
||||
<li>UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)</li>
|
||||
<li>UI: enable shell mode for sync Windows spawns to avoid <code>pnpm ui:build</code> EINVAL. (#1212)</li>
|
||||
<li>TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)</li>
|
||||
<li>TUI: align custom editor initialization with the latest pi-tui API. (#1298)</li>
|
||||
<li>TUI: show generic empty-state text for searchable pickers. (#1201)</li>
|
||||
<li>TUI: highlight model search matches and stabilize search ordering.</li>
|
||||
<li>Configure: hide OpenRouter auto routing model from the model picker. (#1182)</li>
|
||||
<li>Memory: show total file counts + scan issues in <code>clawdbot memory status</code>.</li>
|
||||
<li>Memory: fall back to non-batch embeddings after repeated batch failures.</li>
|
||||
<li>Memory: apply OpenAI batch defaults even without explicit remote config.</li>
|
||||
<li>Memory: index atomically so failed reindex preserves the previous memory database. (#1151)</li>
|
||||
<li>Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)</li>
|
||||
<li>Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.</li>
|
||||
<li>Memory: parallelize embedding indexing with rate-limit retries.</li>
|
||||
<li>Memory: split overly long lines to keep embeddings under token limits.</li>
|
||||
<li>Memory: skip empty chunks to avoid invalid embedding inputs.</li>
|
||||
<li>Memory: split embedding batches to avoid OpenAI token limits during indexing.</li>
|
||||
<li>Memory: probe sqlite-vec availability in <code>clawdbot memory status</code>.</li>
|
||||
<li>Exec approvals: enforce allowlist when ask is off.</li>
|
||||
<li>Exec approvals: prefer raw command for node approvals/events.</li>
|
||||
<li>Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.</li>
|
||||
<li>Tools: return a companion-app-required message when node exec is requested with no paired node.</li>
|
||||
<li>Tools: return a companion-app-required message when <code>system.run</code> is requested without a supporting node.</li>
|
||||
<li>Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).</li>
|
||||
<li>Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)</li>
|
||||
<li>Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)</li>
|
||||
<li>Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)</li>
|
||||
<li>Discord: make resolve warnings avoid raw JSON payloads on rate limits.</li>
|
||||
<li>Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)</li>
|
||||
<li>Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.</li>
|
||||
<li>Discord: only emit slow listener warnings after 30s.</li>
|
||||
<li>Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)</li>
|
||||
<li>Telegram: honor pairing allowlists for native slash commands.</li>
|
||||
<li>Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)</li>
|
||||
<li>Slack: resolve Bolt import interop for Bun + Node. (#1191)</li>
|
||||
<li>Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).</li>
|
||||
<li>Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)</li>
|
||||
<li>Browser: register AI snapshot refs for act commands. (#1282)</li>
|
||||
<li>Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)</li>
|
||||
<li>Anthropic: default API prompt caching to 1h with configurable TTL override.</li>
|
||||
<li>Anthropic: ignore TTL for OAuth.</li>
|
||||
<li>Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)</li>
|
||||
<li>Auth profiles: user pins stay locked. (#1138)</li>
|
||||
<li>Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)</li>
|
||||
<li>Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.</li>
|
||||
<li>Tests: stabilize plugin SDK resolution and embedded agent timeouts.</li>
|
||||
<li>Windows: install gateway scheduled task as the current user.</li>
|
||||
<li>Windows: show friendly guidance instead of failing on access denied.</li>
|
||||
<li>macOS: load menu session previews asynchronously so items populate while the menu is open.</li>
|
||||
<li>macOS: use label colors for session preview text so previews render in menu subviews.</li>
|
||||
<li>macOS: suppress usage error text in the menubar cost view.</li>
|
||||
<li>macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)</li>
|
||||
<li>macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)</li>
|
||||
<li>macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)</li>
|
||||
<li>Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)</li>
|
||||
</ul>
|
||||
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
|
||||
<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.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "com.clawdbot.android"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 202601230
|
||||
versionName = "2026.1.23"
|
||||
versionCode = 202601240
|
||||
versionName = "2026.1.24"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.23</string>
|
||||
<string>2026.1.24</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260123</string>
|
||||
<string>20260124</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.23</string>
|
||||
<string>2026.1.24</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260123</string>
|
||||
<string>20260124</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -81,8 +81,8 @@ targets:
|
||||
properties:
|
||||
CFBundleDisplayName: Clawdbot
|
||||
CFBundleIconName: AppIcon
|
||||
CFBundleShortVersionString: "2026.1.23"
|
||||
CFBundleVersion: "20260123"
|
||||
CFBundleShortVersionString: "2026.1.24"
|
||||
CFBundleVersion: "20260124"
|
||||
UILaunchScreen: {}
|
||||
UIApplicationSceneManifest:
|
||||
UIApplicationSupportsMultipleScenes: false
|
||||
@@ -130,5 +130,5 @@ targets:
|
||||
path: Tests/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: ClawdbotTests
|
||||
CFBundleShortVersionString: "2026.1.23"
|
||||
CFBundleVersion: "20260123"
|
||||
CFBundleShortVersionString: "2026.1.24"
|
||||
CFBundleVersion: "20260124"
|
||||
@@ -24,6 +24,11 @@ final class AppState {
|
||||
case remote
|
||||
}
|
||||
|
||||
enum RemoteTransport: String {
|
||||
case ssh
|
||||
case direct
|
||||
}
|
||||
|
||||
var isPaused: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
|
||||
}
|
||||
@@ -166,6 +171,10 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
var remoteTransport: RemoteTransport {
|
||||
didSet { self.syncGatewayConfigIfNeeded() }
|
||||
}
|
||||
|
||||
var canvasEnabled: Bool {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
|
||||
}
|
||||
@@ -200,6 +209,10 @@ final class AppState {
|
||||
}
|
||||
}
|
||||
|
||||
var remoteUrl: String {
|
||||
didSet { self.syncGatewayConfigIfNeeded() }
|
||||
}
|
||||
|
||||
var remoteIdentity: String {
|
||||
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
|
||||
}
|
||||
@@ -263,13 +276,15 @@ final class AppState {
|
||||
}
|
||||
|
||||
let configRoot = ClawdbotConfigFile.loadDict()
|
||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
|
||||
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
|
||||
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||
self.remoteTransport = configRemoteTransport
|
||||
self.connectionMode = resolvedConnectionMode
|
||||
|
||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
if resolvedConnectionMode == .remote,
|
||||
configRemoteTransport != .direct,
|
||||
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let host = AppState.remoteHost(from: configRemoteUrl)
|
||||
{
|
||||
@@ -277,6 +292,7 @@ final class AppState {
|
||||
} else {
|
||||
self.remoteTarget = storedRemoteTarget
|
||||
}
|
||||
self.remoteUrl = configRemoteUrl ?? ""
|
||||
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
|
||||
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
|
||||
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
|
||||
@@ -354,10 +370,11 @@ final class AppState {
|
||||
private func applyConfigOverrides(_ root: [String: Any]) {
|
||||
let gateway = root["gateway"] as? [String: Any]
|
||||
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
|
||||
let hasRemoteUrl = !(remoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root)
|
||||
|
||||
let desiredMode: ConnectionMode? = switch modeRaw {
|
||||
case "local":
|
||||
@@ -378,8 +395,17 @@ final class AppState {
|
||||
self.connectionMode = .remote
|
||||
}
|
||||
|
||||
if remoteTransport != self.remoteTransport {
|
||||
self.remoteTransport = remoteTransport
|
||||
}
|
||||
let remoteUrlText = remoteUrl ?? ""
|
||||
if remoteUrlText != self.remoteUrl {
|
||||
self.remoteUrl = remoteUrlText
|
||||
}
|
||||
|
||||
let targetMode = desiredMode ?? self.connectionMode
|
||||
if targetMode == .remote,
|
||||
remoteTransport != .direct,
|
||||
let host = AppState.remoteHost(from: remoteUrl)
|
||||
{
|
||||
self.updateRemoteTarget(host: host)
|
||||
@@ -402,6 +428,8 @@ final class AppState {
|
||||
let connectionMode = self.connectionMode
|
||||
let remoteTarget = self.remoteTarget
|
||||
let remoteIdentity = self.remoteIdentity
|
||||
let remoteTransport = self.remoteTransport
|
||||
let remoteUrl = self.remoteUrl
|
||||
let desiredMode: String? = switch connectionMode {
|
||||
case .local:
|
||||
"local"
|
||||
@@ -435,39 +463,63 @@ final class AppState {
|
||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
var remoteChanged = false
|
||||
|
||||
if let host = remoteHost {
|
||||
let existingUrl = (remote["url"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||
let port = parsedExisting?.port ?? 18789
|
||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||
if existingUrl != desiredUrl {
|
||||
remote["url"] = desiredUrl
|
||||
if remoteTransport == .direct {
|
||||
let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedUrl.isEmpty {
|
||||
if remote["url"] != nil {
|
||||
remote.removeValue(forKey: "url")
|
||||
remoteChanged = true
|
||||
}
|
||||
} else {
|
||||
let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl
|
||||
if (remote["url"] as? String) != normalizedUrl {
|
||||
remote["url"] = normalizedUrl
|
||||
remoteChanged = true
|
||||
}
|
||||
}
|
||||
if (remote["transport"] as? String) != RemoteTransport.direct.rawValue {
|
||||
remote["transport"] = RemoteTransport.direct.rawValue
|
||||
remoteChanged = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if remote["transport"] != nil {
|
||||
remote.removeValue(forKey: "transport")
|
||||
remoteChanged = true
|
||||
}
|
||||
if let host = remoteHost {
|
||||
let existingUrl = (remote["url"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||
let port = parsedExisting?.port ?? 18789
|
||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||
if existingUrl != desiredUrl {
|
||||
remote["url"] = desiredUrl
|
||||
remoteChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
||||
if !sanitizedTarget.isEmpty {
|
||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
||||
remote["sshTarget"] = sanitizedTarget
|
||||
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
||||
if !sanitizedTarget.isEmpty {
|
||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
||||
remote["sshTarget"] = sanitizedTarget
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshTarget"] != nil {
|
||||
remote.removeValue(forKey: "sshTarget")
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshTarget"] != nil {
|
||||
remote.removeValue(forKey: "sshTarget")
|
||||
remoteChanged = true
|
||||
}
|
||||
|
||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedIdentity.isEmpty {
|
||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
||||
remote["sshIdentity"] = trimmedIdentity
|
||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedIdentity.isEmpty {
|
||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
||||
remote["sshIdentity"] = trimmedIdentity
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshIdentity"] != nil {
|
||||
remote.removeValue(forKey: "sshIdentity")
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshIdentity"] != nil {
|
||||
remote.removeValue(forKey: "sshIdentity")
|
||||
remoteChanged = true
|
||||
}
|
||||
|
||||
if remoteChanged {
|
||||
@@ -621,8 +673,10 @@ extension AppState {
|
||||
state.iconOverride = .system
|
||||
state.heartbeatsEnabled = true
|
||||
state.connectionMode = .local
|
||||
state.remoteTransport = .ssh
|
||||
state.canvasEnabled = true
|
||||
state.remoteTarget = "user@example.com"
|
||||
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||
state.remoteIdentity = "~/.ssh/id_ed25519"
|
||||
state.remoteProjectRoot = "~/Projects/clawdbot"
|
||||
state.remoteCliPath = ""
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import ClawdbotDiscovery
|
||||
import Foundation
|
||||
|
||||
enum GatewayDiscoveryHelpers {
|
||||
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
||||
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
|
||||
let user = NSUserName()
|
||||
var target = "\(user)@\(host)"
|
||||
if gateway.sshPort != 22 {
|
||||
target += ":\(gateway.sshPort)"
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
self.directGatewayUrl(
|
||||
tailnetDns: gateway.tailnetDns,
|
||||
lanHost: gateway.lanHost,
|
||||
gatewayPort: gateway.gatewayPort)
|
||||
}
|
||||
|
||||
static func directGatewayUrl(
|
||||
tailnetDns: String?,
|
||||
lanHost: String?,
|
||||
gatewayPort: Int?) -> String?
|
||||
{
|
||||
if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) {
|
||||
return "wss://\(tailnetDns)"
|
||||
}
|
||||
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
|
||||
let port = gatewayPort ?? 18789
|
||||
return "ws://\(lanHost):\(port)"
|
||||
}
|
||||
|
||||
static func sanitizedTailnetHost(_ host: String?) -> String? {
|
||||
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
|
||||
if host.hasSuffix(".internal.") || host.hasSuffix(".internal") {
|
||||
return nil
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
private static func trimmed(_ value: String?) -> String? {
|
||||
value?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import SwiftUI
|
||||
struct GatewayDiscoveryInlineList: View {
|
||||
var discovery: GatewayDiscoveryModel
|
||||
var currentTarget: String?
|
||||
var currentUrl: String?
|
||||
var transport: AppState.RemoteTransport
|
||||
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
|
||||
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
|
||||
|
||||
@@ -25,9 +27,8 @@ struct GatewayDiscoveryInlineList: View {
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(self.discovery.gateways.prefix(6)) { gateway in
|
||||
let target = self.suggestedSSHTarget(gateway)
|
||||
let selected = (target != nil && self.currentTarget?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
|
||||
let display = self.displayInfo(for: gateway)
|
||||
let selected = display.selected
|
||||
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
|
||||
@@ -40,7 +41,7 @@ struct GatewayDiscoveryInlineList: View {
|
||||
.font(.callout.weight(.semibold))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Text(target ?? "Gateway pairing only")
|
||||
Text(display.label)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -83,27 +84,26 @@ struct GatewayDiscoveryInlineList: View {
|
||||
.fill(Color(NSColor.controlBackgroundColor)))
|
||||
}
|
||||
}
|
||||
.help("Click a discovered gateway to fill the SSH target.")
|
||||
.help(self.transport == .direct
|
||||
? "Click a discovered gateway to fill the gateway URL."
|
||||
: "Click a discovered gateway to fill the SSH target.")
|
||||
}
|
||||
|
||||
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
|
||||
guard let host else { return nil }
|
||||
let user = NSUserName()
|
||||
return GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
}
|
||||
|
||||
private func sanitizedTailnetHost(_ host: String?) -> String? {
|
||||
guard let host else { return nil }
|
||||
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
|
||||
return nil
|
||||
private func displayInfo(
|
||||
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool)
|
||||
{
|
||||
switch self.transport {
|
||||
case .direct:
|
||||
let url = GatewayDiscoveryHelpers.directUrl(for: gateway)
|
||||
let label = url ?? "Gateway pairing only"
|
||||
let selected = url != nil && self.trimmed(self.currentUrl) == url
|
||||
return (label, selected)
|
||||
case .ssh:
|
||||
let target = GatewayDiscoveryHelpers.sshTarget(for: gateway)
|
||||
let label = target ?? "Gateway pairing only"
|
||||
let selected = target != nil && self.trimmed(self.currentTarget) == target
|
||||
return (label, selected)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
|
||||
@@ -111,6 +111,10 @@ struct GatewayDiscoveryInlineList: View {
|
||||
if hovered { return Color.secondary.opacity(0.08) }
|
||||
return Color.clear
|
||||
}
|
||||
|
||||
private func trimmed(_ value: String?) -> String {
|
||||
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
struct GatewayDiscoveryMenu: View {
|
||||
|
||||
@@ -311,6 +311,19 @@ actor GatewayEndpointStore {
|
||||
token: token,
|
||||
password: password))
|
||||
case .remote:
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.unavailable(
|
||||
mode: .remote,
|
||||
reason: "gateway.remote.url missing or invalid for direct transport"))
|
||||
return
|
||||
}
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||
return
|
||||
}
|
||||
let port = await self.deps.remotePortIfRunning()
|
||||
guard let port else {
|
||||
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
|
||||
@@ -341,6 +354,25 @@ actor GatewayEndpointStore {
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||
}
|
||||
guard let port = GatewayRemoteConfig.defaultPort(for: url),
|
||||
let portInt = UInt16(exactly: port)
|
||||
else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"])
|
||||
}
|
||||
self.logger.info("remote transport direct; skipping SSH tunnel")
|
||||
return portInt
|
||||
}
|
||||
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
|
||||
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
|
||||
throw NSError(
|
||||
@@ -401,6 +433,21 @@ actor GatewayEndpointStore {
|
||||
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
|
||||
}
|
||||
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
|
||||
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
|
||||
throw NSError(
|
||||
domain: "GatewayEndpoint",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
|
||||
}
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
self.cancelRemoteEnsure()
|
||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||
return (url, token, password)
|
||||
}
|
||||
|
||||
self.kickRemoteEnsureIfNeeded(detail: detail)
|
||||
guard let ensure = self.remoteEnsure else {
|
||||
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
enum GatewayRemoteConfig {
|
||||
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let raw = remote["transport"] as? String
|
||||
else {
|
||||
return .ssh
|
||||
}
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return trimmed == AppState.RemoteTransport.direct.rawValue ? .direct : .ssh
|
||||
}
|
||||
|
||||
static func resolveUrlString(root: [String: Any]) -> String? {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let urlRaw = remote["url"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
|
||||
guard let raw = self.resolveUrlString(root: root) else { return nil }
|
||||
return self.normalizeGatewayUrl(raw)
|
||||
}
|
||||
|
||||
static func normalizeGatewayUrlString(_ raw: String) -> String? {
|
||||
self.normalizeGatewayUrl(raw)?.absoluteString
|
||||
}
|
||||
|
||||
static func normalizeGatewayUrl(_ raw: String) -> URL? {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
guard scheme == "ws" || scheme == "wss" else { return nil }
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !host.isEmpty else { return nil }
|
||||
if scheme == "ws", url.port == nil {
|
||||
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
return url
|
||||
}
|
||||
components.port = 18789
|
||||
return components.url
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
static func defaultPort(for url: URL) -> Int? {
|
||||
if let port = url.port { return port }
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
switch scheme {
|
||||
case "wss":
|
||||
return 443
|
||||
case "ws":
|
||||
return 18789
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ struct GeneralSettings: View {
|
||||
@State private var showRemoteAdvanced = false
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||
private var remoteLabelWidth: CGFloat { 88 }
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.vertical) {
|
||||
@@ -104,7 +105,7 @@ struct GeneralSettings: View {
|
||||
Picker("Mode", selection: self.$state.connectionMode) {
|
||||
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
|
||||
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
|
||||
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
|
||||
Text("Remote (another host)").tag(AppState.ConnectionMode.remote)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.labelsHidden()
|
||||
@@ -136,60 +137,51 @@ struct GeneralSettings: View {
|
||||
|
||||
private var remoteCard: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("SSH")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: 48, alignment: .leading)
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
self.remoteTransportRow
|
||||
|
||||
if self.state.remoteTransport == .ssh {
|
||||
self.remoteSshRow
|
||||
} else {
|
||||
self.remoteDirectRow
|
||||
}
|
||||
|
||||
GatewayDiscoveryInlineList(
|
||||
discovery: self.gatewayDiscovery,
|
||||
currentTarget: self.state.remoteTarget)
|
||||
currentTarget: self.state.remoteTarget,
|
||||
currentUrl: self.state.remoteUrl,
|
||||
transport: self.state.remoteTransport)
|
||||
{ gateway in
|
||||
self.applyDiscoveredGateway(gateway)
|
||||
}
|
||||
.padding(.leading, 58)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
|
||||
self.remoteStatusView
|
||||
.padding(.leading, 58)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
|
||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LabeledContent("Identity file") {
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("Project root") {
|
||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("CLI path") {
|
||||
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
if self.state.remoteTransport == .ssh {
|
||||
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
LabeledContent("Identity file") {
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("Project root") {
|
||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
LabeledContent("CLI path") {
|
||||
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
} label: {
|
||||
Text("Advanced")
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
.padding(.top, 4)
|
||||
} label: {
|
||||
Text("Advanced")
|
||||
.font(.callout.weight(.semibold))
|
||||
}
|
||||
|
||||
// Diagnostics
|
||||
@@ -219,16 +211,89 @@ struct GeneralSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
if self.state.remoteTransport == .ssh {
|
||||
Text("Tip: enable Tailscale for stable remote access.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
.transition(.opacity)
|
||||
.onAppear { self.gatewayDiscovery.start() }
|
||||
.onDisappear { self.gatewayDiscovery.stop() }
|
||||
}
|
||||
|
||||
private var remoteTransportRow: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("Transport")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
Picker("Transport", selection: self.$state.remoteTransport) {
|
||||
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
||||
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(maxWidth: 320)
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteSshRow: some View {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("SSH target")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
TextField("user@host[:22]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteDirectRow: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
Text("Gateway")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: self.remoteLabelWidth, alignment: .leading)
|
||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
Button {
|
||||
Task { await self.testRemote() }
|
||||
} label: {
|
||||
if self.remoteStatus == .checking {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text("Test remote")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.remoteStatus == .checking || self.state.remoteUrl
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://<magicdns>).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, self.remoteLabelWidth + 10)
|
||||
}
|
||||
}
|
||||
|
||||
private var controlStatusLine: String {
|
||||
switch ControlChannel.shared.state {
|
||||
case .connected: "Connected"
|
||||
@@ -458,24 +523,36 @@ extension GeneralSettings {
|
||||
func testRemote() async {
|
||||
self.remoteStatus = .checking
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
guard !settings.target.isEmpty else {
|
||||
self.remoteStatus = .failed("Set an SSH target first")
|
||||
return
|
||||
if self.state.remoteTransport == .direct {
|
||||
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedUrl.isEmpty else {
|
||||
self.remoteStatus = .failed("Set a gateway URL first")
|
||||
return
|
||||
}
|
||||
guard Self.isValidWsUrl(trimmedUrl) else {
|
||||
self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
guard !settings.target.isEmpty else {
|
||||
self.remoteStatus = .failed("Set an SSH target first")
|
||||
return
|
||||
}
|
||||
|
||||
// Step 1: basic SSH reachability check
|
||||
let sshResult = await ShellExecutor.run(
|
||||
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeout: 8)
|
||||
|
||||
guard sshResult.ok else {
|
||||
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: basic SSH reachability check
|
||||
let sshResult = await ShellExecutor.run(
|
||||
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeout: 8)
|
||||
|
||||
guard sshResult.ok else {
|
||||
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: control channel health over tunnel
|
||||
// Step 2: control channel health check
|
||||
let originalMode = AppStateStore.shared.connectionMode
|
||||
do {
|
||||
try await ControlChannel.shared.configure(mode: .remote(
|
||||
@@ -502,6 +579,14 @@ extension GeneralSettings {
|
||||
}
|
||||
}
|
||||
|
||||
private static func isValidWsUrl(_ raw: String) -> Bool {
|
||||
guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false }
|
||||
let scheme = url.scheme?.lowercased() ?? ""
|
||||
guard scheme == "ws" || scheme == "wss" else { return false }
|
||||
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return !host.isEmpty
|
||||
}
|
||||
|
||||
private static func sshCheckCommand(target: String, identity: String) -> [String] {
|
||||
var args: [String] = [
|
||||
"/usr/bin/ssh",
|
||||
@@ -570,12 +655,18 @@ extension GeneralSettings {
|
||||
let host = gateway.tailnetDns ?? gateway.lanHost
|
||||
guard let host else { return }
|
||||
let user = NSUserName()
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||
if self.state.remoteTransport == .direct {
|
||||
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
||||
self.state.remoteUrl = url
|
||||
}
|
||||
} else {
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
host: host,
|
||||
port: gateway.sshPort)
|
||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,7 +689,9 @@ extension GeneralSettings {
|
||||
static func exerciseForTesting() {
|
||||
let state = AppState(preview: true)
|
||||
state.connectionMode = .remote
|
||||
state.remoteTransport = .ssh
|
||||
state.remoteTarget = "user@host:2222"
|
||||
state.remoteUrl = "wss://gateway.example.ts.net"
|
||||
state.remoteIdentity = "/tmp/id_ed25519"
|
||||
state.remoteProjectRoot = "/tmp/clawdbot"
|
||||
state.remoteCliPath = "/tmp/clawdbot"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@@ -517,11 +518,25 @@ extension MenuSessionsInjector {
|
||||
switch mode {
|
||||
case .remote:
|
||||
platform = "remote"
|
||||
let target = AppStateStore.shared.remoteTarget
|
||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||
if AppStateStore.shared.remoteTransport == .direct {
|
||||
let trimmedUrl = AppStateStore.shared.remoteUrl
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let url = URL(string: trimmedUrl), let urlHost = url.host, !urlHost.isEmpty {
|
||||
if let port = url.port {
|
||||
host = "\(urlHost):\(port)"
|
||||
} else {
|
||||
host = urlHost
|
||||
}
|
||||
} else {
|
||||
host = trimmedUrl.nonEmpty
|
||||
}
|
||||
} else {
|
||||
host = target.nonEmpty
|
||||
let target = AppStateStore.shared.remoteTarget
|
||||
if let parsed = CommandResolver.parseSSHTarget(target) {
|
||||
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
|
||||
} else {
|
||||
host = target.nonEmpty
|
||||
}
|
||||
}
|
||||
case .local:
|
||||
platform = "local"
|
||||
|
||||
@@ -25,7 +25,11 @@ extension OnboardingView {
|
||||
self.preferredGatewayID = gateway.stableID
|
||||
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||
|
||||
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||
if self.state.remoteTransport == .direct {
|
||||
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
|
||||
self.state.remoteUrl = url
|
||||
}
|
||||
} else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
||||
let user = NSUserName()
|
||||
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
|
||||
user: user,
|
||||
|
||||
@@ -177,42 +177,67 @@ extension OnboardingView {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
Text("SSH target")
|
||||
Text("Transport")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
Picker("Transport", selection: self.$state.remoteTransport) {
|
||||
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
|
||||
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("Identity file")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
if self.state.remoteTransport == .direct {
|
||||
GridRow {
|
||||
Text("Gateway URL")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
Text("Project root")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("CLI path")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField(
|
||||
"/Applications/Clawdbot.app/.../clawdbot",
|
||||
text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
if self.state.remoteTransport == .ssh {
|
||||
GridRow {
|
||||
Text("SSH target")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("user@host[:port]", text: self.$state.remoteTarget)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("Identity file")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("Project root")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
GridRow {
|
||||
Text("CLI path")
|
||||
.font(.callout.weight(.semibold))
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
TextField(
|
||||
"/Applications/Clawdbot.app/.../clawdbot",
|
||||
text: self.$state.remoteCliPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: fieldWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
|
||||
Text(self.state.remoteTransport == .direct
|
||||
? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert."
|
||||
: "Tip: keep Tailscale enabled so your gateway stays reachable.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -225,7 +250,10 @@ extension OnboardingView {
|
||||
}
|
||||
|
||||
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||
if self.state.remoteTransport == .direct {
|
||||
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
|
||||
}
|
||||
if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
|
||||
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
||||
return "\(host)\(portSuffix)"
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.1.23</string>
|
||||
<string>2026.1.24</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202601230</string>
|
||||
<string>202601240</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Clawdbot</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -175,4 +175,10 @@ import Testing
|
||||
customBindHost: "192.168.1.10")
|
||||
#expect(host == "192.168.1.10")
|
||||
}
|
||||
|
||||
@Test func normalizeGatewayUrlAddsDefaultPortForWs() {
|
||||
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
|
||||
#expect(url?.port == 18789)
|
||||
#expect(url?.absoluteString == "ws://gateway:18789")
|
||||
}
|
||||
}
|
||||
-1
@@ -1 +0,0 @@
|
||||
@import"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";:root{--bg: #0a0f14;--bg-accent: #111826;--bg-grad-1: #162031;--bg-grad-2: #1f2a22;--bg-overlay: rgba(255, 255, 255, .05);--bg-glow: rgba(245, 159, 74, .12);--panel: rgba(14, 20, 30, .88);--panel-strong: rgba(18, 26, 38, .96);--chrome: rgba(9, 14, 20, .72);--chrome-strong: rgba(9, 14, 20, .86);--text: rgba(244, 246, 251, .96);--chat-text: rgba(231, 237, 244, .92);--muted: rgba(156, 169, 189, .72);--border: rgba(255, 255, 255, .09);--border-strong: rgba(255, 255, 255, .16);--accent: #f59f4a;--accent-2: #34c7b7;--ok: #2bd97f;--warn: #f2c94c;--danger: #ff6b6b;--focus: rgba(245, 159, 74, .35);--grid-line: rgba(255, 255, 255, .04);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--font-body: "Work Sans", system-ui, sans-serif;--font-display: "Unbounded", "Times New Roman", serif;color-scheme:dark}:root[data-theme=light]{--bg: #f5f1ea;--bg-accent: #ffffff;--bg-grad-1: #f1e6d6;--bg-grad-2: #e5eef4;--bg-overlay: rgba(28, 32, 46, .05);--bg-glow: rgba(52, 199, 183, .14);--panel: rgba(255, 255, 255, .9);--panel-strong: rgba(255, 255, 255, .97);--chrome: rgba(255, 255, 255, .75);--chrome-strong: rgba(255, 255, 255, .88);--text: rgba(27, 36, 50, .98);--chat-text: rgba(36, 48, 66, .9);--muted: rgba(80, 94, 114, .7);--border: rgba(18, 24, 40, .12);--border-strong: rgba(18, 24, 40, .2);--accent: #e28a3f;--accent-2: #1ba99d;--ok: #1aa86c;--warn: #b3771c;--danger: #d44848;--focus: rgba(226, 138, 63, .35);--grid-line: rgba(18, 24, 40, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:15px/1.5 var(--font-body);background:radial-gradient(1200px 900px at 15% -10%,var(--bg-grad-1) 0%,transparent 55%) fixed,radial-gradient(900px 700px at 80% 10%,var(--bg-grad-2) 0%,transparent 60%) fixed,linear-gradient(160deg,var(--bg) 0%,var(--bg-accent) 100%) fixed;color:var(--text)}body:before{content:"";position:fixed;inset:0;background:linear-gradient(140deg,var(--bg-overlay) 0%,rgba(255,255,255,0) 40%),radial-gradient(620px 420px at 75% 75%,var(--bg-glow),transparent 60%);pointer-events:none;z-index:0}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .45s ease-out forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:inherit}button,input,textarea,select{font:inherit;color:inherit}@keyframes rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@keyframes dashboard-enter{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.shell{--shell-pad: 16px;--shell-gap: 16px;--shell-nav-width: 220px;--shell-topbar-height: 56px;--shell-focus-duration: .22s;--shell-focus-ease: cubic-bezier(.2, .85, .25, 1);min-height:100vh;display:grid;grid-template-columns:var(--shell-nav-width) minmax(0,1fr);grid-template-rows:var(--shell-topbar-height) 1fr;grid-template-areas:"topbar topbar" "nav content";gap:0;animation:dashboard-enter .6s ease-out;transition:grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease)}.shell--chat{min-height:100vh;height:100vh;overflow:hidden}@supports (height: 100dvh){.shell--chat{height:100dvh}}.shell--nav-collapsed,.shell--chat-focus{grid-template-columns:0px minmax(0,1fr)}.shell--onboarding{grid-template-rows:0 1fr}.shell--onboarding .topbar{display:none}.shell--onboarding .content{padding-top:0}.shell--chat-focus .content{padding-top:0;gap:0}.topbar{grid-area:topbar;position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 20px;height:var(--shell-topbar-height);border-bottom:1px solid var(--border);background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.topbar-left{display:flex;align-items:center;gap:12px}.topbar .nav-collapse-toggle{width:44px;height:44px;margin-bottom:0}.topbar .nav-collapse-toggle__icon{font-size:22px}.brand{display:flex;flex-direction:column;gap:2px}.brand-title{font-family:var(--font-display);font-size:16px;letter-spacing:1px;text-transform:uppercase;font-weight:600;line-height:1.1}.brand-sub{font-size:10px;color:var(--muted);letter-spacing:.8px;text-transform:uppercase;line-height:1}.topbar-status{display:flex;align-items:center;gap:8px}.toLine truncated
|
||||
+1
@@ -0,0 +1 @@
|
||||
@import"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";:root{--bg: #0a0f14;--bg-accent: #111826;--bg-grad-1: #162031;--bg-grad-2: #1f2a22;--bg-overlay: rgba(255, 255, 255, .05);--bg-glow: rgba(245, 159, 74, .12);--panel: rgba(14, 20, 30, .88);--panel-strong: rgba(18, 26, 38, .96);--chrome: rgba(9, 14, 20, .72);--chrome-strong: rgba(9, 14, 20, .86);--text: rgba(244, 246, 251, .96);--chat-text: rgba(231, 237, 244, .92);--muted: rgba(156, 169, 189, .72);--border: rgba(255, 255, 255, .09);--border-strong: rgba(255, 255, 255, .16);--accent: #f59f4a;--accent-2: #34c7b7;--ok: #2bd97f;--warn: #f2c94c;--danger: #ff6b6b;--focus: rgba(245, 159, 74, .35);--grid-line: rgba(255, 255, 255, .04);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--font-body: "Work Sans", system-ui, sans-serif;--font-display: "Unbounded", "Times New Roman", serif;color-scheme:dark}:root[data-theme=light]{--bg: #f5f1ea;--bg-accent: #ffffff;--bg-grad-1: #f1e6d6;--bg-grad-2: #e5eef4;--bg-overlay: rgba(28, 32, 46, .05);--bg-glow: rgba(52, 199, 183, .14);--panel: rgba(255, 255, 255, .9);--panel-strong: rgba(255, 255, 255, .97);--chrome: rgba(255, 255, 255, .75);--chrome-strong: rgba(255, 255, 255, .88);--text: rgba(27, 36, 50, .98);--chat-text: rgba(36, 48, 66, .9);--muted: rgba(80, 94, 114, .7);--border: rgba(18, 24, 40, .12);--border-strong: rgba(18, 24, 40, .2);--accent: #e28a3f;--accent-2: #1ba99d;--ok: #1aa86c;--warn: #b3771c;--danger: #d44848;--focus: rgba(226, 138, 63, .35);--grid-line: rgba(18, 24, 40, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:15px/1.5 var(--font-body);background:radial-gradient(1200px 900px at 15% -10%,var(--bg-grad-1) 0%,transparent 55%) fixed,radial-gradient(900px 700px at 80% 10%,var(--bg-grad-2) 0%,transparent 60%) fixed,linear-gradient(160deg,var(--bg) 0%,var(--bg-accent) 100%) fixed;color:var(--text)}body:before{content:"";position:fixed;inset:0;background:linear-gradient(140deg,var(--bg-overlay) 0%,rgba(255,255,255,0) 40%),radial-gradient(620px 420px at 75% 75%,var(--bg-glow),transparent 60%);pointer-events:none;z-index:0}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .45s ease-out forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:inherit}button,input,textarea,select{font:inherit;color:inherit}@keyframes rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@keyframes dashboard-enter{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.shell{--shell-pad: 16px;--shell-gap: 16px;--shell-nav-width: 220px;--shell-topbar-height: 56px;--shell-focus-duration: .22s;--shell-focus-ease: cubic-bezier(.2, .85, .25, 1);height:100vh;display:grid;grid-template-columns:var(--shell-nav-width) minmax(0,1fr);grid-template-rows:var(--shell-topbar-height) 1fr;grid-template-areas:"topbar topbar" "nav content";gap:0;animation:dashboard-enter .6s ease-out;transition:grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);overflow:hidden}@supports (height: 100dvh){.shell{height:100dvh}}.shell--chat{min-height:100vh;height:100vh;overflow:hidden}@supports (height: 100dvh){.shell--chat{height:100dvh}}.shell--nav-collapsed,.shell--chat-focus{grid-template-columns:0px minmax(0,1fr)}.shell--onboarding{grid-template-rows:0 1fr}.shell--onboarding .topbar{display:none}.shell--onboarding .content{padding-top:0}.shell--chat-focus .content{padding-top:0;gap:0}.topbar{grid-area:topbar;position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 20px;height:var(--shell-topbar-height);border-bottom:1px solid var(--border);background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.topbar-left{display:flex;align-items:center;gap:12px}.topbar .nav-collapse-toggle{width:44px;height:44px;margin-bottom:0}.topbar .nav-collapse-toggle__icon{font-size:22px}.brand{display:flex;flex-direction:column;gap:2px}.brand-title{font-family:var(--font-display);font-size:16px;letter-spacing:1px;text-transform:uppercase;font-weight:600;line-height:1.1}.brand-sub{font-size:10px;color:var(--muted);letter-spacing:.8px;text-transform:uppercase;line-heightLine truncated
|
||||
+470
-458
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index-DsXRcnEw.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"index-bYQnHP3a.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
|
||||
Vendored
+2
-2
@@ -6,8 +6,8 @@
|
||||
<title>Clawdbot Control</title>
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<link rel="icon" href="./favicon.ico" sizes="any" />
|
||||
<script type="module" crossorigin src="./assets/index-bYQnHP3a.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BPDeGGxb.css">
|
||||
<script type="module" crossorigin src="./assets/index-DsXRcnEw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-BvhR9FCb.css">
|
||||
</head>
|
||||
<body>
|
||||
<clawdbot-app></clawdbot-app>
|
||||
|
||||
+67
-4
@@ -75,10 +75,10 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
|
||||
auth: "aws-sdk",
|
||||
models: [
|
||||
{
|
||||
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||
name: "Claude 3.7 Sonnet (Bedrock)",
|
||||
id: "anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
name: "Claude Opus 4.5 (Bedrock)",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192
|
||||
@@ -89,12 +89,75 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0" }
|
||||
model: { primary: "amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## EC2 Instance Roles
|
||||
|
||||
When running Clawdbot on an EC2 instance with an IAM role attached, the AWS SDK
|
||||
will automatically use the instance metadata service (IMDS) for authentication.
|
||||
However, Clawdbot's credential detection currently only checks for environment
|
||||
variables, not IMDS credentials.
|
||||
|
||||
**Workaround:** Set `AWS_PROFILE=default` to signal that AWS credentials are
|
||||
available. The actual authentication still uses the instance role via IMDS.
|
||||
|
||||
```bash
|
||||
# Add to ~/.bashrc or your shell profile
|
||||
export AWS_PROFILE=default
|
||||
export AWS_REGION=us-east-1
|
||||
```
|
||||
|
||||
**Required IAM permissions** for the EC2 instance role:
|
||||
- `bedrock:InvokeModel`
|
||||
- `bedrock:InvokeModelWithResponseStream`
|
||||
- `bedrock:ListFoundationModels` (for automatic discovery)
|
||||
|
||||
Or attach the managed policy `AmazonBedrockFullAccess`.
|
||||
|
||||
**Quick setup:**
|
||||
|
||||
```bash
|
||||
# 1. Create IAM role and instance profile
|
||||
aws iam create-role --role-name EC2-Bedrock-Access \
|
||||
--assume-role-policy-document '{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"Service": "ec2.amazonaws.com"},
|
||||
"Action": "sts:AssumeRole"
|
||||
}]
|
||||
}'
|
||||
|
||||
aws iam attach-role-policy --role-name EC2-Bedrock-Access \
|
||||
--policy-arn arn:aws:iam::aws:policy/AmazonBedrockFullAccess
|
||||
|
||||
aws iam create-instance-profile --instance-profile-name EC2-Bedrock-Access
|
||||
aws iam add-role-to-instance-profile \
|
||||
--instance-profile-name EC2-Bedrock-Access \
|
||||
--role-name EC2-Bedrock-Access
|
||||
|
||||
# 2. Attach to your EC2 instance
|
||||
aws ec2 associate-iam-instance-profile \
|
||||
--instance-id i-xxxxx \
|
||||
--iam-instance-profile Name=EC2-Bedrock-Access
|
||||
|
||||
# 3. On the EC2 instance, enable discovery
|
||||
clawdbot config set models.bedrockDiscovery.enabled true
|
||||
clawdbot config set models.bedrockDiscovery.region us-east-1
|
||||
|
||||
# 4. Set the workaround env vars
|
||||
echo 'export AWS_PROFILE=default' >> ~/.bashrc
|
||||
echo 'export AWS_REGION=us-east-1' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
|
||||
# 5. Verify models are discovered
|
||||
clawdbot models list
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Bedrock requires **model access** enabled in your AWS account/region.
|
||||
|
||||
@@ -2825,13 +2825,14 @@ Auth and Tailscale:
|
||||
|
||||
Remote client defaults (CLI):
|
||||
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
|
||||
- `gateway.remote.transport` selects the macOS remote transport (`ssh` default, `direct` for ws/wss). When `direct`, `gateway.remote.url` must be `ws://` or `wss://`. `ws://host` defaults to port `18789`.
|
||||
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
|
||||
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
|
||||
|
||||
macOS app behavior:
|
||||
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
|
||||
- If `gateway.mode` is unset but `gateway.remote.url` is set, the macOS app treats it as remote mode.
|
||||
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` in remote mode) back to the config file.
|
||||
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` + `gateway.remote.transport` in remote mode) back to the config file.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -2846,6 +2847,21 @@ macOS app behavior:
|
||||
}
|
||||
```
|
||||
|
||||
Direct transport example (macOS app):
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: {
|
||||
transport: "direct",
|
||||
url: "wss://gateway.example.ts.net",
|
||||
token: "your-token"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `gateway.reload` (Config hot reload)
|
||||
|
||||
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
|
||||
|
||||
@@ -82,7 +82,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
every: "30m", // default: 30m (0m disables)
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
|
||||
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
|
||||
target: "last", // last | none | <channel id> (core or plugin, e.g. "bluebubbles")
|
||||
to: "+15551234567", // optional channel-specific override
|
||||
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
|
||||
ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK
|
||||
|
||||
+304
-3
@@ -9,24 +9,41 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
|
||||
- [What is Clawdbot?](#what-is-clawdbot)
|
||||
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
|
||||
- [What’s the value proposition?](#whats-the-value-proposition)
|
||||
- [Quick start and first-run setup](#quick-start-and-first-run-setup)
|
||||
- [What’s the recommended way to install and set up Clawdbot?](#whats-the-recommended-way-to-install-and-set-up-clawdbot)
|
||||
- [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding)
|
||||
- [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote)
|
||||
- [What runtime do I need?](#what-runtime-do-i-need)
|
||||
- [Does it run on Raspberry Pi?](#does-it-run-on-raspberry-pi)
|
||||
- [Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?](#can-i-migrate-my-setup-to-a-new-machine-mac-mini-without-redoing-onboarding)
|
||||
- [Where do I see what’s new in the latest version?](#where-do-i-see-whats-new-in-the-latest-version)
|
||||
- [I can't access docs.clawd.bot (SSL error). What now?](#i-cant-access-docsclawdbot-ssl-error-what-now)
|
||||
- [What’s the difference between stable and beta?](#whats-the-difference-between-stable-and-beta)
|
||||
- [How do I install the beta version, and what’s the difference between beta and dev?](#how-do-i-install-the-beta-version-and-whats-the-difference-between-beta-and-dev)
|
||||
- [The docs didn’t answer my question — how do I get a better answer?](#the-docs-didnt-answer-my-question--how-do-i-get-a-better-answer)
|
||||
- [How do I install Clawdbot on Linux?](#how-do-i-install-clawdbot-on-linux)
|
||||
- [How do I install Clawdbot on a VPS?](#how-do-i-install-clawdbot-on-a-vps)
|
||||
- [Can I ask Clawd to update itself?](#can-i-ask-clawd-to-update-itself)
|
||||
- [What does the onboarding wizard actually do?](#what-does-the-onboarding-wizard-actually-do)
|
||||
- [Do I need a Claude or OpenAI subscription to run this?](#do-i-need-a-claude-or-openai-subscription-to-run-this)
|
||||
- [How does Anthropic "setup-token" auth work?](#how-does-anthropic-setup-token-auth-work)
|
||||
- [Where do I find an Anthropic setup-token?](#where-do-i-find-an-anthropic-setup-token)
|
||||
- [Do you support Claude subscription auth (Claude Code OAuth)?](#do-you-support-claude-subscription-auth-claude-code-oauth)
|
||||
- [Why am I seeing `HTTP 429: rate_limit_error` from Anthropic?](#why-am-i-seeing-http-429-rate_limit_error-from-anthropic)
|
||||
- [Is AWS Bedrock supported?](#is-aws-bedrock-supported)
|
||||
- [How does Codex auth work?](#how-does-codex-auth-work)
|
||||
- [Do you support OpenAI subscription auth (Codex OAuth)?](#do-you-support-openai-subscription-auth-codex-oauth)
|
||||
- [Is a local model OK for casual chats?](#is-a-local-model-ok-for-casual-chats)
|
||||
- [How do I keep hosted model traffic in a specific region?](#how-do-i-keep-hosted-model-traffic-in-a-specific-region)
|
||||
- [Do I have to buy a Mac Mini to install this?](#do-i-have-to-buy-a-mac-mini-to-install-this)
|
||||
- [Do I need a Mac mini for iMessage support?](#do-i-need-a-mac-mini-for-imessage-support)
|
||||
- [Can I use Bun?](#can-i-use-bun)
|
||||
- [Telegram: what goes in `allowFrom`?](#telegram-what-goes-in-allowfrom)
|
||||
- [Can multiple people use one WhatsApp number with different Clawdbots?](#can-multiple-people-use-one-whatsapp-number-with-different-clawdbots)
|
||||
- [Can I run a "fast chat" agent and an "Opus for coding" agent?](#can-i-run-a-fast-chat-agent-and-an-opus-for-coding-agent)
|
||||
- [Does Homebrew work on Linux?](#does-homebrew-work-on-linux)
|
||||
- [What’s the difference between the hackable (git) install and npm install?](#whats-the-difference-between-the-hackable-git-install-and-npm-install)
|
||||
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
|
||||
- [Should I run the Gateway on my laptop or a VPS?](#should-i-run-the-gateway-on-my-laptop-or-a-vps)
|
||||
- [Skills and automation](#skills-and-automation)
|
||||
@@ -34,6 +51,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [Can I load skills from a custom folder?](#can-i-load-skills-from-a-custom-folder)
|
||||
- [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks)
|
||||
- [How do I install skills on Linux?](#how-do-i-install-skills-on-linux)
|
||||
- [Can Clawdbot run tasks on a schedule or continuously in the background?](#can-clawdbot-run-tasks-on-a-schedule-or-continuously-in-the-background)
|
||||
- [Can I run Apple/macOS-only skills from Linux?](#can-i-run-applemacos-only-skills-from-linux)
|
||||
- [Do you have a Notion or HeyGen integration?](#do-you-have-a-notion-or-heygen-integration)
|
||||
- [How do I install the Chrome extension for browser takeover?](#how-do-i-install-the-chrome-extension-for-browser-takeover)
|
||||
@@ -45,6 +63,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [Where things live on disk](#where-things-live-on-disk)
|
||||
- [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
|
||||
- [Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?](#where-should-agentsmd--soulmd--usermd--memorymd-live)
|
||||
- [What’s the recommended backup strategy?](#whats-the-recommended-backup-strategy)
|
||||
- [How do I completely uninstall Clawdbot?](#how-do-i-completely-uninstall-clawdbot)
|
||||
- [Can agents work outside the workspace?](#can-agents-work-outside-the-workspace)
|
||||
- [I’m in remote mode — where is the session store?](#im-in-remote-mode-where-is-the-session-store)
|
||||
@@ -81,8 +100,11 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
|
||||
- [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms)
|
||||
- [How many workspaces and agents can I create?](#how-many-workspaces-and-agents-can-i-create)
|
||||
- [Can I run multiple bots or chats at the same time (Slack), and how should I set that up?](#can-i-run-multiple-bots-or-chats-at-the-same-time-slack-and-how-should-i-set-that-up)
|
||||
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching)
|
||||
- [What is the “default model”?](#what-is-the-default-model)
|
||||
- [What model do you recommend?](#what-model-do-you-recommend)
|
||||
- [What do Clawd, Flawd, and Krill use for models?](#what-do-clawd-flawd-and-krill-use-for-models)
|
||||
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
||||
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
|
||||
- [Why do I see “Unknown model: minimax/MiniMax-M2.1”?](#why-do-i-see-unknown-model-minimaxminimax-m21)
|
||||
@@ -183,6 +205,28 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
|
||||
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost (plugin), Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product.
|
||||
|
||||
### What’s the value proposition?
|
||||
|
||||
Clawdbot is not “just a Claude wrapper.” It’s a **local-first control plane** that lets you run a
|
||||
capable assistant on **your own hardware**, reachable from the chat apps you already use, with
|
||||
stateful sessions, memory, and tools — without handing control of your workflows to a hosted
|
||||
SaaS.
|
||||
|
||||
Highlights:
|
||||
- **Your devices, your data:** run the Gateway wherever you want (Mac, Linux, VPS) and keep the
|
||||
workspace + session history local.
|
||||
- **Real channels, not a web sandbox:** WhatsApp/Telegram/Slack/Discord/Signal/iMessage/etc,
|
||||
plus mobile voice and Canvas on supported platforms.
|
||||
- **Model-agnostic:** use Anthropic, OpenAI, MiniMax, OpenRouter, etc., with per‑agent routing
|
||||
and failover.
|
||||
- **Local-only option:** run local models so **all data can stay on your device** if you want.
|
||||
- **Multi-agent routing:** separate agents per channel, account, or task, each with its own
|
||||
workspace and defaults.
|
||||
- **Open source and hackable:** inspect, extend, and self-host without vendor lock‑in.
|
||||
|
||||
Docs: [Gateway](/gateway), [Channels](/channels), [Multi‑agent](/concepts/multi-agent),
|
||||
[Memory](/concepts/memory).
|
||||
|
||||
## Quick start and first-run setup
|
||||
|
||||
### What’s the recommended way to install and set up Clawdbot?
|
||||
@@ -231,6 +275,139 @@ See [Dashboard](/web/dashboard) and [Web surfaces](/web) for bind modes and auth
|
||||
|
||||
Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** for the Gateway.
|
||||
|
||||
### Does it run on Raspberry Pi?
|
||||
|
||||
Yes. The Gateway is lightweight — docs list **512MB–1GB RAM**, **1 core**, and about **500MB**
|
||||
disk as enough for personal use, and note that a **Raspberry Pi 4 can run it**.
|
||||
|
||||
If you want extra headroom (logs, media, other services), **2GB is recommended**, but it’s
|
||||
not a hard minimum.
|
||||
|
||||
### Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?
|
||||
|
||||
Yes. Copy the **state directory** and **workspace**, then run Doctor once. This
|
||||
keeps your bot “exactly the same” (memory, session history, auth, and channel
|
||||
state) as long as you copy **both** locations:
|
||||
|
||||
1) Install Clawdbot on the new machine.
|
||||
2) Copy `$CLAWDBOT_STATE_DIR` (default: `~/.clawdbot`) from the old machine.
|
||||
3) Copy your workspace (default: `~/clawd`).
|
||||
4) Run `clawdbot doctor` and restart the Gateway service.
|
||||
|
||||
That preserves config, auth profiles, WhatsApp creds, sessions, and memory. If you’re in
|
||||
remote mode, remember the gateway host owns the session store and workspace.
|
||||
|
||||
**Important:** if you only commit/push your workspace to GitHub, you’re backing
|
||||
up **memory + bootstrap files**, but **not** session history or auth. Those live
|
||||
under `~/.clawdbot/` (for example `~/.clawdbot/agents/<agentId>/sessions/`).
|
||||
|
||||
Related: [Where things live on disk](/help/faq#where-does-clawdbot-store-its-data),
|
||||
[Agent workspace](/concepts/agent-workspace), [Doctor](/gateway/doctor),
|
||||
[Remote mode](/gateway/remote).
|
||||
|
||||
### Where do I see what’s new in the latest version?
|
||||
|
||||
Check the GitHub changelog:
|
||||
https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md
|
||||
|
||||
Newest entries are at the top. If the top section is marked **Unreleased**, the next dated
|
||||
section is the latest shipped version. Entries are grouped by **Highlights**, **Changes**, and
|
||||
**Fixes** (plus docs/other sections when needed).
|
||||
|
||||
### I can't access docs.clawd.bot (SSL error). What now?
|
||||
|
||||
Some Comcast/Xfinity connections incorrectly block `docs.clawd.bot` via Xfinity
|
||||
Advanced Security. Disable it or allowlist `docs.clawd.bot`, then retry. More
|
||||
detail: [Troubleshooting](/help/troubleshooting#docsclawdbot-shows-an-ssl-error-comcastxfinity).
|
||||
Please help us unblock it by reporting here: https://spa.xfinity.com/check_url_status.
|
||||
|
||||
If you still can't reach the site, the docs are mirrored on GitHub:
|
||||
https://github.com/clawdbot/clawdbot/tree/main/docs
|
||||
|
||||
### What’s the difference between stable and beta?
|
||||
|
||||
**Stable** and **beta** are **npm dist‑tags**, not separate code lines:
|
||||
- `latest` = stable
|
||||
- `beta` = early build for testing
|
||||
|
||||
We ship builds to **beta**, test them, and once a build is solid we **promote
|
||||
that same version to `latest`**. That’s why beta and stable can point at the
|
||||
**same version**.
|
||||
|
||||
See what changed:
|
||||
https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md
|
||||
|
||||
### How do I install the beta version, and what’s the difference between beta and dev?
|
||||
|
||||
**Beta** is the npm dist‑tag `beta` (may match `latest`).
|
||||
**Dev** is the moving head of `main` (git); when published, it uses the npm dist‑tag `dev`.
|
||||
|
||||
One‑liners (macOS/Linux):
|
||||
|
||||
```bash
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://clawd.bot/install.sh | bash -s -- --beta
|
||||
```
|
||||
|
||||
```bash
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://clawd.bot/install.sh | bash -s -- --install-method git
|
||||
```
|
||||
|
||||
Windows installer (PowerShell):
|
||||
https://clawd.bot/install.ps1
|
||||
|
||||
More detail: [Development channels](/install/development-channels) and [Installer flags](/install/installer).
|
||||
|
||||
### The docs didn’t answer my question — how do I get a better answer?
|
||||
|
||||
Use the **hackable (git) install** so you have the full source and docs locally, then ask
|
||||
your bot (or Claude/Codex) *from that folder* so it can read the repo and answer precisely.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git
|
||||
```
|
||||
|
||||
More detail: [Install](/install) and [Installer flags](/install/installer).
|
||||
|
||||
### How do I install Clawdbot on Linux?
|
||||
|
||||
Short answer: follow the Linux guide, then run the onboarding wizard.
|
||||
|
||||
- Linux quick path + service install: [Linux](/platforms/linux).
|
||||
- Full walkthrough: [Getting Started](/start/getting-started).
|
||||
- Installer + updates: [Install & updates](/install/updating).
|
||||
|
||||
### How do I install Clawdbot on a VPS?
|
||||
|
||||
Any Linux VPS works. Install on the server, then use SSH/Tailscale to reach the Gateway.
|
||||
|
||||
Guides: [exe.dev](/platforms/exe-dev), [Hetzner](/platforms/hetzner), [Fly.io](/platforms/fly).
|
||||
Remote access: [Gateway remote](/gateway/remote).
|
||||
|
||||
### Can I ask Clawd to update itself?
|
||||
|
||||
Short answer: **possible, not recommended**. The update flow can restart the
|
||||
Gateway (which drops the active session), may need a clean git checkout, and
|
||||
can prompt for confirmation. Safer: run updates from a shell as the operator.
|
||||
|
||||
Use the CLI:
|
||||
|
||||
```bash
|
||||
clawdbot update
|
||||
clawdbot update status
|
||||
clawdbot update --channel stable|beta|dev
|
||||
clawdbot update --tag <dist-tag|version>
|
||||
clawdbot update --no-restart
|
||||
```
|
||||
|
||||
If you must automate from an agent:
|
||||
|
||||
```bash
|
||||
clawdbot update --yes --no-restart
|
||||
clawdbot gateway restart
|
||||
```
|
||||
|
||||
Docs: [Update](/cli/update), [Updating](/install/updating).
|
||||
|
||||
### What does the onboarding wizard actually do?
|
||||
|
||||
`clawdbot onboard` is the recommended setup path. In **local mode** it walks you through:
|
||||
@@ -244,6 +421,15 @@ Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** fo
|
||||
|
||||
It also warns if your configured model is unknown or missing auth.
|
||||
|
||||
### Do I need a Claude or OpenAI subscription to run this?
|
||||
|
||||
No. You can run Clawdbot with **API keys** (Anthropic/OpenAI/others) or with
|
||||
**local‑only models** so your data stays on your device. Subscriptions (Claude
|
||||
Pro/Max or OpenAI Codex) are optional ways to authenticate those providers.
|
||||
|
||||
Docs: [Anthropic](/providers/anthropic), [OpenAI](/providers/openai),
|
||||
[Local models](/gateway/local-models), [Models](/concepts/models).
|
||||
|
||||
### How does Anthropic "setup-token" auth work?
|
||||
|
||||
`claude setup-token` generates a **token string** via the Claude Code CLI (it is not available in the web console). You can run it on **any machine**. If Claude Code CLI credentials are present on the gateway host, Clawdbot can reuse them; otherwise choose **Anthropic token (paste setup-token)** and paste the string. The token is stored as an auth profile for the **anthropic** provider and used like an API key or OAuth profile. More detail: [OAuth](/concepts/oauth).
|
||||
@@ -268,6 +454,16 @@ Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also support
|
||||
|
||||
Note: Claude subscription access is governed by Anthropic’s terms. For production or multi‑user workloads, API keys are usually the safer choice.
|
||||
|
||||
### Why am I seeing `HTTP 429: rate_limit_error` from Anthropic?
|
||||
|
||||
That means your **Anthropic quota/rate limit** is exhausted for the current window. If you
|
||||
use a **Claude subscription** (setup‑token or Claude Code OAuth), wait for the window to
|
||||
reset or upgrade your plan. If you use an **Anthropic API key**, check the Anthropic Console
|
||||
for usage/billing and raise limits as needed.
|
||||
|
||||
Tip: set a **fallback model** so Clawdbot can keep replying while a provider is rate‑limited.
|
||||
See [Models](/cli/models) and [OAuth](/concepts/oauth).
|
||||
|
||||
### Is AWS Bedrock supported?
|
||||
|
||||
Yes — via pi‑ai’s **Amazon Bedrock (Converse)** provider with **manual config**. You must supply AWS credentials/region on the gateway host and add a Bedrock provider entry in your models config. See [Amazon Bedrock](/bedrock) and [Model providers](/providers/models). If you prefer a managed key flow, an OpenAI‑compatible proxy in front of Bedrock is still a valid option.
|
||||
@@ -276,6 +472,14 @@ Yes — via pi‑ai’s **Amazon Bedrock (Converse)** provider with **manual con
|
||||
|
||||
Clawdbot supports **OpenAI Code (Codex)** via OAuth or by reusing your Codex CLI login (`~/.codex/auth.json`). The wizard can import the CLI login or run the OAuth flow and will set the default model to `openai-codex/gpt-5.2` when appropriate. See [Model providers](/concepts/model-providers) and [Wizard](/start/wizard).
|
||||
|
||||
### Do you support OpenAI subscription auth (Codex OAuth)?
|
||||
|
||||
Yes. Clawdbot fully supports **OpenAI Code (Codex) subscription OAuth** and can also reuse an
|
||||
existing Codex CLI login (`~/.codex/auth.json`) on the gateway host. The onboarding wizard
|
||||
can import the CLI login or run the OAuth flow for you.
|
||||
|
||||
See [OAuth](/concepts/oauth), [Model providers](/concepts/model-providers), and [Wizard](/start/wizard).
|
||||
|
||||
### Is a local model OK for casual chats?
|
||||
|
||||
Usually no. Clawdbot needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.1 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk — see [Security](/gateway/security).
|
||||
@@ -284,6 +488,31 @@ Usually no. Clawdbot needs large context + strong safety; small cards truncate a
|
||||
|
||||
Pick region-pinned endpoints. OpenRouter exposes US-hosted options for MiniMax, Kimi, and GLM; choose the US-hosted variant to keep data in-region. You can still list Anthropic/OpenAI alongside these by using `models.mode: "merge"` so fallbacks stay available while respecting the regioned provider you select.
|
||||
|
||||
### Do I have to buy a Mac Mini to install this?
|
||||
|
||||
No. Clawdbot runs on macOS or Linux (Windows via WSL2). A Mac mini is optional — some people
|
||||
buy one as an always‑on host, but a small VPS, home server, or Raspberry Pi‑class box works too.
|
||||
|
||||
You only need a Mac **for macOS‑only tools**. For iMessage, you can keep the Gateway on Linux
|
||||
and run `imsg` on any Mac over SSH by pointing `channels.imessage.cliPath` at an SSH wrapper.
|
||||
If you want other macOS‑only tools, run the Gateway on a Mac or pair a macOS node.
|
||||
|
||||
Docs: [iMessage](/channels/imessage), [Nodes](/nodes), [Mac remote mode](/platforms/mac/remote).
|
||||
|
||||
### Do I need a Mac mini for iMessage support?
|
||||
|
||||
You need **some macOS device** signed into Messages. It does **not** have to be a Mac mini —
|
||||
any Mac works. Clawdbot’s iMessage integrations run on macOS (BlueBubbles or `imsg`), while
|
||||
the Gateway can run elsewhere.
|
||||
|
||||
Common setups:
|
||||
- Run the Gateway on Linux/VPS, and point `channels.imessage.cliPath` at an SSH wrapper that
|
||||
runs `imsg` on the Mac.
|
||||
- Run everything on the Mac if you want the simplest single‑machine setup.
|
||||
|
||||
Docs: [iMessage](/channels/imessage), [BlueBubbles](/channels/bluebubbles),
|
||||
[Mac remote mode](/platforms/mac/remote).
|
||||
|
||||
### Can I use Bun?
|
||||
|
||||
Bun is **not recommended**. We see runtime bugs, especially with WhatsApp and Telegram.
|
||||
@@ -329,6 +558,15 @@ brew install <formula>
|
||||
If you run Clawdbot via systemd, ensure the service PATH includes `/home/linuxbrew/.linuxbrew/bin` (or your brew prefix) so `brew`-installed tools resolve in non‑login shells.
|
||||
Recent builds also prepend common user bin dirs on Linux systemd services (for example `~/.local/bin`, `~/.npm-global/bin`, `~/.local/share/pnpm`, `~/.bun/bin`) and honor `PNPM_HOME`, `NPM_CONFIG_PREFIX`, `BUN_INSTALL`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `NVM_DIR`, and `FNM_DIR` when set.
|
||||
|
||||
### What’s the difference between the hackable (git) install and npm install?
|
||||
|
||||
- **Hackable (git) install:** full source checkout, editable, best for contributors.
|
||||
You run builds locally and can patch code/docs.
|
||||
- **npm install:** global CLI install, no repo, best for “just run it.”
|
||||
Updates come from npm dist‑tags.
|
||||
|
||||
Docs: [Getting started](/start/getting-started), [Updating](/install/updating).
|
||||
|
||||
### Can I switch between npm and git installs later?
|
||||
|
||||
Yes. Install the other flavor, then run Doctor so the gateway service points at the new entrypoint.
|
||||
@@ -405,6 +643,17 @@ npm i -g clawdhub
|
||||
pnpm add -g clawdhub
|
||||
```
|
||||
|
||||
### Can Clawdbot run tasks on a schedule or continuously in the background?
|
||||
|
||||
Yes. Use the Gateway scheduler:
|
||||
|
||||
- **Cron jobs** for scheduled or recurring tasks (persist across restarts).
|
||||
- **Heartbeat** for “main session” periodic checks.
|
||||
- **Isolated jobs** for autonomous agents that post summaries or deliver to chats.
|
||||
|
||||
Docs: [Cron jobs](/automation/cron-jobs), [Cron vs Heartbeat](/automation/cron-vs-heartbeat),
|
||||
[Heartbeat](/gateway/heartbeat).
|
||||
|
||||
### Is there a way to run Apple/macOS-only skills if my Gateway runs on Linux?
|
||||
|
||||
Not directly. macOS skills are gated by `metadata.clawdbot.os` plus required binaries, and skills only appear in the system prompt when they are eligible on the **Gateway host**. On Linux, `darwin`-only skills (like `imsg`, `apple-notes`, `apple-reminders`) will not load unless you override the gating.
|
||||
@@ -573,6 +822,18 @@ workspace, not your local laptop).
|
||||
|
||||
See [Agent workspace](/concepts/agent-workspace) and [Memory](/concepts/memory).
|
||||
|
||||
### What’s the recommended backup strategy?
|
||||
|
||||
Put your **agent workspace** in a **private** git repo and back it up somewhere
|
||||
private (for example GitHub private). This captures memory + AGENTS/SOUL/USER
|
||||
files, and lets you restore the assistant’s “mind” later.
|
||||
|
||||
Do **not** commit anything under `~/.clawdbot` (credentials, sessions, tokens).
|
||||
If you need a full restore, back up both the workspace and the state directory
|
||||
separately (see the migration question above).
|
||||
|
||||
Docs: [Agent workspace](/concepts/agent-workspace).
|
||||
|
||||
### How do I completely uninstall Clawdbot?
|
||||
|
||||
See the dedicated guide: [Uninstall](/install/uninstall).
|
||||
@@ -756,8 +1017,9 @@ Docs: [Nodes](/nodes), [Gateway protocol](/gateway/protocol), [macOS remote mode
|
||||
### Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?
|
||||
|
||||
Yes — nodes are the first‑class way to reach your laptop from a remote Gateway, and they
|
||||
unlock more than shell access. The Gateway runs on macOS/Linux (Windows via WSL2), so a common
|
||||
setup is an always‑on host (VPS/home box/Pi) plus your laptop as a node.
|
||||
unlock more than shell access. The Gateway runs on macOS/Linux (Windows via WSL2) and is
|
||||
lightweight (a small VPS or Raspberry Pi-class box is fine; 4 GB RAM is plenty), so a common
|
||||
setup is an always‑on host plus your laptop as a node.
|
||||
|
||||
- **No inbound SSH required.** Nodes connect out to the Gateway WebSocket and use device pairing.
|
||||
- **Safer execution controls.** `system.run` is gated by node allowlists/approvals on that laptop.
|
||||
@@ -773,7 +1035,8 @@ Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes), [Chrome extension](/tools/chrome
|
||||
### Do nodes run a gateway service?
|
||||
|
||||
No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect
|
||||
to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
|
||||
to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app). For headless node
|
||||
hosts and CLI control, see [Node host CLI](/cli/node).
|
||||
|
||||
A full restart is required for `gateway`, `discovery`, and `canvasHost` changes.
|
||||
|
||||
@@ -1040,6 +1303,24 @@ Tips:
|
||||
- Prune old sessions (delete JSONL or store entries) if disk grows.
|
||||
- Use `clawdbot doctor` to spot stray workspaces and profile mismatches.
|
||||
|
||||
### Can I run multiple bots or chats at the same time (Slack), and how should I set that up?
|
||||
|
||||
Yes. Use **Multi‑Agent Routing** to run multiple isolated agents and route inbound messages by
|
||||
channel/account/peer. Slack is supported as a channel and can be bound to specific agents.
|
||||
|
||||
Browser access is powerful but not “do anything a human can” — anti‑bot, CAPTCHAs, and MFA can
|
||||
still block automation. For the most reliable browser control, use the Chrome extension relay
|
||||
on the machine that runs the browser (and keep the Gateway anywhere).
|
||||
|
||||
Best‑practice setup:
|
||||
- Always‑on Gateway host (VPS/Mac mini).
|
||||
- One agent per role (bindings).
|
||||
- Slack channel(s) bound to those agents.
|
||||
- Local browser via extension relay (or a node) when needed.
|
||||
|
||||
Docs: [Multi‑Agent Routing](/concepts/multi-agent), [Slack](/channels/slack),
|
||||
[Browser](/tools/browser), [Chrome extension](/tools/chrome-extension), [Nodes](/nodes).
|
||||
|
||||
## Models: defaults, selection, aliases, switching
|
||||
|
||||
### What is the “default model”?
|
||||
@@ -1052,6 +1333,26 @@ agents.defaults.model.primary
|
||||
|
||||
Models are referenced as `provider/model` (example: `anthropic/claude-opus-4-5`). If you omit the provider, Clawdbot currently assumes `anthropic` as a temporary deprecation fallback — but you should still **explicitly** set `provider/model`.
|
||||
|
||||
### What model do you recommend?
|
||||
|
||||
**Recommended default:** `anthropic/claude-opus-4-5`.
|
||||
**Good alternative:** `anthropic/claude-sonnet-4-5`.
|
||||
**Reliable (less character):** `openai/gpt-5.2` — nearly as good as Opus, just less personality.
|
||||
**Budget:** `zai/glm-4.7`.
|
||||
|
||||
MiniMax M2.1 has its own docs: [MiniMax](/providers/minimax) and
|
||||
[Local models](/gateway/local-models).
|
||||
|
||||
Strong warning: weaker/over-quantized models are more vulnerable to prompt
|
||||
injection and unsafe behavior. See [Security](/gateway/security).
|
||||
|
||||
More context: [Models](/concepts/models).
|
||||
|
||||
### What do Clawd, Flawd, and Krill use for models?
|
||||
|
||||
- **Clawd + Flawd:** Anthropic Opus (`anthropic/claude-opus-4-5`) — see [Anthropic](/providers/anthropic).
|
||||
- **Krill:** MiniMax M2.1 (`minimax/MiniMax-M2.1`) — see [MiniMax](/providers/minimax).
|
||||
|
||||
### How do I switch models on the fly (without restarting)?
|
||||
|
||||
Use the `/model` command as a standalone message:
|
||||
|
||||
@@ -33,6 +33,22 @@ Almost always a Node/npm PATH issue. Start here:
|
||||
|
||||
- [Install (Node/npm PATH sanity)](/install#nodejs--npm-path-sanity)
|
||||
|
||||
### Installer fails (or you need full logs)
|
||||
|
||||
Re-run the installer in verbose mode to see the full trace and npm output:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --verbose
|
||||
```
|
||||
|
||||
For beta installs:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --beta --verbose
|
||||
```
|
||||
|
||||
You can also set `CLAWDBOT_VERBOSE=1` instead of the flag.
|
||||
|
||||
### Gateway “unauthorized”, can’t connect, or keeps reconnecting
|
||||
|
||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||
|
||||
@@ -11,10 +11,13 @@ Last updated: 2026-01-21
|
||||
|
||||
Clawdbot ships three update channels:
|
||||
|
||||
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`). npm dist-tag: `latest`.
|
||||
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`). npm dist-tag: `beta`.
|
||||
- **stable**: npm dist-tag `latest`.
|
||||
- **beta**: npm dist-tag `beta` (builds under test).
|
||||
- **dev**: moving head of `main` (git). npm dist-tag: `dev` (when published).
|
||||
|
||||
We ship builds to **beta**, test them, then **promote a vetted build to `latest`**
|
||||
without changing the version number — dist-tags are the source of truth for npm installs.
|
||||
|
||||
## Switching channels
|
||||
|
||||
Git checkout:
|
||||
@@ -25,7 +28,7 @@ clawdbot update --channel beta
|
||||
clawdbot update --channel dev
|
||||
```
|
||||
|
||||
- `stable`/`beta` check out the latest matching tag.
|
||||
- `stable`/`beta` check out the latest matching tag (often the same tag).
|
||||
- `dev` switches to `main` and rebases on the upstream.
|
||||
|
||||
npm/pnpm global install:
|
||||
@@ -56,12 +59,11 @@ When you switch channels with `clawdbot update`, Clawdbot also syncs plugin sour
|
||||
|
||||
## Tagging best practices
|
||||
|
||||
- Stable: tag each release (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
|
||||
- Beta: use `vYYYY.M.D-beta.N` (increment `N`).
|
||||
- Tag releases you want git checkouts to land on (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
|
||||
- Keep tags immutable: never move or reuse a tag.
|
||||
- Publish dist-tags alongside git tags:
|
||||
- npm dist-tags remain the source of truth for npm installs:
|
||||
- `latest` → stable
|
||||
- `beta` → prerelease
|
||||
- `beta` → candidate build
|
||||
- `dev` → main snapshot (optional)
|
||||
|
||||
## macOS app availability
|
||||
|
||||
@@ -181,7 +181,7 @@ cat > /data/clawdbot.json << 'EOF'
|
||||
"bind": "auto"
|
||||
},
|
||||
"meta": {
|
||||
"lastTouchedVersion": "2026.1.23"
|
||||
"lastTouchedVersion": "2026.1.24"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -30,17 +30,17 @@ Notes:
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
BUNDLE_ID=com.clawdbot.mac \
|
||||
APP_VERSION=2026.1.23 \
|
||||
APP_VERSION=2026.1.24 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-app.sh
|
||||
|
||||
# Zip for distribution (includes resource forks for Sparkle delta support)
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.23.zip
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.24.zip
|
||||
|
||||
# Optional: also build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg
|
||||
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
@@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
|
||||
BUNDLE_ID=com.clawdbot.mac \
|
||||
APP_VERSION=2026.1.23 \
|
||||
APP_VERSION=2026.1.24 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Optional: ship dSYM alongside the release
|
||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.23.dSYM.zip
|
||||
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.24.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.23.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.24.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
|
||||
```
|
||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
||||
|
||||
## Publish & verify
|
||||
- Upload `Clawdbot-2026.1.23.zip` (and `Clawdbot-2026.1.23.dSYM.zip`) to the GitHub release for tag `v2026.1.23`.
|
||||
- Upload `Clawdbot-2026.1.24.zip` (and `Clawdbot-2026.1.24.dSYM.zip`) to the GitHub release for tag `v2026.1.24`.
|
||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
|
||||
- Sanity checks:
|
||||
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
|
||||
|
||||
@@ -40,6 +40,8 @@ Outbound sends were mirrored into the *current* agent session (tool session key)
|
||||
- Mattermost targets now strip `@` for DM session key routing.
|
||||
- Zalo Personal uses DM peer kind for 1:1 targets (group only when `group:` is present).
|
||||
- BlueBubbles group targets strip `chat_*` prefixes to match inbound session keys.
|
||||
- Slack auto-thread mirroring matches channel ids case-insensitively.
|
||||
- Gateway send lowercases provided session keys before mirroring.
|
||||
|
||||
## Decisions
|
||||
- **Gateway send session derivation**: if `sessionKey` is provided, use it. If omitted, derive a sessionKey from target + default agent and mirror there.
|
||||
|
||||
@@ -78,3 +78,30 @@ When the operator says “release”, immediately do this preflight (no extra qu
|
||||
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).
|
||||
- [ ] From a clean temp directory (no `package.json`), run `npx -y clawdbot@X.Y.Z send --help` to confirm install/CLI entrypoints work.
|
||||
- [ ] Announce/share release notes.
|
||||
|
||||
## Plugin publish scope (npm)
|
||||
|
||||
We only publish **existing npm plugins** under the `@clawdbot/*` scope. Bundled
|
||||
plugins that are not on npm stay **disk-tree only** (still shipped in
|
||||
`extensions/**`).
|
||||
|
||||
Process to derive the list:
|
||||
1) `npm search @clawdbot --json` and capture the package names.
|
||||
2) Compare with `extensions/*/package.json` names.
|
||||
3) Publish only the **intersection** (already on npm).
|
||||
|
||||
Current npm plugin list (update as needed):
|
||||
- @clawdbot/bluebubbles
|
||||
- @clawdbot/diagnostics-otel
|
||||
- @clawdbot/discord
|
||||
- @clawdbot/lobster
|
||||
- @clawdbot/matrix
|
||||
- @clawdbot/msteams
|
||||
- @clawdbot/nextcloud-talk
|
||||
- @clawdbot/nostr
|
||||
- @clawdbot/voice-call
|
||||
- @clawdbot/zalo
|
||||
- @clawdbot/zalouser
|
||||
|
||||
Release notes must also call out **new optional bundled plugins** that are **not
|
||||
on by default** (example: `tlon`).
|
||||
@@ -6,9 +6,10 @@ read_when:
|
||||
# Elevated Mode (/elevated directives)
|
||||
|
||||
## What it does
|
||||
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full` (approvals still apply).
|
||||
- `/elevated on` runs on the gateway host and keeps exec approvals (same as `/elevated ask`).
|
||||
- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals).
|
||||
- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`).
|
||||
- `on`/`ask` do **not** force `exec.security=full`; configured security/ask policy still applies.
|
||||
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
|
||||
- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`.
|
||||
- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state.
|
||||
@@ -18,8 +19,8 @@ read_when:
|
||||
- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key.
|
||||
- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only.
|
||||
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
|
||||
- **Host execution**: elevated forces `exec` onto the gateway host with full security.
|
||||
- **Approvals**: `full` skips exec approvals; `on`/`ask` still honor them.
|
||||
- **Host execution**: elevated forces `exec` onto the gateway host; `full` also sets `security=full`.
|
||||
- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require.
|
||||
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
|
||||
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
|
||||
|
||||
|
||||
@@ -157,6 +157,36 @@ Actions:
|
||||
- **Always allow** → add to allowlist + run
|
||||
- **Deny** → block
|
||||
|
||||
## Approval forwarding to chat channels
|
||||
|
||||
You can forward exec approval prompts to any chat channel (including plugin channels) and approve
|
||||
them with `/approve`. This uses the normal outbound delivery pipeline.
|
||||
|
||||
Config:
|
||||
```json5
|
||||
{
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "session", // "session" | "targets" | "both"
|
||||
agentFilter: ["main"],
|
||||
sessionFilter: ["discord"], // substring or regex
|
||||
targets: [
|
||||
{ channel: "slack", to: "U12345678" },
|
||||
{ channel: "telegram", to: "123456789" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reply in chat:
|
||||
```
|
||||
/approve <id> allow-once
|
||||
/approve <id> allow-always
|
||||
/approve <id> deny
|
||||
```
|
||||
|
||||
### macOS IPC flow
|
||||
```
|
||||
Gateway -> Node Service (WS)
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ Background sessions are scoped per agent; `process` only sees sessions from the
|
||||
- `security` (`deny | allowlist | full`): enforcement mode for `gateway`/`node`
|
||||
- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node`
|
||||
- `node` (string): node id/name for `host=node`
|
||||
- `elevated` (bool): alias for `host=gateway` + `security=full` when sandboxed and allowed
|
||||
- `elevated` (bool): request elevated mode (gateway host); `security=full` is only forced when elevated resolves to `full`
|
||||
|
||||
Notes:
|
||||
- `host` defaults to `sandbox`.
|
||||
|
||||
@@ -61,6 +61,7 @@ Text + native (when enabled):
|
||||
- `/skill <name> [input]` (run a skill by name)
|
||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
||||
- `/allowlist` (list/add/remove allowlist entries)
|
||||
- `/approve <id> allow-once|allow-always|deny` (resolve exec approval prompts)
|
||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
||||
|
||||
@@ -1189,9 +1189,8 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
||||
expect(callArgs.ctx.ReplyToBody).toBe("original message");
|
||||
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
||||
// Body uses just the ID (no sender) for token savings
|
||||
expect(callArgs.ctx.Body).toContain("[Replying to id:msg-0]");
|
||||
expect(callArgs.ctx.Body).toContain("original message");
|
||||
// Body uses inline [[reply_to:N]] tag format
|
||||
expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]");
|
||||
});
|
||||
|
||||
it("hydrates missing reply sender/body from the recent-message cache", async () => {
|
||||
@@ -1260,9 +1259,8 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
|
||||
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
|
||||
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
||||
// Body uses just the short ID (no sender) for token savings
|
||||
expect(callArgs.ctx.Body).toContain("[Replying to id:1]");
|
||||
expect(callArgs.ctx.Body).toContain("original message (cached)");
|
||||
// Body uses inline [[reply_to:N]] tag format with short ID
|
||||
expect(callArgs.ctx.Body).toContain("[[reply_to:1]]");
|
||||
});
|
||||
|
||||
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
|
||||
@@ -1305,6 +1303,88 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("tapback text parsing", () => {
|
||||
it("does not rewrite tapback-like text without metadata", async () => {
|
||||
const account = createMockAccount({ dmPolicy: "open" });
|
||||
const config: ClawdbotConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "Loved this idea",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
expect(callArgs.ctx.RawBody).toBe("Loved this idea");
|
||||
expect(callArgs.ctx.Body).toContain("Loved this idea");
|
||||
expect(callArgs.ctx.Body).not.toContain("reacted with");
|
||||
});
|
||||
|
||||
it("parses tapback text with custom emoji when metadata is present", async () => {
|
||||
const account = createMockAccount({ dmPolicy: "open" });
|
||||
const config: ClawdbotConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
const payload = {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: 'Reacted 😅 to "nice one"',
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-2",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
expect(callArgs.ctx.RawBody).toBe("reacted with 😅");
|
||||
expect(callArgs.ctx.Body).toContain("reacted with 😅");
|
||||
expect(callArgs.ctx.Body).not.toContain("[[reply_to:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ack reactions", () => {
|
||||
it("sends ack reaction when configured", async () => {
|
||||
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||
@@ -1759,7 +1839,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await flushAsync();
|
||||
|
||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining("reaction added"),
|
||||
expect.stringContaining("reacted with ❤️ [[reply_to:"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -1799,7 +1879,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await flushAsync();
|
||||
|
||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining("reaction removed"),
|
||||
expect.stringContaining("removed ❤️ reaction [[reply_to:"),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -1905,7 +1985,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-uuid-12345",
|
||||
guid: "p:1/msg-uuid-12345",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: Date.now(),
|
||||
},
|
||||
@@ -1921,7 +2001,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
// MessageSid should be short ID "1" instead of full UUID
|
||||
expect(callArgs.ctx.MessageSid).toBe("1");
|
||||
expect(callArgs.ctx.MessageSidFull).toBe("msg-uuid-12345");
|
||||
expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345");
|
||||
});
|
||||
|
||||
it("resolves short ID back to UUID", async () => {
|
||||
@@ -1945,7 +2025,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-uuid-12345",
|
||||
guid: "p:1/msg-uuid-12345",
|
||||
chatGuid: "iMessage;-;+15551234567",
|
||||
date: Date.now(),
|
||||
},
|
||||
@@ -1958,7 +2038,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await flushAsync();
|
||||
|
||||
// The short ID "1" should resolve back to the full UUID
|
||||
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
|
||||
expect(resolveBlueBubblesMessageId("1")).toBe("p:1/msg-uuid-12345");
|
||||
});
|
||||
|
||||
it("returns UUID unchanged when not in cache", () => {
|
||||
|
||||
@@ -55,7 +55,7 @@ type BlueBubblesReplyCacheEntry = {
|
||||
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
|
||||
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
|
||||
|
||||
// Bidirectional maps for short ID ↔ UUID resolution (token savings optimization)
|
||||
// Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
|
||||
const blueBubblesShortIdToUuid = new Map<string, string>();
|
||||
const blueBubblesUuidToShortId = new Map<string, string>();
|
||||
let blueBubblesShortIdCounter = 0;
|
||||
@@ -78,7 +78,7 @@ function rememberBlueBubblesReplyCache(
|
||||
return { ...entry, shortId: "" };
|
||||
}
|
||||
|
||||
// Check if we already have a short ID for this UUID
|
||||
// Check if we already have a short ID for this GUID
|
||||
let shortId = blueBubblesUuidToShortId.get(messageId);
|
||||
if (!shortId) {
|
||||
shortId = generateShortId();
|
||||
@@ -86,7 +86,7 @@ function rememberBlueBubblesReplyCache(
|
||||
blueBubblesUuidToShortId.set(messageId, shortId);
|
||||
}
|
||||
|
||||
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, shortId };
|
||||
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
|
||||
|
||||
// Refresh insertion order.
|
||||
blueBubblesReplyCacheByMessageId.delete(messageId);
|
||||
@@ -122,8 +122,8 @@ function rememberBlueBubblesReplyCache(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles UUID.
|
||||
* Returns the input unchanged if it's already a UUID or not found in the mapping.
|
||||
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID.
|
||||
* Returns the input unchanged if it's already a GUID or not found in the mapping.
|
||||
*/
|
||||
export function resolveBlueBubblesMessageId(
|
||||
shortOrUuid: string,
|
||||
@@ -159,7 +159,7 @@ export function _resetBlueBubblesShortIdState(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the short ID for a UUID, if one exists.
|
||||
* Gets the short ID for a message GUID, if one exists.
|
||||
*/
|
||||
function getShortIdForUuid(uuid: string): string | undefined {
|
||||
return blueBubblesUuidToShortId.get(uuid.trim());
|
||||
@@ -390,29 +390,15 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
const REPLY_BODY_TRUNCATE_LENGTH = 60;
|
||||
|
||||
function formatReplyContext(message: {
|
||||
// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
|
||||
function formatReplyTag(message: {
|
||||
replyToId?: string;
|
||||
replyToShortId?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
}): string | null {
|
||||
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
|
||||
// Prefer short ID for token savings
|
||||
const displayId = message.replyToShortId || message.replyToId;
|
||||
// Only include sender if we don't have an ID (fallback)
|
||||
const label = displayId ? `id:${displayId}` : (message.replyToSender?.trim() || "unknown");
|
||||
const rawBody = message.replyToBody?.trim();
|
||||
if (!rawBody) {
|
||||
return `[Replying to ${label}]\n[/Replying]`;
|
||||
}
|
||||
// Truncate long reply bodies for token savings
|
||||
const body =
|
||||
rawBody.length > REPLY_BODY_TRUNCATE_LENGTH
|
||||
? `${rawBody.slice(0, REPLY_BODY_TRUNCATE_LENGTH)}…`
|
||||
: rawBody;
|
||||
return `[Replying to ${label}]\n${body}\n[/Replying]`;
|
||||
// Prefer short ID
|
||||
const rawId = message.replyToShortId || message.replyToId;
|
||||
if (!rawId) return null;
|
||||
return `[[reply_to:${rawId}]]`;
|
||||
}
|
||||
|
||||
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
||||
@@ -629,6 +615,10 @@ type NormalizedWebhookMessage = {
|
||||
fromMe?: boolean;
|
||||
attachments?: BlueBubblesAttachment[];
|
||||
balloonBundleId?: string;
|
||||
associatedMessageGuid?: string;
|
||||
associatedMessageType?: number;
|
||||
associatedMessageEmoji?: string;
|
||||
isTapback?: boolean;
|
||||
participants?: BlueBubblesParticipant[];
|
||||
replyToId?: string;
|
||||
replyToBody?: string;
|
||||
@@ -665,6 +655,113 @@ const REACTION_TYPE_MAP = new Map<number, { emoji: string; action: "added" | "re
|
||||
[3005, { emoji: "❓", action: "removed" }],
|
||||
]);
|
||||
|
||||
// Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action
|
||||
const TAPBACK_TEXT_MAP = new Map<string, { emoji: string; action: "added" | "removed" }>([
|
||||
["loved", { emoji: "❤️", action: "added" }],
|
||||
["liked", { emoji: "👍", action: "added" }],
|
||||
["disliked", { emoji: "👎", action: "added" }],
|
||||
["laughed at", { emoji: "😂", action: "added" }],
|
||||
["emphasized", { emoji: "‼️", action: "added" }],
|
||||
["questioned", { emoji: "❓", action: "added" }],
|
||||
// Removal patterns (e.g., "Removed a heart from")
|
||||
["removed a heart from", { emoji: "❤️", action: "removed" }],
|
||||
["removed a like from", { emoji: "👍", action: "removed" }],
|
||||
["removed a dislike from", { emoji: "👎", action: "removed" }],
|
||||
["removed a laugh from", { emoji: "😂", action: "removed" }],
|
||||
["removed an emphasis from", { emoji: "‼️", action: "removed" }],
|
||||
["removed a question from", { emoji: "❓", action: "removed" }],
|
||||
]);
|
||||
|
||||
const TAPBACK_EMOJI_REGEX =
|
||||
/(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
|
||||
|
||||
function extractFirstEmoji(text: string): string | null {
|
||||
const match = text.match(TAPBACK_EMOJI_REGEX);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
function extractQuotedTapbackText(text: string): string | null {
|
||||
const match = text.match(/[“"]([^”"]+)[”"]/s);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function isTapbackAssociatedType(type: number | undefined): boolean {
|
||||
return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000;
|
||||
}
|
||||
|
||||
function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
|
||||
if (typeof type !== "number" || !Number.isFinite(type)) return undefined;
|
||||
if (type >= 3000 && type < 4000) return "removed";
|
||||
if (type >= 2000 && type < 3000) return "added";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveTapbackContext(message: NormalizedWebhookMessage): {
|
||||
emojiHint?: string;
|
||||
actionHint?: "added" | "removed";
|
||||
replyToId?: string;
|
||||
} | null {
|
||||
const associatedType = message.associatedMessageType;
|
||||
const hasTapbackType = isTapbackAssociatedType(associatedType);
|
||||
const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
|
||||
if (!hasTapbackType && !hasTapbackMarker) return null;
|
||||
const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
|
||||
const actionHint = resolveTapbackActionHint(associatedType);
|
||||
const emojiHint =
|
||||
message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji;
|
||||
return { emojiHint, actionHint, replyToId };
|
||||
}
|
||||
|
||||
// Detects tapback text patterns like 'Loved "message"' and converts to structured format
|
||||
function parseTapbackText(params: {
|
||||
text: string;
|
||||
emojiHint?: string;
|
||||
actionHint?: "added" | "removed";
|
||||
requireQuoted?: boolean;
|
||||
}): {
|
||||
emoji: string;
|
||||
action: "added" | "removed";
|
||||
quotedText: string;
|
||||
} | null {
|
||||
const trimmed = params.text.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (!trimmed) return null;
|
||||
|
||||
for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
|
||||
if (lower.startsWith(pattern)) {
|
||||
// Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
|
||||
const afterPattern = trimmed.slice(pattern.length).trim();
|
||||
if (params.requireQuoted) {
|
||||
const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
|
||||
if (!strictMatch) return null;
|
||||
return { emoji, action, quotedText: strictMatch[1] };
|
||||
}
|
||||
const quotedText =
|
||||
extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern;
|
||||
return { emoji, action, quotedText };
|
||||
}
|
||||
}
|
||||
|
||||
if (lower.startsWith("reacted")) {
|
||||
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
||||
if (!emoji) return null;
|
||||
const quotedText = extractQuotedTapbackText(trimmed);
|
||||
if (params.requireQuoted && !quotedText) return null;
|
||||
const fallback = trimmed.slice("reacted".length).trim();
|
||||
return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
|
||||
}
|
||||
|
||||
if (lower.startsWith("removed")) {
|
||||
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
||||
if (!emoji) return null;
|
||||
const quotedText = extractQuotedTapbackText(trimmed);
|
||||
if (params.requireQuoted && !quotedText) return null;
|
||||
const fallback = trimmed.slice("removed".length).trim();
|
||||
return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function maskSecret(value: string): string {
|
||||
if (value.length <= 6) return "***";
|
||||
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
||||
@@ -805,6 +902,25 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
||||
readString(message, "messageId") ??
|
||||
undefined;
|
||||
const balloonBundleId = readString(message, "balloonBundleId");
|
||||
const associatedMessageGuid =
|
||||
readString(message, "associatedMessageGuid") ??
|
||||
readString(message, "associated_message_guid") ??
|
||||
readString(message, "associatedMessageId") ??
|
||||
undefined;
|
||||
const associatedMessageType =
|
||||
readNumberLike(message, "associatedMessageType") ??
|
||||
readNumberLike(message, "associated_message_type");
|
||||
const associatedMessageEmoji =
|
||||
readString(message, "associatedMessageEmoji") ??
|
||||
readString(message, "associated_message_emoji") ??
|
||||
readString(message, "reactionEmoji") ??
|
||||
readString(message, "reaction_emoji") ??
|
||||
undefined;
|
||||
const isTapback =
|
||||
readBoolean(message, "isTapback") ??
|
||||
readBoolean(message, "is_tapback") ??
|
||||
readBoolean(message, "tapback") ??
|
||||
undefined;
|
||||
|
||||
const timestampRaw =
|
||||
readNumber(message, "date") ??
|
||||
@@ -835,6 +951,10 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
||||
fromMe,
|
||||
attachments: extractAttachments(message),
|
||||
balloonBundleId,
|
||||
associatedMessageGuid,
|
||||
associatedMessageType,
|
||||
associatedMessageEmoji,
|
||||
isTapback,
|
||||
participants: normalizedParticipants,
|
||||
replyToId: replyMetadata.replyToId,
|
||||
replyToBody: replyMetadata.replyToBody,
|
||||
@@ -856,8 +976,13 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
|
||||
if (!associatedGuid || associatedType === undefined) return null;
|
||||
|
||||
const mapping = REACTION_TYPE_MAP.get(associatedType);
|
||||
const emoji = mapping?.emoji ?? `reaction:${associatedType}`;
|
||||
const action = mapping?.action ?? "added";
|
||||
const associatedEmoji =
|
||||
readString(message, "associatedMessageEmoji") ??
|
||||
readString(message, "associated_message_emoji") ??
|
||||
readString(message, "reactionEmoji") ??
|
||||
readString(message, "reaction_emoji");
|
||||
const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
|
||||
const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
|
||||
|
||||
const handleValue = message.handle ?? message.sender;
|
||||
const handle =
|
||||
@@ -1122,7 +1247,21 @@ async function processMessage(
|
||||
const text = message.text.trim();
|
||||
const attachments = message.attachments ?? [];
|
||||
const placeholder = buildMessagePlaceholder(message);
|
||||
const rawBody = text || placeholder;
|
||||
// Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
|
||||
// For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
|
||||
const tapbackContext = resolveTapbackContext(message);
|
||||
const tapbackParsed = parseTapbackText({
|
||||
text,
|
||||
emojiHint: tapbackContext?.emojiHint,
|
||||
actionHint: tapbackContext?.actionHint,
|
||||
requireQuoted: !tapbackContext,
|
||||
});
|
||||
const isTapbackMessage = Boolean(tapbackParsed);
|
||||
const rawBody = tapbackParsed
|
||||
? tapbackParsed.action === "removed"
|
||||
? `removed ${tapbackParsed.emoji} reaction`
|
||||
: `reacted with ${tapbackParsed.emoji}`
|
||||
: text || placeholder;
|
||||
|
||||
const cacheMessageId = message.messageId?.trim();
|
||||
let messageShortId: string | undefined;
|
||||
@@ -1449,7 +1588,11 @@ async function processMessage(
|
||||
let replyToSender = message.replyToSender;
|
||||
let replyToShortId: string | undefined;
|
||||
|
||||
if (replyToId && (!replyToBody || !replyToSender)) {
|
||||
if (isTapbackMessage && tapbackContext?.replyToId) {
|
||||
replyToId = tapbackContext.replyToId;
|
||||
}
|
||||
|
||||
if (replyToId) {
|
||||
const cached = resolveReplyContextFromCache({
|
||||
accountId: account.accountId,
|
||||
replyToId,
|
||||
@@ -1477,8 +1620,15 @@ async function processMessage(
|
||||
replyToShortId = getShortIdForUuid(replyToId);
|
||||
}
|
||||
|
||||
const replyContext = formatReplyContext({ replyToId, replyToShortId, replyToBody, replyToSender });
|
||||
const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
|
||||
// Use inline [[reply_to:N]] tag format
|
||||
// For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
|
||||
// For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome")
|
||||
const replyTag = formatReplyTag({ replyToId, replyToShortId });
|
||||
const baseBody = replyTag
|
||||
? isTapbackMessage
|
||||
? `${rawBody} ${replyTag}`
|
||||
: `${replyTag} ${rawBody}`
|
||||
: rawBody;
|
||||
const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
|
||||
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
||||
const groupMembers = isGroup
|
||||
@@ -1871,7 +2021,11 @@ async function processReaction(
|
||||
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
||||
// Use short ID for token savings
|
||||
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
|
||||
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${messageDisplayId}`;
|
||||
// Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
|
||||
const text =
|
||||
reaction.action === "removed"
|
||||
? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}`
|
||||
: `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`;
|
||||
core.system.enqueueSystemEvent(text, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"clawdbot": "workspace:*"
|
||||
"peerDependencies": {
|
||||
"clawdbot": ">=2026.1.23-1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { TlonAuthorizationSchema, TlonConfigSchema } from "./config-schema.js";
|
||||
|
||||
describe("Tlon config schema", () => {
|
||||
it("accepts channelRules with string keys", () => {
|
||||
const parsed = TlonAuthorizationSchema.parse({
|
||||
channelRules: {
|
||||
"chat/~zod/test": {
|
||||
mode: "open",
|
||||
allowedShips: ["~zod"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.channelRules?.["chat/~zod/test"]?.mode).toBe("open");
|
||||
});
|
||||
|
||||
it("accepts accounts with string keys", () => {
|
||||
const parsed = TlonConfigSchema.parse({
|
||||
accounts: {
|
||||
primary: {
|
||||
ship: "~zod",
|
||||
url: "https://example.com",
|
||||
code: "code-123",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.accounts?.primary?.ship).toBe("~zod");
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,7 @@ export const TlonChannelRuleSchema = z.object({
|
||||
});
|
||||
|
||||
export const TlonAuthorizationSchema = z.object({
|
||||
channelRules: z.record(TlonChannelRuleSchema).optional(),
|
||||
channelRules: z.record(z.string(), TlonChannelRuleSchema).optional(),
|
||||
});
|
||||
|
||||
export const TlonAccountSchema = z.object({
|
||||
@@ -37,7 +37,7 @@ export const TlonConfigSchema = z.object({
|
||||
showModelSignature: z.boolean().optional(),
|
||||
authorization: TlonAuthorizationSchema.optional(),
|
||||
defaultAuthorizedShips: z.array(ShipSchema).optional(),
|
||||
accounts: z.record(TlonAccountSchema).optional(),
|
||||
accounts: z.record(z.string(), TlonAccountSchema).optional(),
|
||||
});
|
||||
|
||||
export const tlonChannelConfigSchema = buildChannelConfigSchema(TlonConfigSchema);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@urbit/aura", () => ({
|
||||
scot: vi.fn(() => "mocked-ud"),
|
||||
da: {
|
||||
fromUnix: vi.fn(() => 123n),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("sendDm", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("uses aura v3 helpers for the DM id", async () => {
|
||||
const { sendDm } = await import("./send.js");
|
||||
const aura = await import("@urbit/aura");
|
||||
const scot = vi.mocked(aura.scot);
|
||||
const fromUnix = vi.mocked(aura.da.fromUnix);
|
||||
|
||||
const sentAt = 1_700_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(sentAt);
|
||||
|
||||
const poke = vi.fn(async () => ({}));
|
||||
|
||||
const result = await sendDm({
|
||||
api: { poke },
|
||||
fromShip: "~zod",
|
||||
toShip: "~nec",
|
||||
text: "hi",
|
||||
});
|
||||
|
||||
expect(fromUnix).toHaveBeenCalledWith(sentAt);
|
||||
expect(scot).toHaveBeenCalledWith("ud", 123n);
|
||||
expect(poke).toHaveBeenCalledTimes(1);
|
||||
expect(result.messageId).toBe("~zod/mocked-ud");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { unixToDa, formatUd } from "@urbit/aura";
|
||||
import { scot, da } from "@urbit/aura";
|
||||
|
||||
export type TlonPokeApi = {
|
||||
poke: (params: { app: string; mark: string; json: unknown }) => Promise<unknown>;
|
||||
@@ -14,7 +14,7 @@ type SendTextParams = {
|
||||
export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) {
|
||||
const story = [{ inline: [text] }];
|
||||
const sentAt = Date.now();
|
||||
const idUd = formatUd(unixToDa(sentAt));
|
||||
const idUd = scot('ud', da.fromUnix(sentAt));
|
||||
const id = `${fromShip}/${idUd}`;
|
||||
|
||||
const delta = {
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawdbot",
|
||||
"version": "2026.1.23",
|
||||
"version": "2026.1.24-0",
|
||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -43,6 +43,7 @@
|
||||
"dist/slack/**",
|
||||
"dist/telegram/**",
|
||||
"dist/tui/**",
|
||||
"dist/tts/**",
|
||||
"dist/web/**",
|
||||
"dist/wizard/**",
|
||||
"dist/*.js",
|
||||
|
||||
Generated
+83
-2
@@ -335,8 +335,8 @@ importers:
|
||||
extensions/memory-core:
|
||||
dependencies:
|
||||
clawdbot:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
specifier: '>=2026.1.23-1'
|
||||
version: 2026.1.23(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3)
|
||||
|
||||
extensions/memory-lancedb:
|
||||
dependencies:
|
||||
@@ -2987,6 +2987,11 @@ packages:
|
||||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
clawdbot@2026.1.23:
|
||||
resolution: {integrity: sha512-w8RjScbxj3YbJYtcB0GBITqmyUYegVbBXDgu/zRxB4AB/SEErR6BNWGeZDWQNFaMftIyJhjttACxSd8G20aREA==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -8368,6 +8373,82 @@ snapshots:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
clawdbot@2026.1.23(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@agentclientprotocol/sdk': 0.13.1(zod@4.3.6)
|
||||
'@aws-sdk/client-bedrock': 3.975.0
|
||||
'@buape/carbon': 0.14.0(hono@4.11.4)
|
||||
'@clack/prompts': 0.11.0
|
||||
'@grammyjs/runner': 2.0.3(grammy@1.39.3)
|
||||
'@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3)
|
||||
'@homebridge/ciao': 1.3.4
|
||||
'@lydell/node-pty': 1.2.0-beta.3
|
||||
'@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.49.3
|
||||
'@mozilla/readability': 0.6.0
|
||||
'@sinclair/typebox': 0.34.47
|
||||
'@slack/bolt': 4.6.0(@types/express@5.0.6)
|
||||
'@slack/web-api': 7.13.0
|
||||
'@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5)
|
||||
ajv: 8.17.1
|
||||
body-parser: 2.2.2
|
||||
chalk: 5.6.2
|
||||
chokidar: 5.0.0
|
||||
chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482)
|
||||
cli-highlight: 2.1.11
|
||||
commander: 14.0.2
|
||||
croner: 9.1.0
|
||||
detect-libc: 2.1.2
|
||||
discord-api-types: 0.38.37
|
||||
dotenv: 17.2.3
|
||||
express: 5.2.1
|
||||
file-type: 21.3.0
|
||||
grammy: 1.39.3
|
||||
hono: 4.11.4
|
||||
jiti: 2.6.1
|
||||
json5: 2.2.3
|
||||
jszip: 3.10.1
|
||||
linkedom: 0.18.12
|
||||
long: 5.3.2
|
||||
markdown-it: 14.1.0
|
||||
osc-progress: 0.3.0
|
||||
pdfjs-dist: 5.4.530
|
||||
playwright-core: 1.58.0
|
||||
proper-lockfile: 4.1.2
|
||||
qrcode-terminal: 0.12.0
|
||||
sharp: 0.34.5
|
||||
sqlite-vec: 0.1.7-alpha.2
|
||||
tar: 7.5.4
|
||||
tslog: 4.10.2
|
||||
undici: 7.19.0
|
||||
ws: 8.19.0
|
||||
yaml: 2.8.2
|
||||
zod: 4.3.6
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.88
|
||||
node-llama-cpp: 3.15.0(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@discordjs/opus'
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- '@types/express'
|
||||
- audio-decode
|
||||
- aws-crt
|
||||
- bufferutil
|
||||
- canvas
|
||||
- debug
|
||||
- devtools-protocol
|
||||
- encoding
|
||||
- ffmpeg-static
|
||||
- jimp
|
||||
- link-preview-js
|
||||
- node-opus
|
||||
- opusscript
|
||||
- supports-color
|
||||
- typescript
|
||||
- utf-8-validate
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
dependencies:
|
||||
restore-cursor: 5.1.0
|
||||
|
||||
@@ -13,9 +13,11 @@ COPY scripts ./scripts
|
||||
COPY docs ./docs
|
||||
COPY skills ./skills
|
||||
COPY patches ./patches
|
||||
COPY ui ./ui
|
||||
COPY extensions/memory-core ./extensions/memory-core
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm build
|
||||
RUN pnpm ui:build
|
||||
|
||||
CMD ["bash"]
|
||||
@@ -150,4 +150,35 @@ describe("exec approvals", () => {
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect(calls).not.toContain("exec.approval.request");
|
||||
});
|
||||
|
||||
it("requires approval for elevated ask when allowlist misses", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const calls: string[] = [];
|
||||
let resolveApproval: (() => void) | undefined;
|
||||
const approvalSeen = new Promise<void>((resolve) => {
|
||||
resolveApproval = resolve;
|
||||
});
|
||||
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
||||
calls.push(method);
|
||||
if (method === "exec.approval.request") {
|
||||
resolveApproval?.();
|
||||
return { decision: "deny" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const { createExecTool } = await import("./bash-tools.exec.js");
|
||||
const tool = createExecTool({
|
||||
ask: "on-miss",
|
||||
security: "allowlist",
|
||||
approvalRunningNoticeMs: 0,
|
||||
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
|
||||
});
|
||||
|
||||
const result = await tool.execute("call4", { command: "echo ok", elevated: true });
|
||||
expect(result.details.status).toBe("approval-pending");
|
||||
await approvalSeen;
|
||||
expect(calls).toContain("exec.approval.request");
|
||||
});
|
||||
});
|
||||
@@ -791,7 +791,7 @@ export function createExecTool(
|
||||
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
|
||||
const requestedSecurity = normalizeExecSecurity(params.security);
|
||||
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
|
||||
if (elevatedRequested) {
|
||||
if (elevatedRequested && elevatedMode === "full") {
|
||||
security = "full";
|
||||
}
|
||||
const configuredAsk = defaults?.ask ?? "on-miss";
|
||||
|
||||
@@ -69,6 +69,9 @@ export function isModernModelRef(ref: ModelRef): boolean {
|
||||
if (provider === "opencode" && id.endsWith("-free")) {
|
||||
return false;
|
||||
}
|
||||
if (provider === "opencode" && id === "alpha-glm-4.7") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (provider === "openrouter" || provider === "opencode") {
|
||||
return matchesAny(id, [
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("getOpencodeZenStaticFallbackModels", () => {
|
||||
it("returns an array of models", () => {
|
||||
const models = getOpencodeZenStaticFallbackModels();
|
||||
expect(Array.isArray(models)).toBe(true);
|
||||
expect(models.length).toBe(10);
|
||||
expect(models.length).toBe(9);
|
||||
});
|
||||
|
||||
it("includes Claude, GPT, Gemini, and GLM models", () => {
|
||||
|
||||
@@ -67,10 +67,9 @@ export const OPENCODE_ZEN_MODEL_ALIASES: Record<string, string> = {
|
||||
"gemini-2.5-pro": "gemini-3-pro",
|
||||
"gemini-2.5-flash": "gemini-3-flash",
|
||||
|
||||
// GLM (free + alpha)
|
||||
// GLM (free)
|
||||
glm: "glm-4.7",
|
||||
"glm-free": "glm-4.7",
|
||||
"alpha-glm": "alpha-glm-4.7",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -122,7 +121,6 @@ const MODEL_COSTS: Record<
|
||||
},
|
||||
"claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
||||
"gemini-3-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 },
|
||||
"alpha-glm-4.7": { input: 0.6, output: 2.2, cacheRead: 0.6, cacheWrite: 0 },
|
||||
"gpt-5.1-codex-mini": {
|
||||
input: 0.25,
|
||||
output: 2,
|
||||
@@ -147,7 +145,6 @@ const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
|
||||
"gpt-5.1-codex": 400000,
|
||||
"claude-opus-4-5": 200000,
|
||||
"gemini-3-pro": 1048576,
|
||||
"alpha-glm-4.7": 204800,
|
||||
"gpt-5.1-codex-mini": 400000,
|
||||
"gpt-5.1": 400000,
|
||||
"glm-4.7": 204800,
|
||||
@@ -164,7 +161,6 @@ const MODEL_MAX_TOKENS: Record<string, number> = {
|
||||
"gpt-5.1-codex": 128000,
|
||||
"claude-opus-4-5": 64000,
|
||||
"gemini-3-pro": 65536,
|
||||
"alpha-glm-4.7": 131072,
|
||||
"gpt-5.1-codex-mini": 128000,
|
||||
"gpt-5.1": 128000,
|
||||
"glm-4.7": 131072,
|
||||
@@ -201,7 +197,6 @@ const MODEL_NAMES: Record<string, string> = {
|
||||
"gpt-5.1-codex": "GPT-5.1 Codex",
|
||||
"claude-opus-4-5": "Claude Opus 4.5",
|
||||
"gemini-3-pro": "Gemini 3 Pro",
|
||||
"alpha-glm-4.7": "Alpha GLM-4.7",
|
||||
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
|
||||
"gpt-5.1": "GPT-5.1",
|
||||
"glm-4.7": "GLM-4.7",
|
||||
@@ -229,7 +224,6 @@ export function getOpencodeZenStaticFallbackModels(): ModelDefinitionConfig[] {
|
||||
"gpt-5.1-codex",
|
||||
"claude-opus-4-5",
|
||||
"gemini-3-pro",
|
||||
"alpha-glm-4.7",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.1",
|
||||
"glm-4.7",
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAssistantErrorText } from "./pi-embedded-helpers.js";
|
||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
||||
|
||||
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
path: "/tmp/AGENTS.md",
|
||||
content: "",
|
||||
missing: false,
|
||||
...overrides,
|
||||
});
|
||||
describe("formatAssistantErrorText", () => {
|
||||
const makeAssistantError = (errorMessage: string): AssistantMessage =>
|
||||
({
|
||||
@@ -21,6 +13,16 @@ describe("formatAssistantErrorText", () => {
|
||||
const msg = makeAssistantError("request_too_large");
|
||||
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
|
||||
});
|
||||
it("returns context overflow for Anthropic 'Request size exceeds model context window'", () => {
|
||||
// This is the new Anthropic error format that wasn't being detected.
|
||||
// Without the fix, this falls through to the invalidRequest regex and returns
|
||||
// "LLM request rejected: Request size exceeds model context window"
|
||||
// instead of the context overflow message, preventing auto-compaction.
|
||||
const msg = makeAssistantError(
|
||||
'{"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}',
|
||||
);
|
||||
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
|
||||
});
|
||||
it("returns a friendly message for Anthropic role ordering", () => {
|
||||
const msg = makeAssistantError('messages: roles must alternate between "user" and "assistant"');
|
||||
expect(formatAssistantErrorText(msg)).toContain("Message ordering conflict");
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isContextOverflowError } from "./pi-embedded-helpers.js";
|
||||
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
|
||||
|
||||
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
|
||||
name: DEFAULT_AGENTS_FILENAME,
|
||||
path: "/tmp/AGENTS.md",
|
||||
content: "",
|
||||
missing: false,
|
||||
...overrides,
|
||||
});
|
||||
describe("isContextOverflowError", () => {
|
||||
it("matches known overflow hints", () => {
|
||||
const samples = [
|
||||
@@ -24,7 +16,34 @@ describe("isContextOverflowError", () => {
|
||||
expect(isContextOverflowError(sample)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("matches Anthropic 'Request size exceeds model context window' error", () => {
|
||||
// Anthropic returns this error format when the prompt exceeds the context window.
|
||||
// Without this fix, auto-compaction is NOT triggered because neither
|
||||
// isContextOverflowError nor pi-ai's isContextOverflow recognizes this pattern.
|
||||
// The user sees: "LLM request rejected: Request size exceeds model context window"
|
||||
// instead of automatic compaction + retry.
|
||||
const anthropicRawError =
|
||||
'{"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}';
|
||||
expect(isContextOverflowError(anthropicRawError)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches 'exceeds model context window' in various formats", () => {
|
||||
const samples = [
|
||||
"Request size exceeds model context window",
|
||||
"request size exceeds model context window",
|
||||
'400 {"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}',
|
||||
"The request size exceeds model context window limit",
|
||||
];
|
||||
for (const sample of samples) {
|
||||
expect(isContextOverflowError(sample)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores unrelated errors", () => {
|
||||
expect(isContextOverflowError("rate limit exceeded")).toBe(false);
|
||||
expect(isContextOverflowError("request size exceeds upload limit")).toBe(false);
|
||||
expect(isContextOverflowError("model not found")).toBe(false);
|
||||
expect(isContextOverflowError("authentication failed")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,19 @@ import type { FailoverReason } from "./types.js";
|
||||
export function isContextOverflowError(errorMessage?: string): boolean {
|
||||
if (!errorMessage) return false;
|
||||
const lower = errorMessage.toLowerCase();
|
||||
const hasRequestSizeExceeds = lower.includes("request size exceeds");
|
||||
const hasContextWindow =
|
||||
lower.includes("context window") ||
|
||||
lower.includes("context length") ||
|
||||
lower.includes("maximum context length");
|
||||
return (
|
||||
lower.includes("request_too_large") ||
|
||||
lower.includes("request exceeds the maximum size") ||
|
||||
lower.includes("context length exceeded") ||
|
||||
lower.includes("maximum context length") ||
|
||||
lower.includes("prompt is too long") ||
|
||||
lower.includes("exceeds model context window") ||
|
||||
(hasRequestSizeExceeds && hasContextWindow) ||
|
||||
lower.includes("context overflow") ||
|
||||
(lower.includes("413") && lower.includes("too large"))
|
||||
);
|
||||
|
||||
@@ -68,12 +68,13 @@ import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../d
|
||||
import { describeUnknownError, mapThinkingLevel, resolveExecToolDefaults } from "./utils.js";
|
||||
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
|
||||
|
||||
export async function compactEmbeddedPiSession(params: {
|
||||
export type CompactEmbeddedPiSessionParams = {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
messageChannel?: string;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
authProfileId?: string;
|
||||
/** Group id for channel-level tool policy resolution. */
|
||||
groupId?: string | null;
|
||||
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
|
||||
@@ -97,354 +98,366 @@ export async function compactEmbeddedPiSession(params: {
|
||||
enqueue?: typeof enqueueCommand;
|
||||
extraSystemPrompt?: string;
|
||||
ownerNumbers?: string[];
|
||||
}): Promise<EmbeddedPiCompactResult> {
|
||||
};
|
||||
|
||||
/**
|
||||
* Core compaction logic without lane queueing.
|
||||
* Use this when already inside a session/global lane to avoid deadlocks.
|
||||
*/
|
||||
export async function compactEmbeddedPiSessionDirect(
|
||||
params: CompactEmbeddedPiSessionParams,
|
||||
): Promise<EmbeddedPiCompactResult> {
|
||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||
const prevCwd = process.cwd();
|
||||
|
||||
const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
||||
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
||||
await ensureClawdbotModelsJson(params.config, agentDir);
|
||||
const { model, error, authStorage, modelRegistry } = resolveModel(
|
||||
provider,
|
||||
modelId,
|
||||
agentDir,
|
||||
params.config,
|
||||
);
|
||||
if (!model) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: error ?? `Unknown model: ${provider}/${modelId}`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const apiKeyInfo = await getApiKeyForModel({
|
||||
model,
|
||||
cfg: params.config,
|
||||
profileId: params.authProfileId,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
if (!apiKeyInfo.apiKey) {
|
||||
if (apiKeyInfo.mode !== "aws-sdk") {
|
||||
throw new Error(
|
||||
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
||||
);
|
||||
}
|
||||
} else if (model.provider === "github-copilot") {
|
||||
const { resolveCopilotApiToken } = await import("../../providers/github-copilot-token.js");
|
||||
const copilotToken = await resolveCopilotApiToken({
|
||||
githubToken: apiKeyInfo.apiKey,
|
||||
});
|
||||
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
|
||||
} else {
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: describeUnknownError(err),
|
||||
};
|
||||
}
|
||||
|
||||
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
||||
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
|
||||
const sandbox = await resolveSandboxContext({
|
||||
config: params.config,
|
||||
sessionKey: sandboxSessionKey,
|
||||
workspaceDir: resolvedWorkspace,
|
||||
});
|
||||
const effectiveWorkspace = sandbox?.enabled
|
||||
? sandbox.workspaceAccess === "rw"
|
||||
? resolvedWorkspace
|
||||
: sandbox.workspaceDir
|
||||
: resolvedWorkspace;
|
||||
await fs.mkdir(effectiveWorkspace, { recursive: true });
|
||||
await ensureSessionHeader({
|
||||
sessionFile: params.sessionFile,
|
||||
sessionId: params.sessionId,
|
||||
cwd: effectiveWorkspace,
|
||||
});
|
||||
|
||||
let restoreSkillEnv: (() => void) | undefined;
|
||||
process.chdir(effectiveWorkspace);
|
||||
try {
|
||||
const shouldLoadSkillEntries = !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
|
||||
const skillEntries = shouldLoadSkillEntries
|
||||
? loadWorkspaceSkillEntries(effectiveWorkspace)
|
||||
: [];
|
||||
restoreSkillEnv = params.skillsSnapshot
|
||||
? applySkillEnvOverridesFromSnapshot({
|
||||
snapshot: params.skillsSnapshot,
|
||||
config: params.config,
|
||||
})
|
||||
: applySkillEnvOverrides({
|
||||
skills: skillEntries ?? [],
|
||||
config: params.config,
|
||||
});
|
||||
const skillsPrompt = resolveSkillsPromptForRun({
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
entries: shouldLoadSkillEntries ? skillEntries : undefined,
|
||||
config: params.config,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
});
|
||||
|
||||
const sessionLabel = params.sessionKey ?? params.sessionId;
|
||||
const { contextFiles } = await resolveBootstrapContextForRun({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
|
||||
});
|
||||
const runAbortController = new AbortController();
|
||||
const toolsRaw = createClawdbotCodingTools({
|
||||
exec: {
|
||||
...resolveExecToolDefaults(params.config),
|
||||
elevated: params.bashElevated,
|
||||
},
|
||||
sandbox,
|
||||
messageProvider: params.messageChannel ?? params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
agentDir,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
config: params.config,
|
||||
abortSignal: runAbortController.signal,
|
||||
modelProvider: model.provider,
|
||||
modelId,
|
||||
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
||||
});
|
||||
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider });
|
||||
logToolSchemasForGoogle({ tools, provider });
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
|
||||
let runtimeCapabilities = runtimeChannel
|
||||
? (resolveChannelCapabilities({
|
||||
cfg: params.config,
|
||||
channel: runtimeChannel,
|
||||
accountId: params.agentAccountId,
|
||||
}) ?? [])
|
||||
: undefined;
|
||||
if (runtimeChannel === "telegram" && params.config) {
|
||||
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
||||
cfg: params.config,
|
||||
accountId: params.agentAccountId ?? undefined,
|
||||
});
|
||||
if (inlineButtonsScope !== "off") {
|
||||
if (!runtimeCapabilities) runtimeCapabilities = [];
|
||||
if (
|
||||
!runtimeCapabilities.some((cap) => String(cap).trim().toLowerCase() === "inlinebuttons")
|
||||
) {
|
||||
runtimeCapabilities.push("inlineButtons");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Resolve channel-specific message actions for system prompt
|
||||
const channelActions = runtimeChannel
|
||||
? listChannelSupportedActions({
|
||||
cfg: params.config,
|
||||
channel: runtimeChannel,
|
||||
})
|
||||
: undefined;
|
||||
const messageToolHints = runtimeChannel
|
||||
? resolveChannelMessageToolHints({
|
||||
cfg: params.config,
|
||||
channel: runtimeChannel,
|
||||
accountId: params.agentAccountId,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const runtimeInfo = {
|
||||
host: machineName,
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
arch: os.arch(),
|
||||
node: process.version,
|
||||
model: `${provider}/${modelId}`,
|
||||
channel: runtimeChannel,
|
||||
capabilities: runtimeCapabilities,
|
||||
channelActions,
|
||||
};
|
||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
||||
const reasoningTagHint = isReasoningTagProvider(provider);
|
||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
});
|
||||
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
||||
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
||||
const docsPath = await resolveClawdbotDocsPath({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
argv1: process.argv[1],
|
||||
cwd: process.cwd(),
|
||||
moduleUrl: import.meta.url,
|
||||
});
|
||||
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
|
||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
defaultThinkLevel: params.thinkLevel,
|
||||
reasoningLevel: params.reasoningLevel ?? "off",
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
reasoningTagHint,
|
||||
heartbeatPrompt: isDefaultAgent
|
||||
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
||||
: undefined,
|
||||
skillsPrompt,
|
||||
docsPath: docsPath ?? undefined,
|
||||
ttsHint,
|
||||
promptMode,
|
||||
runtimeInfo,
|
||||
messageToolHints,
|
||||
sandboxInfo,
|
||||
tools,
|
||||
modelAliasLines: buildModelAliasLines(params.config),
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
contextFiles,
|
||||
});
|
||||
const systemPrompt = createSystemPromptOverride(appendPrompt);
|
||||
|
||||
const sessionLock = await acquireSessionWriteLock({
|
||||
sessionFile: params.sessionFile,
|
||||
});
|
||||
try {
|
||||
await prewarmSessionFile(params.sessionFile);
|
||||
const transcriptPolicy = resolveTranscriptPolicy({
|
||||
modelApi: model.api,
|
||||
provider,
|
||||
modelId,
|
||||
});
|
||||
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
|
||||
});
|
||||
trackSessionManagerAccess(params.sessionFile);
|
||||
const settingsManager = SettingsManager.create(effectiveWorkspace, agentDir);
|
||||
ensurePiCompactionReserveTokens({
|
||||
settingsManager,
|
||||
minReserveTokens: resolveCompactionReserveTokensFloor(params.config),
|
||||
});
|
||||
const additionalExtensionPaths = buildEmbeddedExtensionPaths({
|
||||
cfg: params.config,
|
||||
sessionManager,
|
||||
provider,
|
||||
modelId,
|
||||
model,
|
||||
});
|
||||
|
||||
const { builtInTools, customTools } = splitSdkTools({
|
||||
tools,
|
||||
sandboxEnabled: !!sandbox?.enabled,
|
||||
});
|
||||
|
||||
let session: Awaited<ReturnType<typeof createAgentSession>>["session"];
|
||||
({ session } = await createAgentSession({
|
||||
cwd: resolvedWorkspace,
|
||||
agentDir,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
model,
|
||||
thinkingLevel: mapThinkingLevel(params.thinkLevel),
|
||||
systemPrompt,
|
||||
tools: builtInTools,
|
||||
customTools,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
additionalExtensionPaths,
|
||||
}));
|
||||
|
||||
try {
|
||||
const prior = await sanitizeSessionHistory({
|
||||
messages: session.messages,
|
||||
modelApi: model.api,
|
||||
modelId,
|
||||
provider,
|
||||
sessionManager,
|
||||
sessionId: params.sessionId,
|
||||
policy: transcriptPolicy,
|
||||
});
|
||||
const validatedGemini = transcriptPolicy.validateGeminiTurns
|
||||
? validateGeminiTurns(prior)
|
||||
: prior;
|
||||
const validated = transcriptPolicy.validateAnthropicTurns
|
||||
? validateAnthropicTurns(validatedGemini)
|
||||
: validatedGemini;
|
||||
const limited = limitHistoryTurns(
|
||||
validated,
|
||||
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
|
||||
);
|
||||
if (limited.length > 0) {
|
||||
session.agent.replaceMessages(limited);
|
||||
}
|
||||
const result = await session.compact(params.customInstructions);
|
||||
// Estimate tokens after compaction by summing token estimates for remaining messages
|
||||
let tokensAfter: number | undefined;
|
||||
try {
|
||||
tokensAfter = 0;
|
||||
for (const message of session.messages) {
|
||||
tokensAfter += estimateTokens(message);
|
||||
}
|
||||
// Sanity check: tokensAfter should be less than tokensBefore
|
||||
if (tokensAfter > result.tokensBefore) {
|
||||
tokensAfter = undefined; // Don't trust the estimate
|
||||
}
|
||||
} catch {
|
||||
// If estimation fails, leave tokensAfter undefined
|
||||
tokensAfter = undefined;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: result.summary,
|
||||
firstKeptEntryId: result.firstKeptEntryId,
|
||||
tokensBefore: result.tokensBefore,
|
||||
tokensAfter,
|
||||
details: result.details,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
sessionManager.flushPendingToolResults?.();
|
||||
session.dispose();
|
||||
}
|
||||
} finally {
|
||||
await sessionLock.release();
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: describeUnknownError(err),
|
||||
};
|
||||
} finally {
|
||||
restoreSkillEnv?.();
|
||||
process.chdir(prevCwd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compacts a session with lane queueing (session lane + global lane).
|
||||
* Use this from outside a lane context. If already inside a lane, use
|
||||
* `compactEmbeddedPiSessionDirect` to avoid deadlocks.
|
||||
*/
|
||||
export async function compactEmbeddedPiSession(
|
||||
params: CompactEmbeddedPiSessionParams,
|
||||
): Promise<EmbeddedPiCompactResult> {
|
||||
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
|
||||
const globalLane = resolveGlobalLane(params.lane);
|
||||
const enqueueGlobal =
|
||||
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
|
||||
return enqueueCommandInLane(sessionLane, () =>
|
||||
enqueueGlobal(async () => {
|
||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||
const prevCwd = process.cwd();
|
||||
|
||||
const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
||||
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
||||
await ensureClawdbotModelsJson(params.config, agentDir);
|
||||
const { model, error, authStorage, modelRegistry } = resolveModel(
|
||||
provider,
|
||||
modelId,
|
||||
agentDir,
|
||||
params.config,
|
||||
);
|
||||
if (!model) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: error ?? `Unknown model: ${provider}/${modelId}`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const apiKeyInfo = await getApiKeyForModel({
|
||||
model,
|
||||
cfg: params.config,
|
||||
agentDir,
|
||||
});
|
||||
|
||||
if (!apiKeyInfo.apiKey) {
|
||||
if (apiKeyInfo.mode !== "aws-sdk") {
|
||||
throw new Error(
|
||||
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
||||
);
|
||||
}
|
||||
} else if (model.provider === "github-copilot") {
|
||||
const { resolveCopilotApiToken } =
|
||||
await import("../../providers/github-copilot-token.js");
|
||||
const copilotToken = await resolveCopilotApiToken({
|
||||
githubToken: apiKeyInfo.apiKey,
|
||||
});
|
||||
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
|
||||
} else {
|
||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: describeUnknownError(err),
|
||||
};
|
||||
}
|
||||
|
||||
await fs.mkdir(resolvedWorkspace, { recursive: true });
|
||||
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
|
||||
const sandbox = await resolveSandboxContext({
|
||||
config: params.config,
|
||||
sessionKey: sandboxSessionKey,
|
||||
workspaceDir: resolvedWorkspace,
|
||||
});
|
||||
const effectiveWorkspace = sandbox?.enabled
|
||||
? sandbox.workspaceAccess === "rw"
|
||||
? resolvedWorkspace
|
||||
: sandbox.workspaceDir
|
||||
: resolvedWorkspace;
|
||||
await fs.mkdir(effectiveWorkspace, { recursive: true });
|
||||
await ensureSessionHeader({
|
||||
sessionFile: params.sessionFile,
|
||||
sessionId: params.sessionId,
|
||||
cwd: effectiveWorkspace,
|
||||
});
|
||||
|
||||
let restoreSkillEnv: (() => void) | undefined;
|
||||
process.chdir(effectiveWorkspace);
|
||||
try {
|
||||
const shouldLoadSkillEntries =
|
||||
!params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
|
||||
const skillEntries = shouldLoadSkillEntries
|
||||
? loadWorkspaceSkillEntries(effectiveWorkspace)
|
||||
: [];
|
||||
restoreSkillEnv = params.skillsSnapshot
|
||||
? applySkillEnvOverridesFromSnapshot({
|
||||
snapshot: params.skillsSnapshot,
|
||||
config: params.config,
|
||||
})
|
||||
: applySkillEnvOverrides({
|
||||
skills: skillEntries ?? [],
|
||||
config: params.config,
|
||||
});
|
||||
const skillsPrompt = resolveSkillsPromptForRun({
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
entries: shouldLoadSkillEntries ? skillEntries : undefined,
|
||||
config: params.config,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
});
|
||||
|
||||
const sessionLabel = params.sessionKey ?? params.sessionId;
|
||||
const { contextFiles } = await resolveBootstrapContextForRun({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
config: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: params.sessionId,
|
||||
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
|
||||
});
|
||||
const runAbortController = new AbortController();
|
||||
const toolsRaw = createClawdbotCodingTools({
|
||||
exec: {
|
||||
...resolveExecToolDefaults(params.config),
|
||||
elevated: params.bashElevated,
|
||||
},
|
||||
sandbox,
|
||||
messageProvider: params.messageChannel ?? params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
agentDir,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
config: params.config,
|
||||
abortSignal: runAbortController.signal,
|
||||
modelProvider: model.provider,
|
||||
modelId,
|
||||
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
||||
});
|
||||
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider });
|
||||
logToolSchemasForGoogle({ tools, provider });
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeChannel = normalizeMessageChannel(
|
||||
params.messageChannel ?? params.messageProvider,
|
||||
);
|
||||
let runtimeCapabilities = runtimeChannel
|
||||
? (resolveChannelCapabilities({
|
||||
cfg: params.config,
|
||||
channel: runtimeChannel,
|
||||
accountId: params.agentAccountId,
|
||||
}) ?? [])
|
||||
: undefined;
|
||||
if (runtimeChannel === "telegram" && params.config) {
|
||||
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
||||
cfg: params.config,
|
||||
accountId: params.agentAccountId ?? undefined,
|
||||
});
|
||||
if (inlineButtonsScope !== "off") {
|
||||
if (!runtimeCapabilities) runtimeCapabilities = [];
|
||||
if (
|
||||
!runtimeCapabilities.some(
|
||||
(cap) => String(cap).trim().toLowerCase() === "inlinebuttons",
|
||||
)
|
||||
) {
|
||||
runtimeCapabilities.push("inlineButtons");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Resolve channel-specific message actions for system prompt
|
||||
const channelActions = runtimeChannel
|
||||
? listChannelSupportedActions({
|
||||
cfg: params.config,
|
||||
channel: runtimeChannel,
|
||||
})
|
||||
: undefined;
|
||||
const messageToolHints = runtimeChannel
|
||||
? resolveChannelMessageToolHints({
|
||||
cfg: params.config,
|
||||
channel: runtimeChannel,
|
||||
accountId: params.agentAccountId,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const runtimeInfo = {
|
||||
host: machineName,
|
||||
os: `${os.type()} ${os.release()}`,
|
||||
arch: os.arch(),
|
||||
node: process.version,
|
||||
model: `${provider}/${modelId}`,
|
||||
channel: runtimeChannel,
|
||||
capabilities: runtimeCapabilities,
|
||||
channelActions,
|
||||
};
|
||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
||||
const reasoningTagHint = isReasoningTagProvider(provider);
|
||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
||||
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
|
||||
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
|
||||
sessionKey: params.sessionKey,
|
||||
config: params.config,
|
||||
});
|
||||
const isDefaultAgent = sessionAgentId === defaultAgentId;
|
||||
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
|
||||
const docsPath = await resolveClawdbotDocsPath({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
argv1: process.argv[1],
|
||||
cwd: process.cwd(),
|
||||
moduleUrl: import.meta.url,
|
||||
});
|
||||
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
|
||||
const appendPrompt = buildEmbeddedSystemPrompt({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
defaultThinkLevel: params.thinkLevel,
|
||||
reasoningLevel: params.reasoningLevel ?? "off",
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
reasoningTagHint,
|
||||
heartbeatPrompt: isDefaultAgent
|
||||
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
|
||||
: undefined,
|
||||
skillsPrompt,
|
||||
docsPath: docsPath ?? undefined,
|
||||
ttsHint,
|
||||
promptMode,
|
||||
runtimeInfo,
|
||||
messageToolHints,
|
||||
sandboxInfo,
|
||||
tools,
|
||||
modelAliasLines: buildModelAliasLines(params.config),
|
||||
userTimezone,
|
||||
userTime,
|
||||
userTimeFormat,
|
||||
contextFiles,
|
||||
});
|
||||
const systemPrompt = createSystemPromptOverride(appendPrompt);
|
||||
|
||||
const sessionLock = await acquireSessionWriteLock({
|
||||
sessionFile: params.sessionFile,
|
||||
});
|
||||
try {
|
||||
await prewarmSessionFile(params.sessionFile);
|
||||
const transcriptPolicy = resolveTranscriptPolicy({
|
||||
modelApi: model.api,
|
||||
provider,
|
||||
modelId,
|
||||
});
|
||||
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: params.sessionKey,
|
||||
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
|
||||
});
|
||||
trackSessionManagerAccess(params.sessionFile);
|
||||
const settingsManager = SettingsManager.create(effectiveWorkspace, agentDir);
|
||||
ensurePiCompactionReserveTokens({
|
||||
settingsManager,
|
||||
minReserveTokens: resolveCompactionReserveTokensFloor(params.config),
|
||||
});
|
||||
const additionalExtensionPaths = buildEmbeddedExtensionPaths({
|
||||
cfg: params.config,
|
||||
sessionManager,
|
||||
provider,
|
||||
modelId,
|
||||
model,
|
||||
});
|
||||
|
||||
const { builtInTools, customTools } = splitSdkTools({
|
||||
tools,
|
||||
sandboxEnabled: !!sandbox?.enabled,
|
||||
});
|
||||
|
||||
let session: Awaited<ReturnType<typeof createAgentSession>>["session"];
|
||||
({ session } = await createAgentSession({
|
||||
cwd: resolvedWorkspace,
|
||||
agentDir,
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
model,
|
||||
thinkingLevel: mapThinkingLevel(params.thinkLevel),
|
||||
systemPrompt,
|
||||
tools: builtInTools,
|
||||
customTools,
|
||||
sessionManager,
|
||||
settingsManager,
|
||||
skills: [],
|
||||
contextFiles: [],
|
||||
additionalExtensionPaths,
|
||||
}));
|
||||
|
||||
try {
|
||||
const prior = await sanitizeSessionHistory({
|
||||
messages: session.messages,
|
||||
modelApi: model.api,
|
||||
modelId,
|
||||
provider,
|
||||
sessionManager,
|
||||
sessionId: params.sessionId,
|
||||
policy: transcriptPolicy,
|
||||
});
|
||||
const validatedGemini = transcriptPolicy.validateGeminiTurns
|
||||
? validateGeminiTurns(prior)
|
||||
: prior;
|
||||
const validated = transcriptPolicy.validateAnthropicTurns
|
||||
? validateAnthropicTurns(validatedGemini)
|
||||
: validatedGemini;
|
||||
const limited = limitHistoryTurns(
|
||||
validated,
|
||||
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
|
||||
);
|
||||
if (limited.length > 0) {
|
||||
session.agent.replaceMessages(limited);
|
||||
}
|
||||
const result = await session.compact(params.customInstructions);
|
||||
// Estimate tokens after compaction by summing token estimates for remaining messages
|
||||
let tokensAfter: number | undefined;
|
||||
try {
|
||||
tokensAfter = 0;
|
||||
for (const message of session.messages) {
|
||||
tokensAfter += estimateTokens(message);
|
||||
}
|
||||
// Sanity check: tokensAfter should be less than tokensBefore
|
||||
if (tokensAfter > result.tokensBefore) {
|
||||
tokensAfter = undefined; // Don't trust the estimate
|
||||
}
|
||||
} catch {
|
||||
// If estimation fails, leave tokensAfter undefined
|
||||
tokensAfter = undefined;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: result.summary,
|
||||
firstKeptEntryId: result.firstKeptEntryId,
|
||||
tokensBefore: result.tokensBefore,
|
||||
tokensAfter,
|
||||
details: result.details,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
sessionManager.flushPendingToolResults?.();
|
||||
session.dispose();
|
||||
}
|
||||
} finally {
|
||||
await sessionLock.release();
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: describeUnknownError(err),
|
||||
};
|
||||
} finally {
|
||||
restoreSkillEnv?.();
|
||||
process.chdir(prevCwd);
|
||||
}
|
||||
}),
|
||||
enqueueGlobal(async () => compactEmbeddedPiSessionDirect(params)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("./run/attempt.js", () => ({
|
||||
runEmbeddedAttempt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./compact.js", () => ({
|
||||
compactEmbeddedPiSessionDirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./model.js", () => ({
|
||||
resolveModel: vi.fn(() => ({
|
||||
model: {
|
||||
id: "test-model",
|
||||
provider: "anthropic",
|
||||
contextWindow: 200000,
|
||||
api: "messages",
|
||||
},
|
||||
error: null,
|
||||
authStorage: {
|
||||
setRuntimeApiKey: vi.fn(),
|
||||
},
|
||||
modelRegistry: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../model-auth.js", () => ({
|
||||
ensureAuthProfileStore: vi.fn(() => ({})),
|
||||
getApiKeyForModel: vi.fn(async () => ({
|
||||
apiKey: "test-key",
|
||||
profileId: "test-profile",
|
||||
source: "test",
|
||||
})),
|
||||
resolveAuthProfileOrder: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("../models-config.js", () => ({
|
||||
ensureClawdbotModelsJson: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../context-window-guard.js", () => ({
|
||||
CONTEXT_WINDOW_HARD_MIN_TOKENS: 1000,
|
||||
CONTEXT_WINDOW_WARN_BELOW_TOKENS: 5000,
|
||||
evaluateContextWindowGuard: vi.fn(() => ({
|
||||
shouldWarn: false,
|
||||
shouldBlock: false,
|
||||
tokens: 200000,
|
||||
source: "model",
|
||||
})),
|
||||
resolveContextWindowInfo: vi.fn(() => ({
|
||||
tokens: 200000,
|
||||
source: "model",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../process/command-queue.js", () => ({
|
||||
enqueueCommandInLane: vi.fn((_lane: string, task: () => unknown) => task()),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils.js", () => ({
|
||||
resolveUserPath: vi.fn((p: string) => p),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/message-channel.js", () => ({
|
||||
isMarkdownCapableMessageChannel: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("../agent-paths.js", () => ({
|
||||
resolveClawdbotAgentDir: vi.fn(() => "/tmp/agent-dir"),
|
||||
}));
|
||||
|
||||
vi.mock("../auth-profiles.js", () => ({
|
||||
markAuthProfileFailure: vi.fn(async () => {}),
|
||||
markAuthProfileGood: vi.fn(async () => {}),
|
||||
markAuthProfileUsed: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../defaults.js", () => ({
|
||||
DEFAULT_CONTEXT_TOKENS: 200000,
|
||||
DEFAULT_MODEL: "test-model",
|
||||
DEFAULT_PROVIDER: "anthropic",
|
||||
}));
|
||||
|
||||
vi.mock("../failover-error.js", () => ({
|
||||
FailoverError: class extends Error {
|
||||
constructor(msg: string) {
|
||||
super(msg);
|
||||
}
|
||||
},
|
||||
resolveFailoverStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../usage.js", () => ({
|
||||
normalizeUsage: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("./lanes.js", () => ({
|
||||
resolveSessionLane: vi.fn(() => "session-lane"),
|
||||
resolveGlobalLane: vi.fn(() => "global-lane"),
|
||||
}));
|
||||
|
||||
vi.mock("./logger.js", () => ({
|
||||
log: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./run/payloads.js", () => ({
|
||||
buildEmbeddedRunPayloads: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("./utils.js", () => ({
|
||||
describeUnknownError: vi.fn((err: unknown) => {
|
||||
if (err instanceof Error) return err.message;
|
||||
return String(err);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../pi-embedded-helpers.js", async () => {
|
||||
return {
|
||||
isCompactionFailureError: (msg?: string) => {
|
||||
if (!msg) return false;
|
||||
const lower = msg.toLowerCase();
|
||||
return lower.includes("request_too_large") && lower.includes("summarization failed");
|
||||
},
|
||||
isContextOverflowError: (msg?: string) => {
|
||||
if (!msg) return false;
|
||||
const lower = msg.toLowerCase();
|
||||
return lower.includes("request_too_large") || lower.includes("request size exceeds");
|
||||
},
|
||||
isFailoverAssistantError: vi.fn(() => false),
|
||||
isFailoverErrorMessage: vi.fn(() => false),
|
||||
isAuthAssistantError: vi.fn(() => false),
|
||||
isRateLimitAssistantError: vi.fn(() => false),
|
||||
classifyFailoverReason: vi.fn(() => null),
|
||||
formatAssistantErrorText: vi.fn(() => ""),
|
||||
pickFallbackThinkingLevel: vi.fn(() => null),
|
||||
isTimeoutErrorMessage: vi.fn(() => false),
|
||||
parseImageDimensionError: vi.fn(() => null),
|
||||
};
|
||||
});
|
||||
|
||||
import { runEmbeddedPiAgent } from "./run.js";
|
||||
import { runEmbeddedAttempt } from "./run/attempt.js";
|
||||
import { compactEmbeddedPiSessionDirect } from "./compact.js";
|
||||
import { log } from "./logger.js";
|
||||
|
||||
import type { EmbeddedRunAttemptResult } from "./run/types.js";
|
||||
|
||||
const mockedRunEmbeddedAttempt = vi.mocked(runEmbeddedAttempt);
|
||||
const mockedCompactDirect = vi.mocked(compactEmbeddedPiSessionDirect);
|
||||
|
||||
function makeAttemptResult(
|
||||
overrides: Partial<EmbeddedRunAttemptResult> = {},
|
||||
): EmbeddedRunAttemptResult {
|
||||
return {
|
||||
aborted: false,
|
||||
timedOut: false,
|
||||
promptError: null,
|
||||
sessionIdUsed: "test-session",
|
||||
assistantTexts: ["Hello!"],
|
||||
toolMetas: [],
|
||||
lastAssistant: undefined,
|
||||
messagesSnapshot: [],
|
||||
didSendViaMessagingTool: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentTargets: [],
|
||||
cloudCodeAssistFormatError: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const baseParams = {
|
||||
sessionId: "test-session",
|
||||
sessionKey: "test-key",
|
||||
sessionFile: "/tmp/session.json",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
prompt: "hello",
|
||||
timeoutMs: 30000,
|
||||
runId: "run-1",
|
||||
};
|
||||
|
||||
describe("overflow compaction in run loop", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("retries after successful compaction on context overflow promptError", async () => {
|
||||
const overflowError = new Error("request_too_large: Request size exceeds model context window");
|
||||
|
||||
mockedRunEmbeddedAttempt
|
||||
.mockResolvedValueOnce(makeAttemptResult({ promptError: overflowError }))
|
||||
.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
|
||||
|
||||
mockedCompactDirect.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "Compacted session",
|
||||
firstKeptEntryId: "entry-5",
|
||||
tokensBefore: 150000,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runEmbeddedPiAgent(baseParams);
|
||||
|
||||
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
|
||||
expect(mockedCompactDirect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ authProfileId: "test-profile" }),
|
||||
);
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
|
||||
expect(log.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("context overflow detected; attempting auto-compaction"),
|
||||
);
|
||||
expect(log.info).toHaveBeenCalledWith(expect.stringContaining("auto-compaction succeeded"));
|
||||
// Should not be an error result
|
||||
expect(result.meta.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns error if compaction fails", async () => {
|
||||
const overflowError = new Error("request_too_large: Request size exceeds model context window");
|
||||
|
||||
mockedRunEmbeddedAttempt.mockResolvedValue(makeAttemptResult({ promptError: overflowError }));
|
||||
|
||||
mockedCompactDirect.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "nothing to compact",
|
||||
});
|
||||
|
||||
const result = await runEmbeddedPiAgent(baseParams);
|
||||
|
||||
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
|
||||
expect(result.meta.error?.kind).toBe("context_overflow");
|
||||
expect(result.payloads?.[0]?.isError).toBe(true);
|
||||
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("auto-compaction failed"));
|
||||
});
|
||||
|
||||
it("returns error if overflow happens again after compaction", async () => {
|
||||
const overflowError = new Error("request_too_large: Request size exceeds model context window");
|
||||
|
||||
mockedRunEmbeddedAttempt
|
||||
.mockResolvedValueOnce(makeAttemptResult({ promptError: overflowError }))
|
||||
.mockResolvedValueOnce(makeAttemptResult({ promptError: overflowError }));
|
||||
|
||||
mockedCompactDirect.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "Compacted",
|
||||
firstKeptEntryId: "entry-3",
|
||||
tokensBefore: 180000,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runEmbeddedPiAgent(baseParams);
|
||||
|
||||
// Compaction attempted only once
|
||||
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
|
||||
// Two attempts: first overflow -> compact -> retry -> second overflow -> return error
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
|
||||
expect(result.meta.error?.kind).toBe("context_overflow");
|
||||
expect(result.payloads?.[0]?.isError).toBe(true);
|
||||
});
|
||||
|
||||
it("does not attempt compaction for compaction_failure errors", async () => {
|
||||
const compactionFailureError = new Error(
|
||||
"request_too_large: summarization failed - Request size exceeds model context window",
|
||||
);
|
||||
|
||||
mockedRunEmbeddedAttempt.mockResolvedValue(
|
||||
makeAttemptResult({ promptError: compactionFailureError }),
|
||||
);
|
||||
|
||||
const result = await runEmbeddedPiAgent(baseParams);
|
||||
|
||||
expect(mockedCompactDirect).not.toHaveBeenCalled();
|
||||
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
|
||||
expect(result.meta.error?.kind).toBe("compaction_failure");
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "../pi-embedded-helpers.js";
|
||||
import { normalizeUsage, type UsageLike } from "../usage.js";
|
||||
|
||||
import { compactEmbeddedPiSessionDirect } from "./compact.js";
|
||||
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
||||
import { log } from "./logger.js";
|
||||
import { resolveModel } from "./model.js";
|
||||
@@ -290,6 +291,7 @@ export async function runEmbeddedPiAgent(
|
||||
}
|
||||
}
|
||||
|
||||
let overflowCompactionAttempted = false;
|
||||
try {
|
||||
while (true) {
|
||||
attemptedThinking.add(thinkLevel);
|
||||
@@ -358,9 +360,42 @@ export async function runEmbeddedPiAgent(
|
||||
if (promptError && !aborted) {
|
||||
const errorText = describeUnknownError(promptError);
|
||||
if (isContextOverflowError(errorText)) {
|
||||
const kind = isCompactionFailureError(errorText)
|
||||
? "compaction_failure"
|
||||
: "context_overflow";
|
||||
const isCompactionFailure = isCompactionFailureError(errorText);
|
||||
// Attempt auto-compaction on context overflow (not compaction_failure)
|
||||
if (!isCompactionFailure && !overflowCompactionAttempted) {
|
||||
log.warn(
|
||||
`context overflow detected; attempting auto-compaction for ${provider}/${modelId}`,
|
||||
);
|
||||
overflowCompactionAttempted = true;
|
||||
const compactResult = await compactEmbeddedPiSessionDirect({
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
messageChannel: params.messageChannel,
|
||||
messageProvider: params.messageProvider,
|
||||
agentAccountId: params.agentAccountId,
|
||||
authProfileId: lastProfileId,
|
||||
sessionFile: params.sessionFile,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentDir,
|
||||
config: params.config,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
provider,
|
||||
model: modelId,
|
||||
thinkLevel,
|
||||
reasoningLevel: params.reasoningLevel,
|
||||
bashElevated: params.bashElevated,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
ownerNumbers: params.ownerNumbers,
|
||||
});
|
||||
if (compactResult.compacted) {
|
||||
log.info(`auto-compaction succeeded for ${provider}/${modelId}; retrying prompt`);
|
||||
continue;
|
||||
}
|
||||
log.warn(
|
||||
`auto-compaction failed for ${provider}/${modelId}: ${compactResult.reason ?? "nothing to compact"}`,
|
||||
);
|
||||
}
|
||||
const kind = isCompactionFailure ? "compaction_failure" : "context_overflow";
|
||||
return {
|
||||
payloads: [
|
||||
{
|
||||
|
||||
@@ -460,6 +460,22 @@ File contents here`,
|
||||
expect(result).toBe("The actual answer.");
|
||||
});
|
||||
|
||||
it("strips final tags while keeping content", () => {
|
||||
const msg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "<final>\nAnswer\n</final>",
|
||||
},
|
||||
],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const result = extractAssistantText(msg);
|
||||
expect(result).toBe("Answer");
|
||||
});
|
||||
|
||||
it("strips thought tags", () => {
|
||||
const msg: AssistantMessage = {
|
||||
role: "assistant",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||
import { stripReasoningTagsFromText } from "../shared/text/reasoning-tags.js";
|
||||
import { sanitizeUserFacingText } from "./pi-embedded-helpers.js";
|
||||
import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
|
||||
|
||||
@@ -166,36 +167,7 @@ export function stripDowngradedToolCallText(text: string): string {
|
||||
* that slip through other filtering mechanisms.
|
||||
*/
|
||||
export function stripThinkingTagsFromText(text: string): string {
|
||||
if (!text) return text;
|
||||
// Quick check to avoid regex overhead when no tags present.
|
||||
if (!/(?:think(?:ing)?|thought|antthinking)/i.test(text)) return text;
|
||||
|
||||
const tagRe = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>/gi;
|
||||
let result = "";
|
||||
let lastIndex = 0;
|
||||
let inThinking = false;
|
||||
|
||||
for (const match of text.matchAll(tagRe)) {
|
||||
const idx = match.index ?? 0;
|
||||
const isClose = match[1] === "/";
|
||||
|
||||
if (!inThinking && !isClose) {
|
||||
// Opening tag - save text before it.
|
||||
result += text.slice(lastIndex, idx);
|
||||
inThinking = true;
|
||||
} else if (inThinking && isClose) {
|
||||
// Closing tag - skip content inside.
|
||||
inThinking = false;
|
||||
}
|
||||
lastIndex = idx + match[0].length;
|
||||
}
|
||||
|
||||
// Append remaining text if we're not inside thinking.
|
||||
if (!inThinking) {
|
||||
result += text.slice(lastIndex);
|
||||
}
|
||||
|
||||
return result.trim();
|
||||
return stripReasoningTagsFromText(text, { mode: "strict", trim: "both" });
|
||||
}
|
||||
|
||||
export function extractAssistantText(msg: AssistantMessage): string {
|
||||
|
||||
@@ -164,6 +164,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
acceptsArgs: true,
|
||||
scope: "text",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "approve",
|
||||
nativeName: "approve",
|
||||
description: "Approve or deny exec requests.",
|
||||
textAlias: "/approve",
|
||||
acceptsArgs: true,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "context",
|
||||
nativeName: "context",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||
import { parseInlineDirectives } from "./directive-handling.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: vi.fn(),
|
||||
}));
|
||||
|
||||
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||
const ctx = {
|
||||
Body: commandBody,
|
||||
CommandBody: commandBody,
|
||||
CommandSource: "text",
|
||||
CommandAuthorized: true,
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
...ctxOverrides,
|
||||
} as MsgContext;
|
||||
|
||||
const command = buildCommandContext({
|
||||
ctx,
|
||||
cfg,
|
||||
isGroup: false,
|
||||
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
return {
|
||||
ctx,
|
||||
cfg,
|
||||
command,
|
||||
directives: parseInlineDirectives(commandBody),
|
||||
elevated: { enabled: true, allowed: true, failures: [] },
|
||||
sessionKey: "agent:main:main",
|
||||
workspaceDir: "/tmp",
|
||||
defaultGroupActivation: () => "mention",
|
||||
resolvedVerboseLevel: "off" as const,
|
||||
resolvedReasoningLevel: "off" as const,
|
||||
resolveDefaultThinkingLevel: async () => undefined,
|
||||
provider: "whatsapp",
|
||||
model: "test-model",
|
||||
contextTokens: 0,
|
||||
isGroup: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("/approve command", () => {
|
||||
it("rejects invalid usage", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/approve", cfg);
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Usage: /approve");
|
||||
});
|
||||
|
||||
it("submits approval", async () => {
|
||||
const cfg = {
|
||||
commands: { text: true },
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
} as ClawdbotConfig;
|
||||
const params = buildParams("/approve abc allow-once", cfg, { SenderId: "123" });
|
||||
|
||||
const mockCallGateway = vi.mocked(callGateway);
|
||||
mockCallGateway.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.shouldContinue).toBe(false);
|
||||
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
|
||||
expect(mockCallGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "exec.approval.resolve",
|
||||
params: { id: "abc", decision: "allow-once" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
const COMMAND = "/approve";
|
||||
|
||||
const DECISION_ALIASES: Record<string, "allow-once" | "allow-always" | "deny"> = {
|
||||
allow: "allow-once",
|
||||
once: "allow-once",
|
||||
"allow-once": "allow-once",
|
||||
allowonce: "allow-once",
|
||||
always: "allow-always",
|
||||
"allow-always": "allow-always",
|
||||
allowalways: "allow-always",
|
||||
deny: "deny",
|
||||
reject: "deny",
|
||||
block: "deny",
|
||||
};
|
||||
|
||||
type ParsedApproveCommand =
|
||||
| { ok: true; id: string; decision: "allow-once" | "allow-always" | "deny" }
|
||||
| { ok: false; error: string };
|
||||
|
||||
function parseApproveCommand(raw: string): ParsedApproveCommand | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed.toLowerCase().startsWith(COMMAND)) return null;
|
||||
const rest = trimmed.slice(COMMAND.length).trim();
|
||||
if (!rest) {
|
||||
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
|
||||
}
|
||||
const tokens = rest.split(/\s+/).filter(Boolean);
|
||||
if (tokens.length < 2) {
|
||||
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
|
||||
}
|
||||
|
||||
const first = tokens[0].toLowerCase();
|
||||
const second = tokens[1].toLowerCase();
|
||||
|
||||
if (DECISION_ALIASES[first]) {
|
||||
return {
|
||||
ok: true,
|
||||
decision: DECISION_ALIASES[first],
|
||||
id: tokens.slice(1).join(" ").trim(),
|
||||
};
|
||||
}
|
||||
if (DECISION_ALIASES[second]) {
|
||||
return {
|
||||
ok: true,
|
||||
decision: DECISION_ALIASES[second],
|
||||
id: tokens[0],
|
||||
};
|
||||
}
|
||||
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
|
||||
}
|
||||
|
||||
function buildResolvedByLabel(params: Parameters<CommandHandler>[0]): string {
|
||||
const channel = params.command.channel;
|
||||
const sender = params.command.senderId ?? "unknown";
|
||||
return `${channel}:${sender}`;
|
||||
}
|
||||
|
||||
export const handleApproveCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) return null;
|
||||
const normalized = params.command.commandBodyNormalized;
|
||||
const parsed = parseApproveCommand(normalized);
|
||||
if (!parsed) return null;
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /approve from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
|
||||
if (!parsed.ok) {
|
||||
return { shouldContinue: false, reply: { text: parsed.error } };
|
||||
}
|
||||
|
||||
const resolvedBy = buildResolvedByLabel(params);
|
||||
try {
|
||||
await callGateway({
|
||||
method: "exec.approval.resolve",
|
||||
params: { id: parsed.id, decision: parsed.decision },
|
||||
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||
clientDisplayName: `Chat approval (${resolvedBy})`,
|
||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `❌ Failed to submit approval: ${String(err)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `✅ Exec approval ${parsed.decision} submitted for ${parsed.id}.` },
|
||||
};
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
handleWhoamiCommand,
|
||||
} from "./commands-info.js";
|
||||
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
||||
import { handleApproveCommand } from "./commands-approve.js";
|
||||
import { handleSubagentsCommand } from "./commands-subagents.js";
|
||||
import { handleModelsCommand } from "./commands-models.js";
|
||||
import { handleTtsCommands } from "./commands-tts.js";
|
||||
@@ -45,6 +46,7 @@ const HANDLERS: CommandHandler[] = [
|
||||
handleCommandsListCommand,
|
||||
handleStatusCommand,
|
||||
handleAllowlistCommand,
|
||||
handleApproveCommand,
|
||||
handleContextCommand,
|
||||
handleWhoamiCommand,
|
||||
handleSubagentsCommand,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Command } from "commander";
|
||||
import type { CronJob } from "../../cron/types.js";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { sanitizeAgentId } from "../../routing/session-key.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import { parsePositiveIntOrUndefined } from "../program/helpers.js";
|
||||
@@ -140,7 +140,7 @@ export function registerCronAddCommand(cron: Command) {
|
||||
|
||||
const agentId =
|
||||
typeof opts.agent === "string" && opts.agent.trim()
|
||||
? normalizeAgentId(opts.agent)
|
||||
? sanitizeAgentId(opts.agent.trim())
|
||||
: undefined;
|
||||
|
||||
const payload = (() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { normalizeAgentId } from "../../routing/session-key.js";
|
||||
import { sanitizeAgentId } from "../../routing/session-key.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
import {
|
||||
getCronChannelOptions,
|
||||
@@ -91,7 +91,7 @@ export function registerCronEditCommand(cron: Command) {
|
||||
throw new Error("Use --agent or --clear-agent, not both");
|
||||
}
|
||||
if (typeof opts.agent === "string" && opts.agent.trim()) {
|
||||
patch.agentId = normalizeAgentId(opts.agent);
|
||||
patch.agentId = sanitizeAgentId(opts.agent.trim());
|
||||
}
|
||||
if (opts.clearAgent) {
|
||||
patch.agentId = null;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("gateway.remote.transport", () => {
|
||||
it("accepts direct transport", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
gateway: {
|
||||
remote: {
|
||||
transport: "direct",
|
||||
url: "wss://gateway.example.ts.net",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown transport", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
gateway: {
|
||||
remote: {
|
||||
transport: "udp",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues[0]?.path).toBe("gateway.remote.transport");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ async function writePluginFixture(params: {
|
||||
dir: string;
|
||||
id: string;
|
||||
schema: Record<string, unknown>;
|
||||
channels?: string[];
|
||||
}) {
|
||||
await fs.mkdir(params.dir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
@@ -16,16 +17,16 @@ async function writePluginFixture(params: {
|
||||
`export default { id: "${params.id}", register() {} };`,
|
||||
"utf-8",
|
||||
);
|
||||
const manifest: Record<string, unknown> = {
|
||||
id: params.id,
|
||||
configSchema: params.schema,
|
||||
};
|
||||
if (params.channels) {
|
||||
manifest.channels = params.channels;
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(params.dir, "clawdbot.plugin.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
id: params.id,
|
||||
configSchema: params.schema,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
JSON.stringify(manifest, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
@@ -149,4 +150,43 @@ describe("config plugin validation", () => {
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts plugin heartbeat targets", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
|
||||
const pluginDir = path.join(home, "bluebubbles-plugin");
|
||||
await writePluginFixture({
|
||||
dir: pluginDir,
|
||||
id: "bluebubbles-plugin",
|
||||
channels: ["bluebubbles"],
|
||||
schema: { type: "object" },
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const { validateConfigObjectWithPlugins } = await import("./config.js");
|
||||
const res = validateConfigObjectWithPlugins({
|
||||
agents: { defaults: { heartbeat: { target: "bluebubbles" } }, list: [{ id: "pi" }] },
|
||||
plugins: { enabled: false, load: { paths: [pluginDir] } },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown heartbeat targets", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
process.env.CLAWDBOT_STATE_DIR = path.join(home, ".clawdbot");
|
||||
vi.resetModules();
|
||||
const { validateConfigObjectWithPlugins } = await import("./config.js");
|
||||
const res = validateConfigObjectWithPlugins({
|
||||
agents: { defaults: { heartbeat: { target: "not-a-channel" } }, list: [{ id: "pi" }] },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.issues).toContainEqual({
|
||||
path: "agents.defaults.heartbeat.target",
|
||||
message: "unknown heartbeat target: not-a-channel",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -84,4 +84,22 @@ describe("config schema", () => {
|
||||
const channelProps = channelSchema?.properties as Record<string, unknown> | undefined;
|
||||
expect(channelProps?.accessToken).toBeTruthy();
|
||||
});
|
||||
|
||||
it("adds heartbeat target hints with dynamic channels", () => {
|
||||
const res = buildConfigSchema({
|
||||
channels: [
|
||||
{
|
||||
id: "bluebubbles",
|
||||
label: "BlueBubbles",
|
||||
configSchema: { type: "object" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const defaultsHint = res.uiHints["agents.defaults.heartbeat.target"];
|
||||
const listHint = res.uiHints["agents.list.*.heartbeat.target"];
|
||||
expect(defaultsHint?.help).toContain("bluebubbles");
|
||||
expect(defaultsHint?.help).toContain("last");
|
||||
expect(listHint?.help).toContain("bluebubbles");
|
||||
});
|
||||
});
|
||||
+43
-1
@@ -1,3 +1,4 @@
|
||||
import { CHANNEL_IDS } from "../channels/registry.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { ClawdbotSchema } from "./zod-schema.js";
|
||||
|
||||
@@ -807,6 +808,44 @@ function applyChannelHints(hints: ConfigUiHints, channels: ChannelUiMetadata[]):
|
||||
return next;
|
||||
}
|
||||
|
||||
function listHeartbeatTargetChannels(channels: ChannelUiMetadata[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const ordered: string[] = [];
|
||||
for (const id of CHANNEL_IDS) {
|
||||
const normalized = id.trim().toLowerCase();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
ordered.push(normalized);
|
||||
}
|
||||
for (const channel of channels) {
|
||||
const normalized = channel.id.trim().toLowerCase();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
ordered.push(normalized);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function applyHeartbeatTargetHints(
|
||||
hints: ConfigUiHints,
|
||||
channels: ChannelUiMetadata[],
|
||||
): ConfigUiHints {
|
||||
const next: ConfigUiHints = { ...hints };
|
||||
const channelList = listHeartbeatTargetChannels(channels);
|
||||
const channelHelp = channelList.length ? ` Known channels: ${channelList.join(", ")}.` : "";
|
||||
const help = `Delivery target ("last", "none", or a channel id).${channelHelp}`;
|
||||
const paths = ["agents.defaults.heartbeat.target", "agents.list.*.heartbeat.target"];
|
||||
for (const path of paths) {
|
||||
const current = next[path] ?? {};
|
||||
next[path] = {
|
||||
...current,
|
||||
help: current.help ?? help,
|
||||
placeholder: current.placeholder ?? "last",
|
||||
};
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyPluginSchemas(schema: ConfigSchema, plugins: PluginUiMetadata[]): ConfigSchema {
|
||||
const next = cloneSchema(schema);
|
||||
const root = asSchemaObject(next);
|
||||
@@ -908,7 +947,10 @@ export function buildConfigSchema(params?: {
|
||||
const channels = params?.channels ?? [];
|
||||
if (plugins.length === 0 && channels.length === 0) return base;
|
||||
const mergedHints = applySensitiveHints(
|
||||
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
|
||||
applyHeartbeatTargetHints(
|
||||
applyChannelHints(applyPluginHints(base.uiHints, plugins), channels),
|
||||
channels,
|
||||
),
|
||||
);
|
||||
const mergedSchema = applyChannelSchemas(applyPluginSchemas(base.schema, plugins), channels);
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
HumanDelayConfig,
|
||||
TypingMode,
|
||||
} from "./types.base.js";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import type {
|
||||
SandboxBrowserSettings,
|
||||
SandboxDockerSettings,
|
||||
@@ -177,18 +178,8 @@ export type AgentDefaultsConfig = {
|
||||
model?: string;
|
||||
/** Session key for heartbeat runs ("main" or explicit session key). */
|
||||
session?: string;
|
||||
/** Delivery target (last|whatsapp|telegram|discord|slack|mattermost|msteams|signal|imessage|none). */
|
||||
target?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "slack"
|
||||
| "mattermost"
|
||||
| "msteams"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "none";
|
||||
/** Delivery target ("last", "none", or a channel id). */
|
||||
target?: "last" | "none" | ChannelId;
|
||||
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
|
||||
to?: string;
|
||||
/** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK."). */
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export type ExecApprovalForwardingMode = "session" | "targets" | "both";
|
||||
|
||||
export type ExecApprovalForwardTarget = {
|
||||
/** Channel id (e.g. "discord", "slack", or plugin channel id). */
|
||||
channel: string;
|
||||
/** Destination id (channel id, user id, etc. depending on channel). */
|
||||
to: string;
|
||||
/** Optional account id for multi-account channels. */
|
||||
accountId?: string;
|
||||
/** Optional thread id to reply inside a thread. */
|
||||
threadId?: string | number;
|
||||
};
|
||||
|
||||
export type ExecApprovalForwardingConfig = {
|
||||
/** Enable forwarding exec approvals to chat channels. Default: false. */
|
||||
enabled?: boolean;
|
||||
/** Delivery mode (session=origin chat, targets=config targets, both=both). Default: session. */
|
||||
mode?: ExecApprovalForwardingMode;
|
||||
/** Only forward approvals for these agent IDs. Omit = all agents. */
|
||||
agentFilter?: string[];
|
||||
/** Only forward approvals matching these session key patterns (substring or regex). */
|
||||
sessionFilter?: string[];
|
||||
/** Explicit delivery targets (used when mode includes targets). */
|
||||
targets?: ExecApprovalForwardTarget[];
|
||||
};
|
||||
|
||||
export type ApprovalsConfig = {
|
||||
exec?: ExecApprovalForwardingConfig;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentBinding, AgentsConfig } from "./types.agents.js";
|
||||
import type { ApprovalsConfig } from "./types.approvals.js";
|
||||
import type { AuthConfig } from "./types.auth.js";
|
||||
import type { DiagnosticsConfig, LoggingConfig, SessionConfig, WebConfig } from "./types.base.js";
|
||||
import type { BrowserConfig } from "./types.browser.js";
|
||||
@@ -84,6 +85,7 @@ export type ClawdbotConfig = {
|
||||
audio?: AudioConfig;
|
||||
messages?: MessagesConfig;
|
||||
commands?: CommandsConfig;
|
||||
approvals?: ApprovalsConfig;
|
||||
session?: SessionConfig;
|
||||
web?: WebConfig;
|
||||
channels?: ChannelsConfig;
|
||||
|
||||
@@ -72,6 +72,17 @@ export type DiscordActionConfig = {
|
||||
channels?: boolean;
|
||||
};
|
||||
|
||||
export type DiscordExecApprovalConfig = {
|
||||
/** Enable exec approval forwarding to Discord DMs. Default: false. */
|
||||
enabled?: boolean;
|
||||
/** Discord user IDs to receive approval prompts. Required if enabled. */
|
||||
approvers?: Array<string | number>;
|
||||
/** Only forward approvals for these agent IDs. Omit = all agents. */
|
||||
agentFilter?: string[];
|
||||
/** Only forward approvals matching these session key patterns (substring or regex). */
|
||||
sessionFilter?: string[];
|
||||
};
|
||||
|
||||
export type DiscordAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
@@ -124,6 +135,8 @@ export type DiscordAccountConfig = {
|
||||
guilds?: Record<string, DiscordGuildEntry>;
|
||||
/** Heartbeat visibility settings for this channel. */
|
||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||
/** Exec approval forwarding configuration. */
|
||||
execApprovals?: DiscordExecApprovalConfig;
|
||||
};
|
||||
|
||||
export type DiscordConfig = {
|
||||
|
||||
@@ -80,6 +80,8 @@ export type GatewayTailscaleConfig = {
|
||||
export type GatewayRemoteConfig = {
|
||||
/** Remote Gateway WebSocket URL (ws:// or wss://). */
|
||||
url?: string;
|
||||
/** Transport for macOS remote connections (ssh tunnel or direct WS). */
|
||||
transport?: "ssh" | "direct";
|
||||
/** Token for remote auth (when the gateway requires token auth). */
|
||||
token?: string;
|
||||
/** Password for remote auth (when the gateway requires password auth). */
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
export * from "./types.agent-defaults.js";
|
||||
export * from "./types.agents.js";
|
||||
export * from "./types.approvals.js";
|
||||
export * from "./types.auth.js";
|
||||
export * from "./types.base.js";
|
||||
export * from "./types.browser.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { CHANNEL_IDS } from "../channels/registry.js";
|
||||
import { CHANNEL_IDS, normalizeChatChannelId } from "../channels/registry.js";
|
||||
import {
|
||||
normalizePluginsConfig,
|
||||
resolveEnableState,
|
||||
@@ -226,6 +226,41 @@ export function validateConfigObjectWithPlugins(raw: unknown):
|
||||
}
|
||||
}
|
||||
|
||||
const heartbeatChannelIds = new Set<string>();
|
||||
for (const channelId of CHANNEL_IDS) {
|
||||
heartbeatChannelIds.add(channelId.toLowerCase());
|
||||
}
|
||||
for (const record of registry.plugins) {
|
||||
for (const channelId of record.channels) {
|
||||
const trimmed = channelId.trim();
|
||||
if (trimmed) heartbeatChannelIds.add(trimmed.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
const validateHeartbeatTarget = (target: string | undefined, path: string) => {
|
||||
if (typeof target !== "string") return;
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed) {
|
||||
issues.push({ path, message: "heartbeat target must not be empty" });
|
||||
return;
|
||||
}
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (normalized === "last" || normalized === "none") return;
|
||||
if (normalizeChatChannelId(trimmed)) return;
|
||||
if (heartbeatChannelIds.has(normalized)) return;
|
||||
issues.push({ path, message: `unknown heartbeat target: ${target}` });
|
||||
};
|
||||
|
||||
validateHeartbeatTarget(
|
||||
config.agents?.defaults?.heartbeat?.target,
|
||||
"agents.defaults.heartbeat.target",
|
||||
);
|
||||
if (Array.isArray(config.agents?.list)) {
|
||||
for (const [index, entry] of config.agents.list.entries()) {
|
||||
validateHeartbeatTarget(entry?.heartbeat?.target, `agents.list.${index}.heartbeat.target`);
|
||||
}
|
||||
}
|
||||
|
||||
let selectedMemoryPluginId: string | null = null;
|
||||
const seenPlugins = new Set<string>();
|
||||
for (const record of registry.plugins) {
|
||||
|
||||
@@ -22,19 +22,7 @@ export const HeartbeatSchema = z
|
||||
model: z.string().optional(),
|
||||
session: z.string().optional(),
|
||||
includeReasoning: z.boolean().optional(),
|
||||
target: z
|
||||
.union([
|
||||
z.literal("last"),
|
||||
z.literal("whatsapp"),
|
||||
z.literal("telegram"),
|
||||
z.literal("discord"),
|
||||
z.literal("slack"),
|
||||
z.literal("msteams"),
|
||||
z.literal("signal"),
|
||||
z.literal("imessage"),
|
||||
z.literal("none"),
|
||||
])
|
||||
.optional(),
|
||||
target: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
prompt: z.string().optional(),
|
||||
ackMaxChars: z.number().int().nonnegative().optional(),
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const ExecApprovalForwardTargetSchema = z
|
||||
.object({
|
||||
channel: z.string().min(1),
|
||||
to: z.string().min(1),
|
||||
accountId: z.string().optional(),
|
||||
threadId: z.union([z.string(), z.number()]).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ExecApprovalForwardingSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
mode: z.union([z.literal("session"), z.literal("targets"), z.literal("both")]).optional(),
|
||||
agentFilter: z.array(z.string()).optional(),
|
||||
sessionFilter: z.array(z.string()).optional(),
|
||||
targets: z.array(ExecApprovalForwardTargetSchema).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
export const ApprovalsSchema = z
|
||||
.object({
|
||||
exec: ExecApprovalForwardingSchema,
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
@@ -244,6 +244,15 @@ export const DiscordAccountSchema = z
|
||||
dm: DiscordDmSchema.optional(),
|
||||
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
execApprovals: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
approvers: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
agentFilter: z.array(z.string()).optional(),
|
||||
sessionFilter: z.array(z.string()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
|
||||
import { ApprovalsSchema } from "./zod-schema.approvals.js";
|
||||
import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js";
|
||||
import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js";
|
||||
import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
|
||||
@@ -220,6 +221,7 @@ export const ClawdbotSchema = z
|
||||
.optional(),
|
||||
messages: MessagesSchema,
|
||||
commands: CommandsSchema,
|
||||
approvals: ApprovalsSchema,
|
||||
session: SessionSchema,
|
||||
cron: z
|
||||
.object({
|
||||
@@ -332,6 +334,7 @@ export const ClawdbotSchema = z
|
||||
remote: z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
transport: z.union([z.literal("ssh"), z.literal("direct")]).optional(),
|
||||
token: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
tlsFingerprint: z.string().optional(),
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("normalizeCronJobCreate", () => {
|
||||
expect("provider" in payload).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes agentId and drops null", () => {
|
||||
it("trims agentId and drops null", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
name: "agent-set",
|
||||
enabled: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { sanitizeAgentId } from "../routing/session-key.js";
|
||||
import { parseAbsoluteTimeMs } from "./parse.js";
|
||||
import { migrateLegacyCronPayload } from "./payload-migration.js";
|
||||
import type { CronJobCreate, CronJobPatch } from "./types.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
@@ -76,7 +76,7 @@ export function normalizeCronJobInput(
|
||||
next.agentId = null;
|
||||
} else if (typeof agentId === "string") {
|
||||
const trimmed = agentId.trim();
|
||||
if (trimmed) next.agentId = normalizeAgentId(trimmed);
|
||||
if (trimmed) next.agentId = sanitizeAgentId(trimmed);
|
||||
else delete next.agentId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildExecApprovalCustomId,
|
||||
parseExecApprovalData,
|
||||
type ExecApprovalRequest,
|
||||
DiscordExecApprovalHandler,
|
||||
} from "./exec-approvals.js";
|
||||
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
|
||||
|
||||
describe("buildExecApprovalCustomId", () => {
|
||||
it("encodes approval id and action", () => {
|
||||
const customId = buildExecApprovalCustomId("abc-123", "allow-once");
|
||||
expect(customId).toBe("execapproval:id=abc-123;action=allow-once");
|
||||
});
|
||||
|
||||
it("encodes special characters in approval id", () => {
|
||||
const customId = buildExecApprovalCustomId("abc=123;test", "deny");
|
||||
expect(customId).toBe("execapproval:id=abc%3D123%3Btest;action=deny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseExecApprovalData", () => {
|
||||
it("parses valid data", () => {
|
||||
const result = parseExecApprovalData({ id: "abc-123", action: "allow-once" });
|
||||
expect(result).toEqual({ approvalId: "abc-123", action: "allow-once" });
|
||||
});
|
||||
|
||||
it("parses encoded data", () => {
|
||||
const result = parseExecApprovalData({
|
||||
id: "abc%3D123%3Btest",
|
||||
action: "allow-always",
|
||||
});
|
||||
expect(result).toEqual({ approvalId: "abc=123;test", action: "allow-always" });
|
||||
});
|
||||
|
||||
it("rejects invalid action", () => {
|
||||
const result = parseExecApprovalData({ id: "abc-123", action: "invalid" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing id", () => {
|
||||
const result = parseExecApprovalData({ action: "deny" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing action", () => {
|
||||
const result = parseExecApprovalData({ id: "abc-123" });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects null/undefined input", () => {
|
||||
expect(parseExecApprovalData(null as any)).toBeNull();
|
||||
expect(parseExecApprovalData(undefined as any)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts all valid actions", () => {
|
||||
expect(parseExecApprovalData({ id: "x", action: "allow-once" })?.action).toBe("allow-once");
|
||||
expect(parseExecApprovalData({ id: "x", action: "allow-always" })?.action).toBe("allow-always");
|
||||
expect(parseExecApprovalData({ id: "x", action: "deny" })?.action).toBe("deny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("roundtrip encoding", () => {
|
||||
it("encodes and decodes correctly", () => {
|
||||
const approvalId = "test-approval-with=special;chars&more";
|
||||
const action = "allow-always" as const;
|
||||
const customId = buildExecApprovalCustomId(approvalId, action);
|
||||
|
||||
// Parse the key=value pairs from the custom ID
|
||||
const parts = customId.split(";");
|
||||
const data: Record<string, string> = {};
|
||||
for (const part of parts) {
|
||||
const match = part.match(/^([^:]+:)?([^=]+)=(.+)$/);
|
||||
if (match) {
|
||||
data[match[2]] = match[3];
|
||||
}
|
||||
}
|
||||
|
||||
const result = parseExecApprovalData(data);
|
||||
expect(result).toEqual({ approvalId, action });
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiscordExecApprovalHandler.shouldHandle", () => {
|
||||
function createHandler(config: DiscordExecApprovalConfig) {
|
||||
return new DiscordExecApprovalHandler({
|
||||
token: "test-token",
|
||||
accountId: "default",
|
||||
config,
|
||||
cfg: {},
|
||||
});
|
||||
}
|
||||
|
||||
function createRequest(
|
||||
overrides: Partial<ExecApprovalRequest["request"]> = {},
|
||||
): ExecApprovalRequest {
|
||||
return {
|
||||
id: "test-id",
|
||||
request: {
|
||||
command: "echo hello",
|
||||
cwd: "/home/user",
|
||||
host: "gateway",
|
||||
agentId: "test-agent",
|
||||
sessionKey: "agent:test-agent:discord:123",
|
||||
...overrides,
|
||||
},
|
||||
createdAtMs: Date.now(),
|
||||
expiresAtMs: Date.now() + 60000,
|
||||
};
|
||||
}
|
||||
|
||||
it("returns false when disabled", () => {
|
||||
const handler = createHandler({ enabled: false, approvers: ["123"] });
|
||||
expect(handler.shouldHandle(createRequest())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when no approvers", () => {
|
||||
const handler = createHandler({ enabled: true, approvers: [] });
|
||||
expect(handler.shouldHandle(createRequest())).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true with minimal config", () => {
|
||||
const handler = createHandler({ enabled: true, approvers: ["123"] });
|
||||
expect(handler.shouldHandle(createRequest())).toBe(true);
|
||||
});
|
||||
|
||||
it("filters by agent ID", () => {
|
||||
const handler = createHandler({
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
agentFilter: ["allowed-agent"],
|
||||
});
|
||||
expect(handler.shouldHandle(createRequest({ agentId: "allowed-agent" }))).toBe(true);
|
||||
expect(handler.shouldHandle(createRequest({ agentId: "other-agent" }))).toBe(false);
|
||||
expect(handler.shouldHandle(createRequest({ agentId: null }))).toBe(false);
|
||||
});
|
||||
|
||||
it("filters by session key substring", () => {
|
||||
const handler = createHandler({
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
sessionFilter: ["discord"],
|
||||
});
|
||||
expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:discord:123" }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:telegram:123" }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(handler.shouldHandle(createRequest({ sessionKey: null }))).toBe(false);
|
||||
});
|
||||
|
||||
it("filters by session key regex", () => {
|
||||
const handler = createHandler({
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
sessionFilter: ["^agent:.*:discord:"],
|
||||
});
|
||||
expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:discord:123" }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(handler.shouldHandle(createRequest({ sessionKey: "other:test:discord:123" }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("combines agent and session filters", () => {
|
||||
const handler = createHandler({
|
||||
enabled: true,
|
||||
approvers: ["123"],
|
||||
agentFilter: ["my-agent"],
|
||||
sessionFilter: ["discord"],
|
||||
});
|
||||
expect(
|
||||
handler.shouldHandle(
|
||||
createRequest({
|
||||
agentId: "my-agent",
|
||||
sessionKey: "agent:my-agent:discord:123",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
handler.shouldHandle(
|
||||
createRequest({
|
||||
agentId: "other-agent",
|
||||
sessionKey: "agent:other:discord:123",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
handler.shouldHandle(
|
||||
createRequest({
|
||||
agentId: "my-agent",
|
||||
sessionKey: "agent:my-agent:telegram:123",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,549 @@
|
||||
import { Button, type ButtonInteraction, type ComponentData } from "@buape/carbon";
|
||||
import { ButtonStyle, Routes } from "discord-api-types/v10";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { GatewayClient } from "../../gateway/client.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
||||
import type { EventFrame } from "../../gateway/protocol/index.js";
|
||||
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
|
||||
import { createDiscordClient } from "../send.shared.js";
|
||||
import { logDebug, logError } from "../../logger.js";
|
||||
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
|
||||
const EXEC_APPROVAL_KEY = "execapproval";
|
||||
|
||||
export type ExecApprovalRequest = {
|
||||
id: string;
|
||||
request: {
|
||||
command: string;
|
||||
cwd?: string | null;
|
||||
host?: string | null;
|
||||
security?: string | null;
|
||||
ask?: string | null;
|
||||
agentId?: string | null;
|
||||
resolvedPath?: string | null;
|
||||
sessionKey?: string | null;
|
||||
};
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
export type ExecApprovalResolved = {
|
||||
id: string;
|
||||
decision: ExecApprovalDecision;
|
||||
resolvedBy?: string | null;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
type PendingApproval = {
|
||||
discordMessageId: string;
|
||||
discordChannelId: string;
|
||||
timeoutId: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
function encodeCustomIdValue(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function decodeCustomIdValue(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildExecApprovalCustomId(
|
||||
approvalId: string,
|
||||
action: ExecApprovalDecision,
|
||||
): string {
|
||||
return [`${EXEC_APPROVAL_KEY}:id=${encodeCustomIdValue(approvalId)}`, `action=${action}`].join(
|
||||
";",
|
||||
);
|
||||
}
|
||||
|
||||
export function parseExecApprovalData(
|
||||
data: ComponentData,
|
||||
): { approvalId: string; action: ExecApprovalDecision } | null {
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const coerce = (value: unknown) =>
|
||||
typeof value === "string" || typeof value === "number" ? String(value) : "";
|
||||
const rawId = coerce(data.id);
|
||||
const rawAction = coerce(data.action);
|
||||
if (!rawId || !rawAction) return null;
|
||||
const action = rawAction as ExecApprovalDecision;
|
||||
if (action !== "allow-once" && action !== "allow-always" && action !== "deny") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
approvalId: decodeCustomIdValue(rawId),
|
||||
action,
|
||||
};
|
||||
}
|
||||
|
||||
function formatExecApprovalEmbed(request: ExecApprovalRequest) {
|
||||
const commandText = request.request.command;
|
||||
const commandPreview =
|
||||
commandText.length > 1000 ? `${commandText.slice(0, 1000)}...` : commandText;
|
||||
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000));
|
||||
|
||||
const fields: Array<{ name: string; value: string; inline: boolean }> = [
|
||||
{
|
||||
name: "Command",
|
||||
value: `\`\`\`\n${commandPreview}\n\`\`\``,
|
||||
inline: false,
|
||||
},
|
||||
];
|
||||
|
||||
if (request.request.cwd) {
|
||||
fields.push({
|
||||
name: "Working Directory",
|
||||
value: request.request.cwd,
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (request.request.host) {
|
||||
fields.push({
|
||||
name: "Host",
|
||||
value: request.request.host,
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (request.request.agentId) {
|
||||
fields.push({
|
||||
name: "Agent",
|
||||
value: request.request.agentId,
|
||||
inline: true,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
title: "Exec Approval Required",
|
||||
description: "A command needs your approval.",
|
||||
color: 0xffa500, // Orange
|
||||
fields,
|
||||
footer: { text: `Expires in ${expiresIn}s | ID: ${request.id}` },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function formatResolvedEmbed(
|
||||
request: ExecApprovalRequest,
|
||||
decision: ExecApprovalDecision,
|
||||
resolvedBy?: string | null,
|
||||
) {
|
||||
const commandText = request.request.command;
|
||||
const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText;
|
||||
|
||||
const decisionLabel =
|
||||
decision === "allow-once"
|
||||
? "Allowed (once)"
|
||||
: decision === "allow-always"
|
||||
? "Allowed (always)"
|
||||
: "Denied";
|
||||
|
||||
const color = decision === "deny" ? 0xed4245 : decision === "allow-always" ? 0x5865f2 : 0x57f287;
|
||||
|
||||
return {
|
||||
title: `Exec Approval: ${decisionLabel}`,
|
||||
description: resolvedBy ? `Resolved by ${resolvedBy}` : "Resolved",
|
||||
color,
|
||||
fields: [
|
||||
{
|
||||
name: "Command",
|
||||
value: `\`\`\`\n${commandPreview}\n\`\`\``,
|
||||
inline: false,
|
||||
},
|
||||
],
|
||||
footer: { text: `ID: ${request.id}` },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function formatExpiredEmbed(request: ExecApprovalRequest) {
|
||||
const commandText = request.request.command;
|
||||
const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText;
|
||||
|
||||
return {
|
||||
title: "Exec Approval: Expired",
|
||||
description: "This approval request has expired.",
|
||||
color: 0x99aab5, // Gray
|
||||
fields: [
|
||||
{
|
||||
name: "Command",
|
||||
value: `\`\`\`\n${commandPreview}\n\`\`\``,
|
||||
inline: false,
|
||||
},
|
||||
],
|
||||
footer: { text: `ID: ${request.id}` },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export type DiscordExecApprovalHandlerOpts = {
|
||||
token: string;
|
||||
accountId: string;
|
||||
config: DiscordExecApprovalConfig;
|
||||
gatewayUrl?: string;
|
||||
cfg: ClawdbotConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
onResolve?: (id: string, decision: ExecApprovalDecision) => Promise<void>;
|
||||
};
|
||||
|
||||
export class DiscordExecApprovalHandler {
|
||||
private gatewayClient: GatewayClient | null = null;
|
||||
private pending = new Map<string, PendingApproval>();
|
||||
private requestCache = new Map<string, ExecApprovalRequest>();
|
||||
private opts: DiscordExecApprovalHandlerOpts;
|
||||
private started = false;
|
||||
|
||||
constructor(opts: DiscordExecApprovalHandlerOpts) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
shouldHandle(request: ExecApprovalRequest): boolean {
|
||||
const config = this.opts.config;
|
||||
if (!config.enabled) return false;
|
||||
if (!config.approvers || config.approvers.length === 0) return false;
|
||||
|
||||
// Check agent filter
|
||||
if (config.agentFilter?.length) {
|
||||
if (!request.request.agentId) return false;
|
||||
if (!config.agentFilter.includes(request.request.agentId)) return false;
|
||||
}
|
||||
|
||||
// Check session filter (substring match)
|
||||
if (config.sessionFilter?.length) {
|
||||
const session = request.request.sessionKey;
|
||||
if (!session) return false;
|
||||
const matches = config.sessionFilter.some((p) => {
|
||||
try {
|
||||
return session.includes(p) || new RegExp(p).test(session);
|
||||
} catch {
|
||||
return session.includes(p);
|
||||
}
|
||||
});
|
||||
if (!matches) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.started) return;
|
||||
this.started = true;
|
||||
|
||||
const config = this.opts.config;
|
||||
if (!config.enabled) {
|
||||
logDebug("discord exec approvals: disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.approvers || config.approvers.length === 0) {
|
||||
logDebug("discord exec approvals: no approvers configured");
|
||||
return;
|
||||
}
|
||||
|
||||
logDebug("discord exec approvals: starting handler");
|
||||
|
||||
this.gatewayClient = new GatewayClient({
|
||||
url: this.opts.gatewayUrl ?? "ws://127.0.0.1:18789",
|
||||
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||
clientDisplayName: "Discord Exec Approvals",
|
||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||
scopes: ["operator.approvals"],
|
||||
onEvent: (evt) => this.handleGatewayEvent(evt),
|
||||
onHelloOk: () => {
|
||||
logDebug("discord exec approvals: connected to gateway");
|
||||
},
|
||||
onConnectError: (err) => {
|
||||
logError(`discord exec approvals: connect error: ${err.message}`);
|
||||
},
|
||||
onClose: (code, reason) => {
|
||||
logDebug(`discord exec approvals: gateway closed: ${code} ${reason}`);
|
||||
},
|
||||
});
|
||||
|
||||
this.gatewayClient.start();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (!this.started) return;
|
||||
this.started = false;
|
||||
|
||||
// Clear all pending timeouts
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.requestCache.clear();
|
||||
|
||||
this.gatewayClient?.stop();
|
||||
this.gatewayClient = null;
|
||||
|
||||
logDebug("discord exec approvals: stopped");
|
||||
}
|
||||
|
||||
private handleGatewayEvent(evt: EventFrame): void {
|
||||
if (evt.event === "exec.approval.requested") {
|
||||
const request = evt.payload as ExecApprovalRequest;
|
||||
void this.handleApprovalRequested(request);
|
||||
} else if (evt.event === "exec.approval.resolved") {
|
||||
const resolved = evt.payload as ExecApprovalResolved;
|
||||
void this.handleApprovalResolved(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleApprovalRequested(request: ExecApprovalRequest): Promise<void> {
|
||||
if (!this.shouldHandle(request)) return;
|
||||
|
||||
logDebug(`discord exec approvals: received request ${request.id}`);
|
||||
|
||||
this.requestCache.set(request.id, request);
|
||||
|
||||
const { rest, request: discordRequest } = createDiscordClient(
|
||||
{ token: this.opts.token, accountId: this.opts.accountId },
|
||||
this.opts.cfg,
|
||||
);
|
||||
|
||||
const embed = formatExecApprovalEmbed(request);
|
||||
|
||||
// Build action rows with buttons
|
||||
const components = [
|
||||
{
|
||||
type: 1, // ACTION_ROW
|
||||
components: [
|
||||
{
|
||||
type: 2, // BUTTON
|
||||
style: ButtonStyle.Success,
|
||||
label: "Allow once",
|
||||
custom_id: buildExecApprovalCustomId(request.id, "allow-once"),
|
||||
},
|
||||
{
|
||||
type: 2, // BUTTON
|
||||
style: ButtonStyle.Primary,
|
||||
label: "Always allow",
|
||||
custom_id: buildExecApprovalCustomId(request.id, "allow-always"),
|
||||
},
|
||||
{
|
||||
type: 2, // BUTTON
|
||||
style: ButtonStyle.Danger,
|
||||
label: "Deny",
|
||||
custom_id: buildExecApprovalCustomId(request.id, "deny"),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const approvers = this.opts.config.approvers ?? [];
|
||||
|
||||
for (const approver of approvers) {
|
||||
const userId = String(approver);
|
||||
try {
|
||||
// Create DM channel
|
||||
const dmChannel = (await discordRequest(
|
||||
() =>
|
||||
rest.post(Routes.userChannels(), {
|
||||
body: { recipient_id: userId },
|
||||
}) as Promise<{ id: string }>,
|
||||
"dm-channel",
|
||||
)) as { id: string };
|
||||
|
||||
if (!dmChannel?.id) {
|
||||
logError(`discord exec approvals: failed to create DM for user ${userId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send message with embed and buttons
|
||||
const message = (await discordRequest(
|
||||
() =>
|
||||
rest.post(Routes.channelMessages(dmChannel.id), {
|
||||
body: {
|
||||
embeds: [embed],
|
||||
components,
|
||||
},
|
||||
}) as Promise<{ id: string; channel_id: string }>,
|
||||
"send-approval",
|
||||
)) as { id: string; channel_id: string };
|
||||
|
||||
if (!message?.id) {
|
||||
logError(`discord exec approvals: failed to send message to user ${userId}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set up timeout
|
||||
const timeoutMs = Math.max(0, request.expiresAtMs - Date.now());
|
||||
const timeoutId = setTimeout(() => {
|
||||
void this.handleApprovalTimeout(request.id);
|
||||
}, timeoutMs);
|
||||
|
||||
this.pending.set(request.id, {
|
||||
discordMessageId: message.id,
|
||||
discordChannelId: dmChannel.id,
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
logDebug(`discord exec approvals: sent approval ${request.id} to user ${userId}`);
|
||||
} catch (err) {
|
||||
logError(`discord exec approvals: failed to notify user ${userId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleApprovalResolved(resolved: ExecApprovalResolved): Promise<void> {
|
||||
const pending = this.pending.get(resolved.id);
|
||||
if (!pending) return;
|
||||
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pending.delete(resolved.id);
|
||||
|
||||
const request = this.requestCache.get(resolved.id);
|
||||
this.requestCache.delete(resolved.id);
|
||||
|
||||
if (!request) return;
|
||||
|
||||
logDebug(`discord exec approvals: resolved ${resolved.id} with ${resolved.decision}`);
|
||||
|
||||
await this.updateMessage(
|
||||
pending.discordChannelId,
|
||||
pending.discordMessageId,
|
||||
formatResolvedEmbed(request, resolved.decision, resolved.resolvedBy),
|
||||
);
|
||||
}
|
||||
|
||||
private async handleApprovalTimeout(approvalId: string): Promise<void> {
|
||||
const pending = this.pending.get(approvalId);
|
||||
if (!pending) return;
|
||||
|
||||
this.pending.delete(approvalId);
|
||||
|
||||
const request = this.requestCache.get(approvalId);
|
||||
this.requestCache.delete(approvalId);
|
||||
|
||||
if (!request) return;
|
||||
|
||||
logDebug(`discord exec approvals: timeout for ${approvalId}`);
|
||||
|
||||
await this.updateMessage(
|
||||
pending.discordChannelId,
|
||||
pending.discordMessageId,
|
||||
formatExpiredEmbed(request),
|
||||
);
|
||||
}
|
||||
|
||||
private async updateMessage(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
embed: ReturnType<typeof formatExpiredEmbed>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { rest, request: discordRequest } = createDiscordClient(
|
||||
{ token: this.opts.token, accountId: this.opts.accountId },
|
||||
this.opts.cfg,
|
||||
);
|
||||
|
||||
await discordRequest(
|
||||
() =>
|
||||
rest.patch(Routes.channelMessage(channelId, messageId), {
|
||||
body: {
|
||||
embeds: [embed],
|
||||
components: [], // Remove buttons
|
||||
},
|
||||
}),
|
||||
"update-approval",
|
||||
);
|
||||
} catch (err) {
|
||||
logError(`discord exec approvals: failed to update message: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async resolveApproval(approvalId: string, decision: ExecApprovalDecision): Promise<boolean> {
|
||||
if (!this.gatewayClient) {
|
||||
logError("discord exec approvals: gateway client not connected");
|
||||
return false;
|
||||
}
|
||||
|
||||
logDebug(`discord exec approvals: resolving ${approvalId} with ${decision}`);
|
||||
|
||||
try {
|
||||
await this.gatewayClient.request("exec.approval.resolve", {
|
||||
id: approvalId,
|
||||
decision,
|
||||
});
|
||||
logDebug(`discord exec approvals: resolved ${approvalId} successfully`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logError(`discord exec approvals: resolve failed: ${String(err)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ExecApprovalButtonContext = {
|
||||
handler: DiscordExecApprovalHandler;
|
||||
};
|
||||
|
||||
export class ExecApprovalButton extends Button {
|
||||
label = "execapproval";
|
||||
customId = `${EXEC_APPROVAL_KEY}:seed=1`;
|
||||
style = ButtonStyle.Primary;
|
||||
private ctx: ExecApprovalButtonContext;
|
||||
|
||||
constructor(ctx: ExecApprovalButtonContext) {
|
||||
super();
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
|
||||
const parsed = parseExecApprovalData(data);
|
||||
if (!parsed) {
|
||||
try {
|
||||
await interaction.update({
|
||||
content: "This approval is no longer valid.",
|
||||
components: [],
|
||||
});
|
||||
} catch {
|
||||
// Interaction may have expired
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const decisionLabel =
|
||||
parsed.action === "allow-once"
|
||||
? "Allowed (once)"
|
||||
: parsed.action === "allow-always"
|
||||
? "Allowed (always)"
|
||||
: "Denied";
|
||||
|
||||
// Update the message immediately to show the decision
|
||||
try {
|
||||
await interaction.update({
|
||||
content: `Submitting decision: **${decisionLabel}**...`,
|
||||
components: [], // Remove buttons
|
||||
});
|
||||
} catch {
|
||||
// Interaction may have expired, try to continue anyway
|
||||
}
|
||||
|
||||
const ok = await this.ctx.handler.resolveApproval(parsed.approvalId, parsed.action);
|
||||
|
||||
if (!ok) {
|
||||
try {
|
||||
await interaction.followUp({
|
||||
content:
|
||||
"Failed to submit approval decision. The request may have expired or already been resolved.",
|
||||
ephemeral: true,
|
||||
});
|
||||
} catch {
|
||||
// Interaction may have expired
|
||||
}
|
||||
}
|
||||
// On success, the handleApprovalResolved event will update the message with the final result
|
||||
}
|
||||
}
|
||||
|
||||
export function createExecApprovalButton(ctx: ExecApprovalButtonContext): Button {
|
||||
return new ExecApprovalButton(ctx);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { inspect } from "node:util";
|
||||
import { Client } from "@buape/carbon";
|
||||
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
createDiscordCommandArgFallbackButton,
|
||||
createDiscordNativeCommand,
|
||||
} from "./native-command.js";
|
||||
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
|
||||
|
||||
export type MonitorDiscordOpts = {
|
||||
token?: string;
|
||||
@@ -95,13 +97,8 @@ function formatDiscordDeployErrorDetails(err: unknown): string {
|
||||
try {
|
||||
bodyText = JSON.stringify(rawBody);
|
||||
} catch {
|
||||
if (typeof rawBody === "string") {
|
||||
bodyText = rawBody;
|
||||
} else if (rawBody instanceof Error) {
|
||||
bodyText = rawBody.message;
|
||||
} else {
|
||||
bodyText = "[unserializable]";
|
||||
}
|
||||
bodyText =
|
||||
typeof rawBody === "string" ? rawBody : inspect(rawBody, { depth: 3, breakLength: 120 });
|
||||
}
|
||||
if (bodyText) {
|
||||
const maxLen = 800;
|
||||
@@ -410,6 +407,31 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
}),
|
||||
);
|
||||
|
||||
// Initialize exec approvals handler if enabled
|
||||
const execApprovalsConfig = discordCfg.execApprovals ?? {};
|
||||
const execApprovalsHandler = execApprovalsConfig.enabled
|
||||
? new DiscordExecApprovalHandler({
|
||||
token,
|
||||
accountId: account.accountId,
|
||||
config: execApprovalsConfig,
|
||||
cfg,
|
||||
runtime,
|
||||
})
|
||||
: null;
|
||||
|
||||
const components = [
|
||||
createDiscordCommandArgFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
];
|
||||
|
||||
if (execApprovalsHandler) {
|
||||
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
|
||||
}
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
baseUrl: "http://localhost",
|
||||
@@ -422,14 +444,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
{
|
||||
commands,
|
||||
listeners: [],
|
||||
components: [
|
||||
createDiscordCommandArgFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
],
|
||||
components,
|
||||
},
|
||||
[
|
||||
new GatewayPlugin({
|
||||
@@ -514,6 +529,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
|
||||
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
|
||||
|
||||
// Start exec approvals handler after client is ready
|
||||
if (execApprovalsHandler) {
|
||||
await execApprovalsHandler.start();
|
||||
}
|
||||
|
||||
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
|
||||
const stopGatewayLogging = attachDiscordGatewayLogging({
|
||||
@@ -579,6 +599,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
if (helloTimeoutId) clearTimeout(helloTimeoutId);
|
||||
gatewayEmitter?.removeListener("debug", onGatewayDebug);
|
||||
abortSignal?.removeEventListener("abort", onAbort);
|
||||
if (execApprovalsHandler) {
|
||||
await execApprovalsHandler.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { stripEnvelopeFromMessage } from "./chat-sanitize.js";
|
||||
|
||||
describe("stripEnvelopeFromMessage", () => {
|
||||
test("removes message_id hint lines from user messages", () => {
|
||||
const input = {
|
||||
role: "user",
|
||||
content: "[WhatsApp 2026-01-24 13:36] yolo\n[message_id: 7b8b]",
|
||||
};
|
||||
const result = stripEnvelopeFromMessage(input) as { content?: string };
|
||||
expect(result.content).toBe("yolo");
|
||||
});
|
||||
|
||||
test("removes message_id hint lines from text content arrays", () => {
|
||||
const input = {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "hi\n[message_id: abc123]" }],
|
||||
};
|
||||
const result = stripEnvelopeFromMessage(input) as {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
expect(result.content?.[0]?.text).toBe("hi");
|
||||
});
|
||||
|
||||
test("does not strip inline message_id text that is part of a line", () => {
|
||||
const input = {
|
||||
role: "user",
|
||||
content: "I typed [message_id: 123] on purpose",
|
||||
};
|
||||
const result = stripEnvelopeFromMessage(input) as { content?: string };
|
||||
expect(result.content).toBe("I typed [message_id: 123] on purpose");
|
||||
});
|
||||
|
||||
test("does not strip assistant messages", () => {
|
||||
const input = {
|
||||
role: "assistant",
|
||||
content: "note\n[message_id: 123]",
|
||||
};
|
||||
const result = stripEnvelopeFromMessage(input) as { content?: string };
|
||||
expect(result.content).toBe("note\n[message_id: 123]");
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,8 @@ const ENVELOPE_CHANNELS = [
|
||||
"BlueBubbles",
|
||||
];
|
||||
|
||||
const MESSAGE_ID_LINE = /^\s*\[message_id:\s*[^\]]+\]\s*$/i;
|
||||
|
||||
function looksLikeEnvelopeHeader(header: string): boolean {
|
||||
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true;
|
||||
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true;
|
||||
@@ -28,13 +30,20 @@ export function stripEnvelope(text: string): string {
|
||||
return text.slice(match[0].length);
|
||||
}
|
||||
|
||||
function stripMessageIdHints(text: string): string {
|
||||
if (!text.includes("[message_id:")) return text;
|
||||
const lines = text.split(/\r?\n/);
|
||||
const filtered = lines.filter((line) => !MESSAGE_ID_LINE.test(line));
|
||||
return filtered.length === lines.length ? text : filtered.join("\n");
|
||||
}
|
||||
|
||||
function stripEnvelopeFromContent(content: unknown[]): { content: unknown[]; changed: boolean } {
|
||||
let changed = false;
|
||||
const next = content.map((item) => {
|
||||
if (!item || typeof item !== "object") return item;
|
||||
const entry = item as Record<string, unknown>;
|
||||
if (entry.type !== "text" || typeof entry.text !== "string") return item;
|
||||
const stripped = stripEnvelope(entry.text);
|
||||
const stripped = stripMessageIdHints(stripEnvelope(entry.text));
|
||||
if (stripped === entry.text) return item;
|
||||
changed = true;
|
||||
return {
|
||||
@@ -55,7 +64,7 @@ export function stripEnvelopeFromMessage(message: unknown): unknown {
|
||||
const next: Record<string, unknown> = { ...entry };
|
||||
|
||||
if (typeof entry.content === "string") {
|
||||
const stripped = stripEnvelope(entry.content);
|
||||
const stripped = stripMessageIdHints(stripEnvelope(entry.content));
|
||||
if (stripped !== entry.content) {
|
||||
next.content = stripped;
|
||||
changed = true;
|
||||
@@ -67,7 +76,7 @@ export function stripEnvelopeFromMessage(message: unknown): unknown {
|
||||
changed = true;
|
||||
}
|
||||
} else if (typeof entry.text === "string") {
|
||||
const stripped = stripEnvelope(entry.text);
|
||||
const stripped = stripMessageIdHints(stripEnvelope(entry.text));
|
||||
if (stripped !== entry.text) {
|
||||
next.text = stripped;
|
||||
changed = true;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
|
||||
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
|
||||
import type { ExecApprovalManager } from "../exec-approval-manager.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
@@ -9,7 +10,10 @@ import {
|
||||
} from "../protocol/index.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
export function createExecApprovalHandlers(manager: ExecApprovalManager): GatewayRequestHandlers {
|
||||
export function createExecApprovalHandlers(
|
||||
manager: ExecApprovalManager,
|
||||
opts?: { forwarder?: ExecApprovalForwarder },
|
||||
): GatewayRequestHandlers {
|
||||
return {
|
||||
"exec.approval.request": async ({ params, respond, context }) => {
|
||||
if (!validateExecApprovalRequestParams(params)) {
|
||||
@@ -69,6 +73,16 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa
|
||||
},
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
void opts?.forwarder
|
||||
?.handleRequested({
|
||||
id: record.id,
|
||||
request: record.request,
|
||||
createdAtMs: record.createdAtMs,
|
||||
expiresAtMs: record.expiresAtMs,
|
||||
})
|
||||
.catch((err) => {
|
||||
context.logGateway?.error?.(`exec approvals: forward request failed: ${String(err)}`);
|
||||
});
|
||||
const decision = await decisionPromise;
|
||||
respond(
|
||||
true,
|
||||
@@ -112,6 +126,11 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa
|
||||
{ id: p.id, decision, resolvedBy, ts: Date.now() },
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
void opts?.forwarder
|
||||
?.handleResolved({ id: p.id, decision, resolvedBy, ts: Date.now() })
|
||||
.catch((err) => {
|
||||
context.logGateway?.error?.(`exec approvals: forward resolve failed: ${String(err)}`);
|
||||
});
|
||||
respond(true, { ok: true }, undefined);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -468,7 +468,10 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
error: p.error ?? null,
|
||||
});
|
||||
if (!ok) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown invoke id"));
|
||||
// Late-arriving results (after invoke timeout) are expected and harmless.
|
||||
// Return success instead of error to reduce log noise; client can discard.
|
||||
context.logGateway.debug(`late invoke result ignored: id=${p.id} node=${p.nodeId}`);
|
||||
respond(true, { ok: true, ignored: true }, undefined);
|
||||
return;
|
||||
}
|
||||
respond(true, { ok: true }, undefined);
|
||||
|
||||
@@ -137,6 +137,34 @@ describe("gateway send mirroring", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lowercases provided session keys for mirroring", async () => {
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m-lower", channel: "slack" }]);
|
||||
|
||||
const respond = vi.fn();
|
||||
await sendHandlers.send({
|
||||
params: {
|
||||
to: "channel:C1",
|
||||
message: "hi",
|
||||
channel: "slack",
|
||||
idempotencyKey: "idem-lower",
|
||||
sessionKey: "agent:main:slack:channel:C123",
|
||||
},
|
||||
respond,
|
||||
context: makeContext(),
|
||||
req: { type: "req", id: "1", method: "send" },
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mirror: expect.objectContaining({
|
||||
sessionKey: "agent:main:slack:channel:c123",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("derives a target session key when none is provided", async () => {
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m3", channel: "slack" }]);
|
||||
|
||||
|
||||
@@ -145,7 +145,7 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
const providedSessionKey =
|
||||
typeof request.sessionKey === "string" && request.sessionKey.trim()
|
||||
? request.sessionKey.trim()
|
||||
? request.sessionKey.trim().toLowerCase()
|
||||
: undefined;
|
||||
const derivedAgentId = resolveSessionAgentId({ config: cfg });
|
||||
// If callers omit sessionKey, derive a target session key from the outbound route.
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import { startGatewayDiscovery } from "./server-discovery-runtime.js";
|
||||
import { ExecApprovalManager } from "./exec-approval-manager.js";
|
||||
import { createExecApprovalHandlers } from "./server-methods/exec-approval.js";
|
||||
import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js";
|
||||
import type { startBrowserControlServerIfEnabled } from "./server-browser.js";
|
||||
import { createChannelManager } from "./server-channels.js";
|
||||
import { createAgentEventHandler } from "./server-chat.js";
|
||||
@@ -344,9 +345,19 @@ export async function startGatewayServer(
|
||||
|
||||
setSkillsRemoteRegistry(nodeRegistry);
|
||||
void primeRemoteSkillsCache();
|
||||
registerSkillsChangeListener(() => {
|
||||
const latest = loadConfig();
|
||||
void refreshRemoteBinsForConnectedNodes(latest);
|
||||
// Debounce skills-triggered node probes to avoid feedback loops and rapid-fire invokes.
|
||||
// Skills changes can happen in bursts (e.g., file watcher events), and each probe
|
||||
// takes time to complete. A 30-second delay ensures we batch changes together.
|
||||
let skillsRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const skillsRefreshDelayMs = 30_000;
|
||||
const skillsChangeUnsub = registerSkillsChangeListener((event) => {
|
||||
if (event.reason === "remote-node") return;
|
||||
if (skillsRefreshTimer) clearTimeout(skillsRefreshTimer);
|
||||
skillsRefreshTimer = setTimeout(() => {
|
||||
skillsRefreshTimer = null;
|
||||
const latest = loadConfig();
|
||||
void refreshRemoteBinsForConnectedNodes(latest);
|
||||
}, skillsRefreshDelayMs);
|
||||
});
|
||||
|
||||
const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({
|
||||
@@ -386,7 +397,10 @@ export async function startGatewayServer(
|
||||
void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`));
|
||||
|
||||
const execApprovalManager = new ExecApprovalManager();
|
||||
const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager);
|
||||
const execApprovalForwarder = createExecApprovalForwarder();
|
||||
const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager, {
|
||||
forwarder: execApprovalForwarder,
|
||||
});
|
||||
|
||||
const canvasHostServerPort = (canvasHostServer as CanvasHostServer | null)?.port;
|
||||
|
||||
@@ -544,6 +558,11 @@ export async function startGatewayServer(
|
||||
if (diagnosticsEnabled) {
|
||||
stopDiagnosticHeartbeat();
|
||||
}
|
||||
if (skillsRefreshTimer) {
|
||||
clearTimeout(skillsRefreshTimer);
|
||||
skillsRefreshTimer = null;
|
||||
}
|
||||
skillsChangeUnsub();
|
||||
await close(opts);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||
|
||||
vi.mock("../infra/update-runner.js", () => ({
|
||||
runGatewayUpdate: vi.fn(async () => ({
|
||||
status: "ok",
|
||||
mode: "git",
|
||||
root: "/repo",
|
||||
steps: [],
|
||||
durationMs: 12,
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
|
||||
let ws: WebSocket;
|
||||
let port: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startServerWithClient();
|
||||
server = started.server;
|
||||
ws = started.ws;
|
||||
port = started.port;
|
||||
await connectOk(ws);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
describe("late-arriving invoke results", () => {
|
||||
test("returns success for unknown invoke id (late arrival after timeout)", async () => {
|
||||
// Create a node client WebSocket
|
||||
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
|
||||
|
||||
try {
|
||||
// Connect as a node with device identity
|
||||
const identity = loadOrCreateDeviceIdentity();
|
||||
const nodeId = identity.deviceId;
|
||||
|
||||
await connectOk(nodeWs, {
|
||||
role: "node",
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
version: "1.0.0",
|
||||
platform: "ios",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
},
|
||||
commands: ["canvas.snapshot"],
|
||||
});
|
||||
|
||||
// Send an invoke result with an unknown ID (simulating late arrival after timeout)
|
||||
const result = await rpcReq<{ ok?: boolean; ignored?: boolean }>(
|
||||
nodeWs,
|
||||
"node.invoke.result",
|
||||
{
|
||||
id: "unknown-invoke-id-12345",
|
||||
nodeId,
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({ result: "late" }),
|
||||
},
|
||||
);
|
||||
|
||||
// Late-arriving results return success instead of error to reduce log noise
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.payload?.ok).toBe(true);
|
||||
expect(result.payload?.ignored).toBe(true);
|
||||
} finally {
|
||||
nodeWs.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("returns success for unknown invoke id with error payload", async () => {
|
||||
// Verifies late results are accepted regardless of their ok/error status
|
||||
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
|
||||
|
||||
try {
|
||||
await connectOk(nodeWs, {
|
||||
role: "node",
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.NODE_HOST,
|
||||
version: "1.0.0",
|
||||
platform: "darwin",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
},
|
||||
commands: [],
|
||||
});
|
||||
|
||||
const identity = loadOrCreateDeviceIdentity();
|
||||
const nodeId = identity.deviceId;
|
||||
|
||||
// Late invoke result with error payload - should still return success
|
||||
const result = await rpcReq<{ ok?: boolean; ignored?: boolean }>(
|
||||
nodeWs,
|
||||
"node.invoke.result",
|
||||
{
|
||||
id: "another-unknown-invoke-id",
|
||||
nodeId,
|
||||
ok: false,
|
||||
error: { code: "FAILED", message: "test error" },
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.payload?.ok).toBe(true);
|
||||
expect(result.payload?.ignored).toBe(true);
|
||||
} finally {
|
||||
nodeWs.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { createExecApprovalForwarder } from "./exec-approval-forwarder.js";
|
||||
|
||||
const baseRequest = {
|
||||
id: "req-1",
|
||||
request: {
|
||||
command: "echo hello",
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
},
|
||||
createdAtMs: 1000,
|
||||
expiresAtMs: 6000,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("exec approval forwarder", () => {
|
||||
it("forwards to session target and resolves", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const cfg = {
|
||||
approvals: { exec: { enabled: true, mode: "session" } },
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const forwarder = createExecApprovalForwarder({
|
||||
getConfig: () => cfg,
|
||||
deliver,
|
||||
nowMs: () => 1000,
|
||||
resolveSessionTarget: () => ({ channel: "slack", to: "U1" }),
|
||||
});
|
||||
|
||||
await forwarder.handleRequested(baseRequest);
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
|
||||
await forwarder.handleResolved({
|
||||
id: baseRequest.id,
|
||||
decision: "allow-once",
|
||||
resolvedBy: "slack:U1",
|
||||
ts: 2000,
|
||||
});
|
||||
expect(deliver).toHaveBeenCalledTimes(2);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
expect(deliver).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("forwards to explicit targets and expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deliver = vi.fn().mockResolvedValue([]);
|
||||
const cfg = {
|
||||
approvals: {
|
||||
exec: {
|
||||
enabled: true,
|
||||
mode: "targets",
|
||||
targets: [{ channel: "telegram", to: "123" }],
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const forwarder = createExecApprovalForwarder({
|
||||
getConfig: () => cfg,
|
||||
deliver,
|
||||
nowMs: () => 1000,
|
||||
resolveSessionTarget: () => null,
|
||||
});
|
||||
|
||||
await forwarder.handleRequested(baseRequest);
|
||||
expect(deliver).toHaveBeenCalledTimes(1);
|
||||
|
||||
await vi.runAllTimersAsync();
|
||||
expect(deliver).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||
import type {
|
||||
ExecApprovalForwardingConfig,
|
||||
ExecApprovalForwardTarget,
|
||||
} from "../config/types.approvals.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import type { ExecApprovalDecision } from "./exec-approvals.js";
|
||||
import { deliverOutboundPayloads } from "./outbound/deliver.js";
|
||||
import { resolveSessionDeliveryTarget } from "./outbound/targets.js";
|
||||
|
||||
const log = createSubsystemLogger("gateway/exec-approvals");
|
||||
|
||||
export type ExecApprovalRequest = {
|
||||
id: string;
|
||||
request: {
|
||||
command: string;
|
||||
cwd?: string | null;
|
||||
host?: string | null;
|
||||
security?: string | null;
|
||||
ask?: string | null;
|
||||
agentId?: string | null;
|
||||
resolvedPath?: string | null;
|
||||
sessionKey?: string | null;
|
||||
};
|
||||
createdAtMs: number;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
|
||||
export type ExecApprovalResolved = {
|
||||
id: string;
|
||||
decision: ExecApprovalDecision;
|
||||
resolvedBy?: string | null;
|
||||
ts: number;
|
||||
};
|
||||
|
||||
type ForwardTarget = ExecApprovalForwardTarget & { source: "session" | "target" };
|
||||
|
||||
type PendingApproval = {
|
||||
request: ExecApprovalRequest;
|
||||
targets: ForwardTarget[];
|
||||
timeoutId: NodeJS.Timeout | null;
|
||||
};
|
||||
|
||||
export type ExecApprovalForwarder = {
|
||||
handleRequested: (request: ExecApprovalRequest) => Promise<void>;
|
||||
handleResolved: (resolved: ExecApprovalResolved) => Promise<void>;
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export type ExecApprovalForwarderDeps = {
|
||||
getConfig?: () => ClawdbotConfig;
|
||||
deliver?: typeof deliverOutboundPayloads;
|
||||
nowMs?: () => number;
|
||||
resolveSessionTarget?: (params: {
|
||||
cfg: ClawdbotConfig;
|
||||
request: ExecApprovalRequest;
|
||||
}) => ExecApprovalForwardTarget | null;
|
||||
};
|
||||
|
||||
const DEFAULT_MODE = "session" as const;
|
||||
|
||||
function normalizeMode(mode?: ExecApprovalForwardingConfig["mode"]) {
|
||||
return mode ?? DEFAULT_MODE;
|
||||
}
|
||||
|
||||
function matchSessionFilter(sessionKey: string, patterns: string[]): boolean {
|
||||
return patterns.some((pattern) => {
|
||||
try {
|
||||
return sessionKey.includes(pattern) || new RegExp(pattern).test(sessionKey);
|
||||
} catch {
|
||||
return sessionKey.includes(pattern);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function shouldForward(params: {
|
||||
config?: ExecApprovalForwardingConfig;
|
||||
request: ExecApprovalRequest;
|
||||
}): boolean {
|
||||
const config = params.config;
|
||||
if (!config?.enabled) return false;
|
||||
if (config.agentFilter?.length) {
|
||||
const agentId =
|
||||
params.request.request.agentId ??
|
||||
parseAgentSessionKey(params.request.request.sessionKey)?.agentId;
|
||||
if (!agentId) return false;
|
||||
if (!config.agentFilter.includes(agentId)) return false;
|
||||
}
|
||||
if (config.sessionFilter?.length) {
|
||||
const sessionKey = params.request.request.sessionKey;
|
||||
if (!sessionKey) return false;
|
||||
if (!matchSessionFilter(sessionKey, config.sessionFilter)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildTargetKey(target: ExecApprovalForwardTarget): string {
|
||||
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
||||
const accountId = target.accountId ?? "";
|
||||
const threadId = target.threadId ?? "";
|
||||
return [channel, target.to, accountId, threadId].join(":");
|
||||
}
|
||||
|
||||
function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) {
|
||||
const lines: string[] = ["🔒 Exec approval required", `ID: ${request.id}`];
|
||||
lines.push(`Command: ${request.request.command}`);
|
||||
if (request.request.cwd) lines.push(`CWD: ${request.request.cwd}`);
|
||||
if (request.request.host) lines.push(`Host: ${request.request.host}`);
|
||||
if (request.request.agentId) lines.push(`Agent: ${request.request.agentId}`);
|
||||
if (request.request.security) lines.push(`Security: ${request.request.security}`);
|
||||
if (request.request.ask) lines.push(`Ask: ${request.request.ask}`);
|
||||
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1000));
|
||||
lines.push(`Expires in: ${expiresIn}s`);
|
||||
lines.push("Reply with: /approve <id> allow-once|allow-always|deny");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function decisionLabel(decision: ExecApprovalDecision): string {
|
||||
if (decision === "allow-once") return "allowed once";
|
||||
if (decision === "allow-always") return "allowed always";
|
||||
return "denied";
|
||||
}
|
||||
|
||||
function buildResolvedMessage(resolved: ExecApprovalResolved) {
|
||||
const base = `✅ Exec approval ${decisionLabel(resolved.decision)}.`;
|
||||
const by = resolved.resolvedBy ? ` Resolved by ${resolved.resolvedBy}.` : "";
|
||||
return `${base}${by} ID: ${resolved.id}`;
|
||||
}
|
||||
|
||||
function buildExpiredMessage(request: ExecApprovalRequest) {
|
||||
return `⏱️ Exec approval expired. ID: ${request.id}`;
|
||||
}
|
||||
|
||||
function defaultResolveSessionTarget(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
request: ExecApprovalRequest;
|
||||
}): ExecApprovalForwardTarget | null {
|
||||
const sessionKey = params.request.request.sessionKey?.trim();
|
||||
if (!sessionKey) return null;
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
const agentId = parsed?.agentId ?? params.request.request.agentId ?? "main";
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
|
||||
const store = loadSessionStore(storePath);
|
||||
const entry = store[sessionKey];
|
||||
if (!entry) return null;
|
||||
const target = resolveSessionDeliveryTarget({ entry, requestedChannel: "last" });
|
||||
if (!target.channel || !target.to) return null;
|
||||
if (!isDeliverableMessageChannel(target.channel)) return null;
|
||||
return {
|
||||
channel: target.channel,
|
||||
to: target.to,
|
||||
accountId: target.accountId,
|
||||
threadId: target.threadId,
|
||||
};
|
||||
}
|
||||
|
||||
async function deliverToTargets(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
targets: ForwardTarget[];
|
||||
text: string;
|
||||
deliver: typeof deliverOutboundPayloads;
|
||||
shouldSend?: () => boolean;
|
||||
}) {
|
||||
const deliveries = params.targets.map(async (target) => {
|
||||
if (params.shouldSend && !params.shouldSend()) return;
|
||||
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
|
||||
if (!isDeliverableMessageChannel(channel)) return;
|
||||
try {
|
||||
await params.deliver({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
to: target.to,
|
||||
accountId: target.accountId,
|
||||
threadId: target.threadId,
|
||||
payloads: [{ text: params.text }],
|
||||
});
|
||||
} catch (err) {
|
||||
log.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(err)}`);
|
||||
}
|
||||
});
|
||||
await Promise.allSettled(deliveries);
|
||||
}
|
||||
|
||||
export function createExecApprovalForwarder(
|
||||
deps: ExecApprovalForwarderDeps = {},
|
||||
): ExecApprovalForwarder {
|
||||
const getConfig = deps.getConfig ?? loadConfig;
|
||||
const deliver = deps.deliver ?? deliverOutboundPayloads;
|
||||
const nowMs = deps.nowMs ?? Date.now;
|
||||
const resolveSessionTarget = deps.resolveSessionTarget ?? defaultResolveSessionTarget;
|
||||
const pending = new Map<string, PendingApproval>();
|
||||
|
||||
const handleRequested = async (request: ExecApprovalRequest) => {
|
||||
const cfg = getConfig();
|
||||
const config = cfg.approvals?.exec;
|
||||
if (!shouldForward({ config, request })) return;
|
||||
|
||||
const mode = normalizeMode(config?.mode);
|
||||
const targets: ForwardTarget[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
if (mode === "session" || mode === "both") {
|
||||
const sessionTarget = resolveSessionTarget({ cfg, request });
|
||||
if (sessionTarget) {
|
||||
const key = buildTargetKey(sessionTarget);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
targets.push({ ...sessionTarget, source: "session" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "targets" || mode === "both") {
|
||||
const explicitTargets = config?.targets ?? [];
|
||||
for (const target of explicitTargets) {
|
||||
const key = buildTargetKey(target);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
targets.push({ ...target, source: "target" });
|
||||
}
|
||||
}
|
||||
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const expiresInMs = Math.max(0, request.expiresAtMs - nowMs());
|
||||
const timeoutId = setTimeout(() => {
|
||||
void (async () => {
|
||||
const entry = pending.get(request.id);
|
||||
if (!entry) return;
|
||||
pending.delete(request.id);
|
||||
const expiredText = buildExpiredMessage(request);
|
||||
await deliverToTargets({ cfg, targets: entry.targets, text: expiredText, deliver });
|
||||
})();
|
||||
}, expiresInMs);
|
||||
timeoutId.unref?.();
|
||||
|
||||
const pendingEntry: PendingApproval = { request, targets, timeoutId };
|
||||
pending.set(request.id, pendingEntry);
|
||||
|
||||
if (pending.get(request.id) !== pendingEntry) return;
|
||||
|
||||
const text = buildRequestMessage(request, nowMs());
|
||||
await deliverToTargets({
|
||||
cfg,
|
||||
targets,
|
||||
text,
|
||||
deliver,
|
||||
shouldSend: () => pending.get(request.id) === pendingEntry,
|
||||
});
|
||||
};
|
||||
|
||||
const handleResolved = async (resolved: ExecApprovalResolved) => {
|
||||
const entry = pending.get(resolved.id);
|
||||
if (!entry) return;
|
||||
if (entry.timeoutId) clearTimeout(entry.timeoutId);
|
||||
pending.delete(resolved.id);
|
||||
|
||||
const cfg = getConfig();
|
||||
const text = buildResolvedMessage(resolved);
|
||||
await deliverToTargets({ cfg, targets: entry.targets, text, deliver });
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
for (const entry of pending.values()) {
|
||||
if (entry.timeoutId) clearTimeout(entry.timeoutId);
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
return { handleRequested, handleResolved, stop };
|
||||
}
|
||||
|
||||
export function shouldForwardExecApproval(params: {
|
||||
config?: ExecApprovalForwardingConfig;
|
||||
request: ExecApprovalRequest;
|
||||
}): boolean {
|
||||
return shouldForward(params);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "../config/sessions.js";
|
||||
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import { peekSystemEvents } from "../infra/system-events.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { getQueueSize } from "../process/command-queue.js";
|
||||
import { CommandLane } from "../process/lanes.js";
|
||||
@@ -88,6 +89,14 @@ export type HeartbeatSummary = {
|
||||
const DEFAULT_HEARTBEAT_TARGET = "last";
|
||||
const ACTIVE_HOURS_TIME_PATTERN = /^([01]\d|2[0-3]|24):([0-5]\d)$/;
|
||||
|
||||
// Prompt used when an async exec has completed and the result should be relayed to the user.
|
||||
// This overrides the standard heartbeat prompt to ensure the model responds with the exec result
|
||||
// instead of just "HEARTBEAT_OK".
|
||||
const EXEC_EVENT_PROMPT =
|
||||
"An async command you ran earlier has completed. The result is shown in the system messages above. " +
|
||||
"Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " +
|
||||
"If it failed, explain what went wrong.";
|
||||
|
||||
function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed || trimmed === "user") {
|
||||
@@ -453,11 +462,13 @@ export async function runHeartbeatOnce(opts: {
|
||||
|
||||
// Skip heartbeat if HEARTBEAT.md exists but has no actionable content.
|
||||
// This saves API calls/costs when the file is effectively empty (only comments/headers).
|
||||
// EXCEPTION: Don't skip for exec events - they have pending system events to process.
|
||||
const isExecEventReason = opts.reason === "exec-event";
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
||||
try {
|
||||
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
|
||||
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent)) {
|
||||
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && !isExecEventReason) {
|
||||
emitHeartbeatEvent({
|
||||
status: "skipped",
|
||||
reason: "empty-heartbeat-file",
|
||||
@@ -483,12 +494,20 @@ export async function runHeartbeatOnce(opts: {
|
||||
: { showOk: false, showAlerts: true, useIndicator: true };
|
||||
const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
|
||||
const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix;
|
||||
const prompt = resolveHeartbeatPrompt(cfg, heartbeat);
|
||||
|
||||
// Check if this is an exec event with pending exec completion system events.
|
||||
// If so, use a specialized prompt that instructs the model to relay the result
|
||||
// instead of the standard heartbeat prompt with "reply HEARTBEAT_OK".
|
||||
const isExecEvent = opts.reason === "exec-event";
|
||||
const pendingEvents = isExecEvent ? peekSystemEvents(sessionKey) : [];
|
||||
const hasExecCompletion = pendingEvents.some((evt) => evt.includes("Exec finished"));
|
||||
|
||||
const prompt = hasExecCompletion ? EXEC_EVENT_PROMPT : resolveHeartbeatPrompt(cfg, heartbeat);
|
||||
const ctx = {
|
||||
Body: prompt,
|
||||
From: sender,
|
||||
To: sender,
|
||||
Provider: "heartbeat",
|
||||
Provider: hasExecCompletion ? "exec-event" : "heartbeat",
|
||||
SessionKey: sessionKey,
|
||||
};
|
||||
if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) {
|
||||
@@ -558,7 +577,19 @@ export async function runHeartbeatOnce(opts: {
|
||||
|
||||
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
|
||||
const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars);
|
||||
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
|
||||
// For exec completion events, don't skip even if the response looks like HEARTBEAT_OK.
|
||||
// The model should be responding with exec results, not ack tokens.
|
||||
// Also, if normalized.text is empty due to token stripping but we have exec completion,
|
||||
// fall back to the original reply text.
|
||||
const execFallbackText =
|
||||
hasExecCompletion && !normalized.text.trim() && replyPayload.text?.trim()
|
||||
? replyPayload.text.trim()
|
||||
: null;
|
||||
if (execFallbackText) {
|
||||
normalized.text = execFallbackText;
|
||||
normalized.shouldSkip = false;
|
||||
}
|
||||
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia && !hasExecCompletion;
|
||||
if (shouldSkipMain && reasoningPayloads.length === 0) {
|
||||
await restoreHeartbeatUpdatedAt({
|
||||
storePath,
|
||||
|
||||
@@ -89,4 +89,30 @@ describe("runMessageAction Slack threading", () => {
|
||||
const call = mocks.executeSendAction.mock.calls[0]?.[0];
|
||||
expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:111.222");
|
||||
});
|
||||
|
||||
it("matches auto-threading when channel ids differ in case", async () => {
|
||||
mocks.executeSendAction.mockResolvedValue({
|
||||
handledBy: "plugin",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
await runMessageAction({
|
||||
cfg: slackConfig,
|
||||
action: "send",
|
||||
params: {
|
||||
channel: "slack",
|
||||
target: "channel:c123",
|
||||
message: "hi",
|
||||
},
|
||||
toolContext: {
|
||||
currentChannelId: "C123",
|
||||
currentThreadTs: "333.444",
|
||||
replyToMode: "all",
|
||||
},
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
const call = mocks.executeSendAction.mock.calls[0]?.[0];
|
||||
expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:333.444");
|
||||
});
|
||||
});
|
||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user