Merge branch 'main' into commands-list-clean

This commit is contained in:
Luke
2026-01-08 16:22:25 -05:00
committed by GitHub
35 changed files with 847 additions and 201 deletions

View File

@@ -81,6 +81,8 @@
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested. - **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session. - **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those. - **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there. - When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item. - Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac. - Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.

View File

@@ -2,12 +2,15 @@
## Unreleased ## Unreleased
- Doctor/Daemon: audit supervisor configs, recommend doctor from daemon status, and document user vs system services. (#?) — thanks @steipete - Doctor: check config/state permissions and offer to tighten them. — thanks @steipete
- Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete
- Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c - Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
- Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino
- Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos - Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos
- Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot - Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot
- Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos - Heartbeat: resolve Telegram account IDs from config-only tokens; cron tool accepts canonical `jobId` and legacy `id` for job actions. (#516) — thanks @YuriNachos
- Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123 - Discord: stop provider when gateway reconnects are exhausted and surface errors. (#514) — thanks @joshp123
- Agents: strip empty assistant text blocks from session history to avoid Claude API 400s. (#210)
- Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123 - Auto-reply: preserve block reply ordering with timeout fallback for streaming. (#503) — thanks @joshp123
- Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1 - Auto-reply: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1
- Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess - Auto-reply: avoid splitting outbound chunks inside parentheses. (#499) — thanks @philipp-spiess
@@ -19,6 +22,7 @@
- Control UI: add Docs link, remove chat composer divider, and add New session button. - Control UI: add Docs link, remove chat composer divider, and add New session button.
- Telegram: retry long-polling conflicts with backoff to avoid fatal exits. - Telegram: retry long-polling conflicts with backoff to avoid fatal exits.
- Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos - Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos
- WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415)
- Agent system prompt: avoid automatic self-updates unless explicitly requested. - Agent system prompt: avoid automatic self-updates unless explicitly requested.
- Onboarding: tighten QuickStart hint copy for configuring later. - Onboarding: tighten QuickStart hint copy for configuring later.
- Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic. - Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic.

View File

@@ -6,62 +6,85 @@ read_when:
--- ---
# Cron jobs (Gateway scheduler) # Cron jobs (Gateway scheduler)
Cron runs inside the Gateway and schedules background work so Clawdbot can Cron is the Gateways built-in scheduler. It persists jobs, wakes the agent at
wake itself up, run isolated agent jobs, and deliver reminders on time. the right time, and can optionally deliver output back to a chat.
## Update checklist (internal) If you want *“run this every morning”* or *“poke the agent in 20 minutes”*,
- [x] Audit cron + heartbeat behavior in code cron is the mechanism.
- [x] Rewrite cron doc as user-facing feature
- [x] Update heartbeat docs + templates
- [x] Update cron links in docs
- [x] Update changelog
- [x] Run full gate (lint/build/test/docs)
## What cron is ## TL;DR
- **Gateway-owned scheduler** that persists jobs under `~/.clawdbot/cron/`. - Cron runs **inside the Gateway** (not inside the model).
- **Two execution modes**: - Jobs persist under `~/.clawdbot/cron/` so restarts dont lose schedules.
- **Main session jobs** enqueue `System:` events and rely on the heartbeat runner. - Two execution styles:
- **Isolated jobs** run a dedicated agent turn in `cron:<jobId>` sessions. - **Main session**: enqueue a system event, then run on the next heartbeat.
- **Wakeups** are first-class: a job can trigger the next heartbeat or run it now. - **Isolated**: run a dedicated agent turn in `cron:<jobId>`, optionally deliver output.
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
## When to use it ## Concepts
- Recurring reminders: “every weekday at 7:30” or “every 2h.”
- Background chores: summarize inboxes, check dashboards, watch logs.
- Automation that should not pollute the main chat history.
- Scheduled wakeups that drive the heartbeat pipeline.
## Schedules ### Jobs
A cron job is a stored record with:
- a **schedule** (when it should run),
- a **payload** (what it should do),
- optional **delivery** (where output should be sent).
Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs).
In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility.
### Schedules
Cron supports three schedule kinds: Cron supports three schedule kinds:
- `at`: one-shot timestamp in ms. - `at`: one-shot timestamp (ms since epoch).
- `every`: fixed interval (ms). - `every`: fixed interval (ms).
- `cron`: 5-field cron expression, optional IANA timezone. - `cron`: 5-field cron expression with optional IANA timezone.
Cron expressions use `croner` under the hood. If a timezone is omitted, the Cron expressions use `croner`. If a timezone is omitted, the Gateway hosts
servers local timezone is used. local timezone is used.
## Job types ### Main vs isolated execution
### Main session jobs #### Main session jobs (system events)
Main jobs enqueue a system event and optionally wake the heartbeat runner. Main jobs enqueue a system event and optionally wake the heartbeat runner.
They **must** use `payload.kind = "systemEvent"`. They must use `payload.kind = "systemEvent"`.
- **`wakeMode: "next-heartbeat"`** (default): the event waits for the next - `wakeMode: "next-heartbeat"` (default): event waits for the next scheduled heartbeat.
scheduled heartbeat. - `wakeMode: "now"`: event triggers an immediate heartbeat run.
- **`wakeMode: "now"`**: the event triggers an immediate heartbeat run.
### Isolated jobs This is the best fit when you want the normal heartbeat prompt + main-session context.
Isolated jobs run a dedicated agent turn in session `cron:<jobId>` and can See [Heartbeat](/gateway/heartbeat).
optionally deliver a message.
#### Isolated jobs (dedicated cron sessions)
Isolated jobs run a dedicated agent turn in session `cron:<jobId>`.
Key behaviors: Key behaviors:
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability. - Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
- A summary is posted to the main session with prefix `Cron` (or - A summary is posted to the main session (prefix `Cron`, configurable).
`isolation.postToMainPrefix`).
- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary. - `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
- `payload.deliver: true` sends output to a provider; otherwise it stays internal. - If `payload.deliver: true`, output is delivered to a provider; otherwise it stays internal.
Use isolated jobs for noisy, frequent, or “background chores” that shouldnt spam
your main chat history.
### Delivery (provider + target)
Isolated jobs can deliver output to a provider. The job payload can specify:
- `provider`: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage` / `last`
- `to`: provider-specific recipient target
If `provider` or `to` is omitted, cron can fall back to the main sessions “last route”
(the last place the agent replied).
#### Telegram delivery targets (topics / forum threads)
Telegram supports forum topics via `message_thread_id`. For cron delivery, you can encode
the topic/thread into the `to` field:
- `-1001234567890` (chat id only)
- `-1001234567890:topic:123` (preferred: explicit topic marker)
- `-1001234567890:123` (shorthand: numeric suffix)
Internal prefixes like `telegram:...` / `telegram:group:...` are also accepted:
- `telegram:group:-1001234567890:topic:123`
## Storage & history ## Storage & history
- Job store: `~/.clawdbot/cron/jobs.json` (JSON, Gateway-managed). - Job store: `~/.clawdbot/cron/jobs.json` (Gateway-managed JSON).
- Run history: `~/.clawdbot/cron/runs/<jobId>.jsonl` (JSONL, auto-pruned). - Run history: `~/.clawdbot/cron/runs/<jobId>.jsonl` (JSONL, auto-pruned).
- Override store path: `cron.store` in config. - Override store path: `cron.store` in config.
@@ -70,16 +93,16 @@ Key behaviors:
```json5 ```json5
{ {
cron: { cron: {
enabled: true, // default true enabled: true, // default true
store: "~/.clawdbot/cron/jobs.json", store: "~/.clawdbot/cron/jobs.json",
maxConcurrentRuns: 1 // default 1 maxConcurrentRuns: 1 // default 1
} }
} }
``` ```
Disable cron entirely: Disable cron entirely:
- `cron.enabled: false` (config) - `cron.enabled: false` (config)
- or `CLAWDBOT_SKIP_CRON=1` (env) - `CLAWDBOT_SKIP_CRON=1` (env)
## CLI quickstart ## CLI quickstart
@@ -106,6 +129,19 @@ clawdbot cron add \
--to "+15551234567" --to "+15551234567"
``` ```
Recurring isolated job (deliver to a Telegram topic):
```bash
clawdbot cron add \
--name "Nightly summary (topic)" \
--cron "0 22 * * *" \
--tz "America/Los_Angeles" \
--session isolated \
--message "Summarize today; send to the nightly topic." \
--deliver \
--provider telegram \
--to "-1001234567890:topic:123"
```
Manual run (debug): Manual run (debug):
```bash ```bash
clawdbot cron run <jobId> --force clawdbot cron run <jobId> --force
@@ -121,12 +157,19 @@ Immediate wake without creating a job:
clawdbot wake --mode now --text "Next heartbeat: check battery." clawdbot wake --mode now --text "Next heartbeat: check battery."
``` ```
## API surface (Gateway) ## Gateway API surface
- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove` - `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
- `cron.run` (force or due), `cron.runs` - `cron.run` (force or due), `cron.runs`
- `wake` (enqueue system event + optional heartbeat) - `wake` (enqueue system event + optional heartbeat)
## Tips ## Troubleshooting
- Use **main session jobs** when you want the heartbeat prompt + existing context.
- Use **isolated jobs** for noisy, frequent, or long-running work. ### “Nothing runs”
- Keep messages short; cron turns are full agent runs and can burn tokens. - Check cron is enabled: `cron.enabled` and `CLAWDBOT_SKIP_CRON`.
- Check the Gateway is running continuously (cron runs inside the Gateway process).
- For `cron` schedules: confirm timezone (`--tz`) vs the host timezone.
### Telegram delivers to the wrong place
- For forum topics, use `-100…:topic:<id>` so its explicit and unambiguous.
- If you see `telegram:...` prefixes in logs or stored “last route” targets, thats normal;
cron delivery accepts them and still parses topic IDs correctly.

View File

@@ -1176,6 +1176,8 @@ per session key at a time). Default: 1.
Optional **Docker sandboxing** for the embedded agent. Intended for non-main Optional **Docker sandboxing** for the embedded agent. Intended for non-main
sessions so they cannot access your host system. sessions so they cannot access your host system.
Details: [Sandboxing](/gateway/sandboxing)
Defaults (if enabled): Defaults (if enabled):
- scope: `"agent"` (one container + workspace per agent) - scope: `"agent"` (one container + workspace per agent)
- Debian bookworm-slim based image - Debian bookworm-slim based image

View File

@@ -23,11 +23,24 @@ clawdbot doctor --yes
Accept defaults without prompting (including restart/service/sandbox repair steps when applicable). Accept defaults without prompting (including restart/service/sandbox repair steps when applicable).
```bash
clawdbot doctor --repair
```
Apply recommended repairs without prompting (repairs + restarts where safe).
```bash
clawdbot doctor --repair --force
```
Apply aggressive repairs too (overwrites custom supervisor configs).
```bash ```bash
clawdbot doctor --non-interactive clawdbot doctor --non-interactive
``` ```
Run without prompts and only apply safe migrations (config normalization + on-disk state moves). Skips restart/service/sandbox actions that require human confirmation. Run without prompts and only apply safe migrations (config normalization + on-disk state moves). Skips restart/service/sandbox actions that require human confirmation.
Legacy state migrations run automatically when detected.
```bash ```bash
clawdbot doctor --deep clawdbot doctor --deep
@@ -47,6 +60,7 @@ cat ~/.clawdbot/clawdbot.json
- Legacy config migration and normalization. - Legacy config migration and normalization.
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth). - Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
- State integrity and permissions checks (sessions, transcripts, state dir). - State integrity and permissions checks (sessions, transcripts, state dir).
- Config file permission checks (chmod 600) when running locally.
- Legacy workspace dir detection (`~/clawdis`, `~/clawdbot`). - Legacy workspace dir detection (`~/clawdis`, `~/clawdbot`).
- Sandbox image repair when sandboxing is enabled. - Sandbox image repair when sandboxing is enabled.
- Legacy service migration and extra gateway detection. - Legacy service migration and extra gateway detection.
@@ -117,6 +131,8 @@ Doctor checks:
split between installs). split between installs).
- **Remote mode reminder**: if `gateway.mode=remote`, doctor reminds you to run - **Remote mode reminder**: if `gateway.mode=remote`, doctor reminds you to run
it on the remote host (the state lives there). it on the remote host (the state lives there).
- **Config file permissions**: warns if `~/.clawdbot/clawdbot.json` is
group/world readable and offers to tighten to `600`.
### 5) Sandbox image repair ### 5) Sandbox image repair
When sandboxing is enabled, doctor checks Docker images and offers to build or When sandboxing is enabled, doctor checks Docker images and offers to build or
@@ -150,6 +166,13 @@ missing or outdated defaults (e.g., systemd network-online dependencies and
restart delay). When it finds a mismatch, it recommends an update and can restart delay). When it finds a mismatch, it recommends an update and can
rewrite the service file/task to the current defaults. rewrite the service file/task to the current defaults.
Notes:
- `clawdbot doctor` prompts before rewriting supervisor config.
- `clawdbot doctor --yes` accepts the default repair prompts.
- `clawdbot doctor --repair` applies recommended fixes without prompts.
- `clawdbot doctor --repair --force` overwrites custom supervisor configs.
- You can always force a full rewrite via `clawdbot daemon install --force`.
### 12) Gateway runtime + port diagnostics ### 12) Gateway runtime + port diagnostics
Doctor inspects the daemon runtime (PID, last exit status) and warns when the Doctor inspects the daemon runtime (PID, last exit status) and warns when the
service is installed but not actually running. It also checks for port collisions service is installed but not actually running. It also checks for port collisions

View File

@@ -156,6 +156,8 @@ See also: [`docs/presence.md`](/concepts/presence) for how presence is produced/
- StandardOut/Err: file paths or `syslog` - StandardOut/Err: file paths or `syslog`
- On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices. - On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices.
- LaunchAgents are per-user and require a logged-in session; for headless setups use a custom LaunchDaemon (not shipped). - LaunchAgents are per-user and require a logged-in session; for headless setups use a custom LaunchDaemon (not shipped).
- `clawdbot daemon install` writes `~/Library/LaunchAgents/com.clawdbot.gateway.plist`.
- `clawdbot doctor` audits the LaunchAgent config and can update it to current defaults.
## Daemon management (CLI) ## Daemon management (CLI)

View File

@@ -0,0 +1,94 @@
---
summary: "How Clawdbot sandboxing works: modes, scopes, workspace access, and images"
title: Sandboxing
read_when: "You want a dedicated explanation of sandboxing or need to tune agent.sandbox."
status: active
---
# Sandboxing
Clawdbot can run **tools inside Docker containers** to reduce blast radius.
This is **optional** and controlled by configuration (`agent.sandbox` or
`routing.agents[id].sandbox`). If sandboxing is off, tools run on the host.
The Gateway stays on the host; tool execution runs in an isolated sandbox
when enabled.
This is not a perfect security boundary, but it materially limits filesystem
and process access when the model does something dumb.
## What gets sandboxed
- Tool execution (`bash`, `read`, `write`, `edit`, `process`, etc.).
- Optional sandboxed browser (`agent.sandbox.browser`).
Not sandboxed:
- The Gateway process itself.
- Any tool explicitly allowed to run on the host (e.g. `agent.elevated`).
## Modes
`agent.sandbox.mode` controls **when** sandboxing is used:
- `"off"`: no sandboxing.
- `"non-main"`: sandbox only **non-main** sessions (default if you want normal chats on host).
- `"all"`: every session runs in a sandbox.
## Scope
`agent.sandbox.scope` controls **how many containers** are created:
- `"session"` (default): one container per session.
- `"agent"`: one container per agent.
- `"shared"`: one container shared by all sandboxed sessions.
## Workspace access
`agent.sandbox.workspaceAccess` controls **what the sandbox can see**:
- `"none"` (default): tools see a sandbox workspace under `~/.clawdbot/sandboxes`.
- `"ro"`: mounts the agent workspace read-only at `/agent` (disables `write`/`edit`).
- `"rw"`: mounts the agent workspace read/write at `/workspace`.
Inbound media is copied into the active sandbox workspace (`media/inbound/*`).
## Images + setup
Default image: `clawdbot-sandbox:bookworm-slim`
Build it once:
```bash
scripts/sandbox-setup.sh
```
Sandboxed browser image:
```bash
scripts/sandbox-browser-setup.sh
```
By default, sandbox containers run with **no network**.
Override with `agent.sandbox.docker.network`.
Docker installs and the containerized gateway live here:
[Docker](/install/docker)
## Tool policy + escape hatches
Tool allow/deny policies still apply before sandbox rules. If a tool is denied
globally or per-agent, sandboxing doesnt bring it back.
`agent.elevated` is an explicit escape hatch that runs `bash` on the host.
Keep it locked down.
## Multi-agent overrides
Each agent can override sandbox + tools:
`routing.agents[id].sandbox` and `routing.agents[id].tools`.
See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for precedence.
## Minimal enable example
```json5
{
agent: {
sandbox: {
mode: "non-main",
scope: "session",
workspaceAccess: "none"
}
}
}
```
## Related docs
- [Sandbox Configuration](/gateway/configuration#agent-sandbox)
- [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools)
- [Security](/gateway/security)

View File

@@ -95,6 +95,14 @@ This is social engineering 101. Create distrust, encourage snooping.
## Configuration Hardening (examples) ## Configuration Hardening (examples)
### 0) File permissions
Keep config + state private on the gateway host:
- `~/.clawdbot/clawdbot.json`: `600` (user read/write only)
- `~/.clawdbot`: `700` (user only)
`clawdbot doctor` can warn and offer to tighten these permissions.
### 1) DMs: pairing by default ### 1) DMs: pairing by default
```json5 ```json5
@@ -138,10 +146,12 @@ We may add a single `readOnlyMode` flag later to simplify this configuration.
## Sandboxing (recommended) ## Sandboxing (recommended)
Dedicated doc: [Sandboxing](/gateway/sandboxing)
Two complementary approaches: Two complementary approaches:
- **Run the full Gateway in Docker** (container boundary): [Docker](/install/docker) - **Run the full Gateway in Docker** (container boundary): [Docker](/install/docker)
- **Tool sandbox** (`agent.sandbox`, host gateway + Docker-isolated tools): [Configuration](/gateway/configuration) - **Tool sandbox** (`agent.sandbox`, host gateway + Docker-isolated tools): [Sandboxing](/gateway/sandboxing)
Note: to prevent cross-agent access, keep `sandbox.scope` at `"agent"` (default) Note: to prevent cross-agent access, keep `sandbox.scope` at `"agent"` (default)
or `"session"` for stricter per-session isolation. `scope: "shared"` uses a or `"session"` for stricter per-session isolation. `scope: "shared"` uses a

View File

@@ -55,6 +55,10 @@ the Gateway likely refused to bind.
- If they dont, youre almost certainly editing one config while the daemon is running another. - If they dont, youre almost certainly editing one config while the daemon is running another.
- Fix: rerun `clawdbot daemon install --force` from the same `--profile` / `CLAWDBOT_STATE_DIR` you want the daemon to use. - Fix: rerun `clawdbot daemon install --force` from the same `--profile` / `CLAWDBOT_STATE_DIR` you want the daemon to use.
**If `clawdbot daemon status` reports service config issues**
- The supervisor config (launchd/systemd/schtasks) is missing current defaults.
- Fix: run `clawdbot doctor` to update it (or `clawdbot daemon install --force` for a full rewrite).
**If `Last gateway error:` mentions “refusing to bind … without auth”** **If `Last gateway error:` mentions “refusing to bind … without auth”**
- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`auto`) but left auth off. - You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`auto`) but left auth off.
- Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the daemon. - Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the daemon.

View File

@@ -9,10 +9,18 @@ read_when:
Docker is **optional**. Use it only if you want a containerized gateway or to validate the Docker flow. Docker is **optional**. Use it only if you want a containerized gateway or to validate the Docker flow.
## Is Docker right for me?
- **Yes**: you want an isolated, throwaway gateway environment or to run Clawdbot on a host without local installs.
- **No**: youre running on your own machine and just want the fastest dev loop. Use the normal install flow instead.
- **Sandboxing note**: agent sandboxing uses Docker too, but it does **not** require the full gateway to run in Docker. See [Sandboxing](/gateway/sandboxing).
This guide covers: This guide covers:
- Containerized Gateway (full Clawdbot in Docker) - Containerized Gateway (full Clawdbot in Docker)
- Per-session Agent Sandbox (host gateway + Docker-isolated agent tools) - Per-session Agent Sandbox (host gateway + Docker-isolated agent tools)
Sandboxing details: [Sandboxing](/gateway/sandboxing)
## Requirements ## Requirements
- Docker Desktop (or Docker Engine) + Docker Compose v2 - Docker Desktop (or Docker Engine) + Docker Compose v2
@@ -33,6 +41,11 @@ This script:
- runs the onboarding wizard - runs the onboarding wizard
- prints optional provider setup hints - prints optional provider setup hints
- starts the gateway via Docker Compose - starts the gateway via Docker Compose
- generates a gateway token and writes it to `.env`
After it finishes:
- Open `http://127.0.0.1:18789/` in your browser.
- Paste the token into the Control UI (Settings → token).
It writes config/workspace on the host: It writes config/workspace on the host:
- `~/.clawdbot/` - `~/.clawdbot/`
@@ -92,6 +105,8 @@ pnpm test:docker:qr
## Agent Sandbox (host gateway + Docker tools) ## Agent Sandbox (host gateway + Docker tools)
Deep dive: [Sandboxing](/gateway/sandboxing)
### What it does ### What it does
When `agent.sandbox` is enabled, **non-main sessions** run tools inside a Docker When `agent.sandbox` is enabled, **non-main sessions** run tools inside a Docker

View File

@@ -18,6 +18,8 @@ This allows you to run multiple agents with different security profiles:
- Family/work agents with restricted tools - Family/work agents with restricted tools
- Public-facing agents in sandboxes - Public-facing agents in sandboxes
For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing).
--- ---
## Configuration Examples ## Configuration Examples

View File

@@ -13,6 +13,15 @@ Goal: Clawdbot Gateway running on an exe.dev VM, reachable from your laptop via:
This page assumes **Ubuntu/Debian**. If you picked a different distro, map packages accordingly. This page assumes **Ubuntu/Debian**. If you picked a different distro, map packages accordingly.
If youre on any other Linux VPS, the same steps apply — you just wont use the exe.dev proxy commands.
## Beginner quick path
1) Create VM → install Node 22 → install Clawdbot
2) Run `clawdbot onboard --install-daemon`
3) Tunnel from laptop (`ssh -N -L 18789:127.0.0.1:18789 …`)
4) Open `http://127.0.0.1:18789/` and paste your token
## What you need ## What you need
- exe.dev account + `ssh exe.dev` working on your laptop - exe.dev account + `ssh exe.dev` working on your laptop

View File

@@ -10,6 +10,16 @@ Clawdbot core is fully supported on Linux. The core is written in TypeScript, so
We do not have a Linux companion app yet. It is planned, and we would love contributions to make it happen. We do not have a Linux companion app yet. It is planned, and we would love contributions to make it happen.
## Beginner quick path (VPS)
1) Install Node 22+
2) `npm i -g clawdbot@latest`
3) `clawdbot onboard --install-daemon`
4) From your laptop: `ssh -N -L 18789:127.0.0.1:18789 <user>@<host>`
5) Open `http://127.0.0.1:18789/` and paste your token
Step-by-step VPS guide: [exe.dev](/platforms/exe-dev)
## Install ## Install
- [Getting Started](/start/getting-started) - [Getting Started](/start/getting-started)
- [Install & updates](/install/updating) - [Install & updates](/install/updating)
@@ -35,12 +45,6 @@ clawdbot daemon install
Or: Or:
```
clawdbot daemon install
```
Or:
``` ```
clawdbot configure clawdbot configure
``` ```

View File

@@ -63,6 +63,8 @@ Manager:
Behavior: Behavior:
- “Clawdbot Active” enables/disables the LaunchAgent. - “Clawdbot Active” enables/disables the LaunchAgent.
- App quit does **not** stop the gateway (launchd keeps it alive). - App quit does **not** stop the gateway (launchd keeps it alive).
- CLI install (`clawdbot daemon install`) writes the same LaunchAgent; `clawdbot daemon install --force` rewrites it.
- `clawdbot doctor` audits the LaunchAgent config and can update it to current defaults.
Logging: Logging:
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log` - launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`

View File

@@ -36,12 +36,6 @@ clawdbot daemon install
Or: Or:
```
clawdbot daemon install
```
Or:
``` ```
clawdbot configure clawdbot configure
``` ```

View File

@@ -89,6 +89,10 @@ It also warns if your configured model is unknown or missing auth.
Bun is supported for faster TypeScript execution, but **WhatsApp requires Node** in this ecosystem. The wizard lets you pick the runtime; choose **Node** if you use WhatsApp. Bun is supported for faster TypeScript execution, but **WhatsApp requires Node** in this ecosystem. The wizard lets you pick the runtime; choose **Node** if you use WhatsApp.
### Is there a dedicated sandboxing doc?
Yes. See [Sandboxing](/gateway/sandboxing). For Docker-specific setup (full gateway in Docker or sandbox images), see [Docker](/install/docker).
## Where things live on disk ## Where things live on disk
### Where does Clawdbot store its data? ### Where does Clawdbot store its data?

View File

@@ -80,6 +80,7 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [Heartbeat](https://docs.clawd.bot/gateway/heartbeat) - [Heartbeat](https://docs.clawd.bot/gateway/heartbeat)
- [Doctor](https://docs.clawd.bot/gateway/doctor) - [Doctor](https://docs.clawd.bot/gateway/doctor)
- [Logging](https://docs.clawd.bot/gateway/logging) - [Logging](https://docs.clawd.bot/gateway/logging)
- [Sandboxing](https://docs.clawd.bot/gateway/sandboxing)
- [Dashboard](https://docs.clawd.bot/web/dashboard) - [Dashboard](https://docs.clawd.bot/web/dashboard)
- [Control UI](https://docs.clawd.bot/web/control-ui) - [Control UI](https://docs.clawd.bot/web/control-ui)
- [Remote access](https://docs.clawd.bot/gateway/remote) - [Remote access](https://docs.clawd.bot/gateway/remote)

View File

@@ -8,6 +8,7 @@ import {
isMessagingToolDuplicate, isMessagingToolDuplicate,
normalizeTextForComparison, normalizeTextForComparison,
sanitizeGoogleTurnOrdering, sanitizeGoogleTurnOrdering,
sanitizeSessionMessagesImages,
validateGeminiTurns, validateGeminiTurns,
} from "./pi-embedded-helpers.js"; } from "./pi-embedded-helpers.js";
import { import {
@@ -250,6 +251,77 @@ describe("sanitizeGoogleTurnOrdering", () => {
}); });
}); });
describe("sanitizeSessionMessagesImages", () => {
it("removes empty assistant text blocks but preserves tool calls", async () => {
const input = [
{
role: "assistant",
content: [
{ type: "text", text: "" },
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
expect(out).toHaveLength(1);
const content = (out[0] as { content?: unknown }).content;
expect(Array.isArray(content)).toBe(true);
expect(content).toHaveLength(1);
expect((content as Array<{ type?: string }>)[0]?.type).toBe("toolCall");
});
it("filters whitespace-only assistant text blocks", async () => {
const input = [
{
role: "assistant",
content: [
{ type: "text", text: " " },
{ type: "text", text: "ok" },
],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
expect(out).toHaveLength(1);
const content = (out[0] as { content?: unknown }).content;
expect(Array.isArray(content)).toBe(true);
expect(content).toHaveLength(1);
expect((content as Array<{ text?: string }>)[0]?.text).toBe("ok");
});
it("drops assistant messages that only contain empty text", async () => {
const input = [
{ role: "user", content: "hello" },
{ role: "assistant", content: [{ type: "text", text: "" }] },
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
expect(out).toHaveLength(1);
expect(out[0]?.role).toBe("user");
});
it("leaves non-assistant messages unchanged", async () => {
const input = [
{ role: "user", content: "hello" },
{
role: "toolResult",
toolUseId: "tool-1",
content: [{ type: "text", text: "result" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionMessagesImages(input, "test");
expect(out).toHaveLength(2);
expect(out[0]?.role).toBe("user");
expect(out[1]?.role).toBe("toolResult");
});
});
describe("normalizeTextForComparison", () => { describe("normalizeTextForComparison", () => {
it("lowercases text", () => { it("lowercases text", () => {
expect(normalizeTextForComparison("Hello World")).toBe("hello world"); expect(normalizeTextForComparison("Hello World")).toBe("hello world");

View File

@@ -99,6 +99,28 @@ export async function sanitizeSessionMessagesImages(
} }
} }
if (role === "assistant") {
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
const content = assistantMsg.content;
if (Array.isArray(content)) {
const filteredContent = content.filter((block) => {
if (!block || typeof block !== "object") return true;
const rec = block as { type?: unknown; text?: unknown };
if (rec.type !== "text" || typeof rec.text !== "string") return true;
return rec.text.trim().length > 0;
});
const sanitizedContent = (await sanitizeContentBlocksImages(
filteredContent as unknown as ContentBlock[],
label,
)) as unknown as typeof assistantMsg.content;
if (sanitizedContent.length === 0) {
continue;
}
out.push({ ...assistantMsg, content: sanitizedContent });
continue;
}
}
out.push(msg); out.push(msg);
} }
return out; return out;

View File

@@ -547,14 +547,14 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
defaultRuntime.log(`Daemon env: ${daemonEnvLines.join(" ")}`); defaultRuntime.log(`Daemon env: ${daemonEnvLines.join(" ")}`);
} }
if (service.configAudit?.issues.length) { if (service.configAudit?.issues.length) {
defaultRuntime.error( defaultRuntime.error("Service config looks out of date or non-standard.");
"Service config looks out of date or non-standard.",
);
for (const issue of service.configAudit.issues) { for (const issue of service.configAudit.issues) {
const detail = issue.detail ? ` (${issue.detail})` : ""; const detail = issue.detail ? ` (${issue.detail})` : "";
defaultRuntime.error(`Service config issue: ${issue.message}${detail}`); defaultRuntime.error(`Service config issue: ${issue.message}${detail}`);
} }
defaultRuntime.error('Recommendation: run "clawdbot doctor".'); defaultRuntime.error(
'Recommendation: run "clawdbot doctor" (or "clawdbot doctor --repair").',
);
} }
if (status.config) { if (status.config) {
const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`; const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;

View File

@@ -74,6 +74,13 @@ function parsePort(raw: unknown): number | null {
return parsed; return parsed;
} }
const toOptionString = (value: unknown): string | undefined => {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "bigint")
return value.toString();
return undefined;
};
function describeUnknownError(err: unknown): string { function describeUnknownError(err: unknown): string {
if (err instanceof Error) return err.message; if (err instanceof Error) return err.message;
if (typeof err === "string") return err; if (typeof err === "string") return err;
@@ -338,9 +345,10 @@ async function runGatewayCommand(
} }
} }
if (opts.token) { if (opts.token) {
process.env.CLAWDBOT_GATEWAY_TOKEN = String(opts.token); const token = toOptionString(opts.token);
if (token) process.env.CLAWDBOT_GATEWAY_TOKEN = token;
} }
const authModeRaw = opts.auth ? String(opts.auth) : undefined; const authModeRaw = toOptionString(opts.auth);
const authMode: GatewayAuthMode | null = const authMode: GatewayAuthMode | null =
authModeRaw === "token" || authModeRaw === "password" ? authModeRaw : null; authModeRaw === "token" || authModeRaw === "password" ? authModeRaw : null;
if (authModeRaw && !authMode) { if (authModeRaw && !authMode) {
@@ -348,7 +356,7 @@ async function runGatewayCommand(
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
const tailscaleRaw = opts.tailscale ? String(opts.tailscale) : undefined; const tailscaleRaw = toOptionString(opts.tailscale);
const tailscaleMode = const tailscaleMode =
tailscaleRaw === "off" || tailscaleRaw === "off" ||
tailscaleRaw === "serve" || tailscaleRaw === "serve" ||
@@ -362,6 +370,8 @@ async function runGatewayCommand(
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
const passwordRaw = toOptionString(opts.password);
const tokenRaw = toOptionString(opts.token);
const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT); const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT);
const mode = cfg.gateway?.mode; const mode = cfg.gateway?.mode;
if (!opts.allowUnconfigured && mode !== "local") { if (!opts.allowUnconfigured && mode !== "local") {
@@ -377,7 +387,7 @@ async function runGatewayCommand(
defaultRuntime.exit(1); defaultRuntime.exit(1);
return; return;
} }
const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback"); const bindRaw = toOptionString(opts.bind) ?? cfg.gateway?.bind ?? "loopback";
const bind = const bind =
bindRaw === "loopback" || bindRaw === "loopback" ||
bindRaw === "tailnet" || bindRaw === "tailnet" ||
@@ -398,8 +408,8 @@ async function runGatewayCommand(
const authConfig = { const authConfig = {
...cfg.gateway?.auth, ...cfg.gateway?.auth,
...(authMode ? { mode: authMode } : {}), ...(authMode ? { mode: authMode } : {}),
...(opts.password ? { password: String(opts.password) } : {}), ...(passwordRaw ? { password: passwordRaw } : {}),
...(opts.token ? { token: String(opts.token) } : {}), ...(tokenRaw ? { token: tokenRaw } : {}),
}; };
const resolvedAuth = resolveGatewayAuth({ const resolvedAuth = resolveGatewayAuth({
authConfig, authConfig,
@@ -467,11 +477,11 @@ async function runGatewayCommand(
await startGatewayServer(port, { await startGatewayServer(port, {
bind, bind,
auth: auth:
authMode || opts.password || opts.token || authModeRaw authMode || passwordRaw || tokenRaw || authModeRaw
? { ? {
mode: authMode ?? undefined, mode: authMode ?? undefined,
token: opts.token ? String(opts.token) : undefined, token: tokenRaw,
password: opts.password ? String(opts.password) : undefined, password: passwordRaw,
} }
: undefined, : undefined,
tailscale: tailscale:

View File

@@ -328,6 +328,12 @@ export function buildProgram() {
false, false,
) )
.option("--yes", "Accept defaults without prompting", false) .option("--yes", "Accept defaults without prompting", false)
.option("--repair", "Apply recommended repairs without prompting", false)
.option(
"--force",
"Apply aggressive repairs (overwrites custom service config)",
false,
)
.option( .option(
"--non-interactive", "--non-interactive",
"Run without prompts (safe migrations only)", "Run without prompts (safe migrations only)",
@@ -339,6 +345,8 @@ export function buildProgram() {
await doctorCommand(defaultRuntime, { await doctorCommand(defaultRuntime, {
workspaceSuggestions: opts.workspaceSuggestions, workspaceSuggestions: opts.workspaceSuggestions,
yes: Boolean(opts.yes), yes: Boolean(opts.yes),
repair: Boolean(opts.repair),
force: Boolean(opts.force),
nonInteractive: Boolean(opts.nonInteractive), nonInteractive: Boolean(opts.nonInteractive),
deep: Boolean(opts.deep), deep: Boolean(opts.deep),
}); });

View File

@@ -142,7 +142,12 @@ export async function maybeRepairGatewayServiceConfig(
} }
const service = resolveGatewayService(); const service = resolveGatewayService();
const command = await service.readCommand(process.env).catch(() => null); let command: Awaited<ReturnType<typeof service.readCommand>> | null = null;
try {
command = await service.readCommand(process.env);
} catch {
command = null;
}
if (!command) return; if (!command) return;
const audit = await auditGatewayServiceConfig({ const audit = await auditGatewayServiceConfig({
@@ -154,16 +159,39 @@ export async function maybeRepairGatewayServiceConfig(
note( note(
audit.issues audit.issues
.map((issue) => .map((issue) =>
issue.detail ? `- ${issue.message} (${issue.detail})` : `- ${issue.message}`, issue.detail
? `- ${issue.message} (${issue.detail})`
: `- ${issue.message}`,
) )
.join("\n"), .join("\n"),
"Gateway service config", "Gateway service config",
); );
const repair = await prompter.confirmSkipInNonInteractive({ const aggressiveIssues = audit.issues.filter(
message: "Update gateway service config to the recommended defaults now?", (issue) => issue.level === "aggressive",
initialValue: true, );
}); const _recommendedIssues = audit.issues.filter(
(issue) => issue.level !== "aggressive",
);
const needsAggressive = aggressiveIssues.length > 0;
if (needsAggressive && !prompter.shouldForce) {
note(
"Custom or unexpected service edits detected. Rerun with --force to overwrite.",
"Gateway service config",
);
}
const repair = needsAggressive
? await prompter.confirmAggressive({
message: "Overwrite gateway service config with current defaults now?",
initialValue: Boolean(prompter.shouldForce),
})
: await prompter.confirmRepair({
message:
"Update gateway service config to the recommended defaults now?",
initialValue: true,
});
if (!repair) return; if (!repair) return;
const devMode = const devMode =

View File

@@ -8,14 +8,22 @@ export type DoctorOptions = {
yes?: boolean; yes?: boolean;
nonInteractive?: boolean; nonInteractive?: boolean;
deep?: boolean; deep?: boolean;
repair?: boolean;
force?: boolean;
}; };
export type DoctorPrompter = { export type DoctorPrompter = {
confirm: (params: Parameters<typeof confirm>[0]) => Promise<boolean>; confirm: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmRepair: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmAggressive: (
params: Parameters<typeof confirm>[0],
) => Promise<boolean>;
confirmSkipInNonInteractive: ( confirmSkipInNonInteractive: (
params: Parameters<typeof confirm>[0], params: Parameters<typeof confirm>[0],
) => Promise<boolean>; ) => Promise<boolean>;
select: <T>(params: Parameters<typeof select>[0], fallback: T) => Promise<T>; select: <T>(params: Parameters<typeof select>[0], fallback: T) => Promise<T>;
shouldRepair: boolean;
shouldForce: boolean;
}; };
export function createDoctorPrompter(params: { export function createDoctorPrompter(params: {
@@ -24,24 +32,42 @@ export function createDoctorPrompter(params: {
}): DoctorPrompter { }): DoctorPrompter {
const yes = params.options.yes === true; const yes = params.options.yes === true;
const requestedNonInteractive = params.options.nonInteractive === true; const requestedNonInteractive = params.options.nonInteractive === true;
const shouldRepair = params.options.repair === true || yes;
const shouldForce = params.options.force === true;
const isTty = Boolean(process.stdin.isTTY); const isTty = Boolean(process.stdin.isTTY);
const nonInteractive = requestedNonInteractive || (!isTty && !yes); const nonInteractive = requestedNonInteractive || (!isTty && !yes);
const canPrompt = isTty && !yes && !nonInteractive; const canPrompt = isTty && !yes && !nonInteractive;
const confirmDefault = async (p: Parameters<typeof confirm>[0]) => { const confirmDefault = async (p: Parameters<typeof confirm>[0]) => {
if (nonInteractive) return false;
if (shouldRepair) return true;
if (!canPrompt) return Boolean(p.initialValue ?? false); if (!canPrompt) return Boolean(p.initialValue ?? false);
return guardCancel(await confirm(p), params.runtime) === true; return guardCancel(await confirm(p), params.runtime) === true;
}; };
return { return {
confirm: confirmDefault, confirm: confirmDefault,
confirmSkipInNonInteractive: async (p) => { confirmRepair: async (p) => {
if (nonInteractive) return false; if (nonInteractive) return false;
return confirmDefault(p); return confirmDefault(p);
}, },
confirmAggressive: async (p) => {
if (nonInteractive) return false;
if (shouldRepair && shouldForce) return true;
if (shouldRepair && !shouldForce) return false;
if (!canPrompt) return Boolean(p.initialValue ?? false);
return guardCancel(await confirm(p), params.runtime) === true;
},
confirmSkipInNonInteractive: async (p) => {
if (nonInteractive) return false;
if (shouldRepair) return true;
return confirmDefault(p);
},
select: async <T>(p: Parameters<typeof select>[0], fallback: T) => { select: async <T>(p: Parameters<typeof select>[0], fallback: T) => {
if (!canPrompt) return fallback; if (!canPrompt || shouldRepair) return fallback;
return guardCancel(await select(p), params.runtime) as T; return guardCancel(await select(p), params.runtime) as T;
}, },
shouldRepair,
shouldForce,
}; };
} }

View File

@@ -123,6 +123,7 @@ function findOtherStateDirs(stateDir: string): string[] {
export async function noteStateIntegrity( export async function noteStateIntegrity(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
prompter: DoctorPrompterLike, prompter: DoctorPrompterLike,
configPath?: string,
) { ) {
const warnings: string[] = []; const warnings: string[] = [];
const changes: string[] = []; const changes: string[] = [];
@@ -186,6 +187,49 @@ export async function noteStateIntegrity(
} }
} }
} }
if (stateDirExists && process.platform !== "win32") {
try {
const stat = fs.statSync(stateDir);
if ((stat.mode & 0o077) !== 0) {
warnings.push(
`- State directory permissions are too open (${stateDir}). Recommend chmod 700.`,
);
const tighten = await prompter.confirmSkipInNonInteractive({
message: `Tighten permissions on ${stateDir} to 700?`,
initialValue: true,
});
if (tighten) {
fs.chmodSync(stateDir, 0o700);
changes.push(`- Tightened permissions on ${stateDir} to 700`);
}
}
} catch (err) {
warnings.push(`- Failed to read ${stateDir} permissions: ${String(err)}`);
}
}
if (configPath && existsFile(configPath) && process.platform !== "win32") {
try {
const stat = fs.statSync(configPath);
if ((stat.mode & 0o077) !== 0) {
warnings.push(
`- Config file is group/world readable (${configPath}). Recommend chmod 600.`,
);
const tighten = await prompter.confirmSkipInNonInteractive({
message: `Tighten permissions on ${configPath} to 600?`,
initialValue: true,
});
if (tighten) {
fs.chmodSync(configPath, 0o600);
changes.push(`- Tightened permissions on ${configPath} to 600`);
}
}
} catch (err) {
warnings.push(
`- Failed to read config permissions (${configPath}): ${String(err)}`,
);
}
}
if (stateDirExists) { if (stateDirExists) {
const dirCandidates = new Map<string, string>(); const dirCandidates = new Map<string, string>();

View File

@@ -129,10 +129,13 @@ export async function doctorCommand(
const legacyState = await detectLegacyStateMigrations({ cfg }); const legacyState = await detectLegacyStateMigrations({ cfg });
if (legacyState.preview.length > 0) { if (legacyState.preview.length > 0) {
note(legacyState.preview.join("\n"), "Legacy state detected"); note(legacyState.preview.join("\n"), "Legacy state detected");
const migrate = await prompter.confirm({ const migrate =
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?", options.nonInteractive === true
initialValue: true, ? true
}); : await prompter.confirm({
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
initialValue: true,
});
if (migrate) { if (migrate) {
const migrated = await runLegacyStateMigrations({ const migrated = await runLegacyStateMigrations({
detected: legacyState, detected: legacyState,
@@ -146,7 +149,11 @@ export async function doctorCommand(
} }
} }
await noteStateIntegrity(cfg, prompter); await noteStateIntegrity(
cfg,
prompter,
snapshot.path ?? CONFIG_PATH_CLAWDBOT,
);
cfg = await maybeRepairSandboxImages(cfg, runtime, prompter); cfg = await maybeRepairSandboxImages(cfg, runtime, prompter);
noteSandboxScopeWarnings(cfg); noteSandboxScopeWarnings(cfg);

View File

@@ -20,10 +20,7 @@ vi.mock("../agents/model-catalog.js", () => ({
import { loadModelCatalog } from "../agents/model-catalog.js"; import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
parseTelegramTarget,
runCronIsolatedAgentTurn,
} from "./isolated-agent.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> { async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cron-")); const base = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-cron-"));
@@ -408,6 +405,51 @@ describe("runCronIsolatedAgentTurn", () => {
}); });
}); });
it("delivers telegram topic targets with messageThreadId", async () => {
await withTempHome(async (home) => {
const storePath = await writeSessionStore(home);
const deps: CliDeps = {
sendMessageWhatsApp: vi.fn(),
sendMessageTelegram: vi.fn().mockResolvedValue({
messageId: "t1",
chatId: "-1001234567890",
}),
sendMessageDiscord: vi.fn(),
sendMessageSignal: vi.fn(),
sendMessageIMessage: vi.fn(),
};
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "hello from cron" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const res = await runCronIsolatedAgentTurn({
cfg: makeCfg(home, storePath),
deps,
job: makeJob({
kind: "agentTurn",
message: "do it",
deliver: true,
provider: "telegram",
to: "telegram:group:-1001234567890:topic:321",
}),
message: "do it",
sessionKey: "cron:job-1",
lane: "cron",
});
expect(res.status).toBe("ok");
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
"-1001234567890",
"hello from cron",
expect.objectContaining({ messageThreadId: 321 }),
);
});
});
it("delivers via discord when configured", async () => { it("delivers via discord when configured", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const storePath = await writeSessionStore(home); const storePath = await writeSessionStore(home);
@@ -673,63 +715,3 @@ describe("runCronIsolatedAgentTurn", () => {
}); });
}); });
}); });
describe("parseTelegramTarget", () => {
it("parses plain chatId", () => {
expect(parseTelegramTarget("-1001234567890")).toEqual({
chatId: "-1001234567890",
topicId: undefined,
});
});
it("parses @username", () => {
expect(parseTelegramTarget("@mychannel")).toEqual({
chatId: "@mychannel",
topicId: undefined,
});
});
it("parses chatId:topicId format", () => {
expect(parseTelegramTarget("-1001234567890:123")).toEqual({
chatId: "-1001234567890",
topicId: 123,
});
});
it("parses chatId:topic:topicId format", () => {
expect(parseTelegramTarget("-1001234567890:topic:456")).toEqual({
chatId: "-1001234567890",
topicId: 456,
});
});
it("trims whitespace", () => {
expect(parseTelegramTarget(" -1001234567890:99 ")).toEqual({
chatId: "-1001234567890",
topicId: 99,
});
});
it("does not treat non-numeric suffix as topicId", () => {
expect(parseTelegramTarget("-1001234567890:abc")).toEqual({
chatId: "-1001234567890:abc",
topicId: undefined,
});
});
it("strips internal telegram prefix", () => {
expect(parseTelegramTarget("telegram:123")).toEqual({
chatId: "123",
topicId: undefined,
});
});
it("strips internal telegram + group prefixes before parsing topic", () => {
expect(
parseTelegramTarget("telegram:group:-1001234567890:topic:456"),
).toEqual({
chatId: "-1001234567890",
topicId: 456,
});
});
});

View File

@@ -46,49 +46,11 @@ import {
saveSessionStore, saveSessionStore,
} from "../config/sessions.js"; } from "../config/sessions.js";
import { registerAgentRunContext } from "../infra/agent-events.js"; import { registerAgentRunContext } from "../infra/agent-events.js";
import { parseTelegramTarget } from "../telegram/targets.js";
import { resolveTelegramToken } from "../telegram/token.js"; import { resolveTelegramToken } from "../telegram/token.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
import type { CronJob } from "./types.js"; import type { CronJob } from "./types.js";
/**
* Parse a Telegram delivery target into chatId and optional topicId.
* Supports formats:
* - `chatId` (plain chat ID or @username)
* - `chatId:topicId` (chat ID with topic/thread ID)
* - `chatId:topic:topicId` (alternative format with explicit "topic" marker)
*/
export function parseTelegramTarget(to: string): {
chatId: string;
topicId: number | undefined;
} {
let trimmed = to.trim();
// Cron "lastTo" values can include internal prefixes like `telegram:...` or
// `telegram:group:...` (see normalizeChatId in telegram/send.ts).
// Strip these before parsing `:topic:` / `:<topicId>` suffixes.
while (true) {
const next = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
if (next === trimmed) break;
trimmed = next;
}
// Try format: chatId:topic:topicId
const topicMatch = /^(.+?):topic:(\d+)$/.exec(trimmed);
if (topicMatch) {
return { chatId: topicMatch[1], topicId: parseInt(topicMatch[2], 10) };
}
// Try format: chatId:topicId (where topicId is numeric)
// Be careful not to match @username or other non-numeric suffixes
const colonMatch = /^(.+):(\d+)$/.exec(trimmed);
if (colonMatch) {
return { chatId: colonMatch[1], topicId: parseInt(colonMatch[2], 10) };
}
// Plain chatId, no topic
return { chatId: trimmed, topicId: undefined };
}
export type RunCronAgentTurnResult = { export type RunCronAgentTurnResult = {
status: "ok" | "error" | "skipped"; status: "ok" | "error" | "skipped";
summary?: string; summary?: string;
@@ -526,7 +488,9 @@ export async function runCronIsolatedAgentTurn(params: {
summary: "Delivery skipped (no Telegram chatId).", summary: "Delivery skipped (no Telegram chatId).",
}; };
} }
const { chatId, topicId } = parseTelegramTarget(resolvedDelivery.to); const telegramTarget = parseTelegramTarget(resolvedDelivery.to);
const chatId = telegramTarget.chatId;
const messageThreadId = telegramTarget.messageThreadId;
const textLimit = resolveTextChunkLimit(params.cfg, "telegram"); const textLimit = resolveTextChunkLimit(params.cfg, "telegram");
try { try {
for (const payload of payloads) { for (const payload of payloads) {
@@ -540,7 +504,7 @@ export async function runCronIsolatedAgentTurn(params: {
await params.deps.sendMessageTelegram(chatId, chunk, { await params.deps.sendMessageTelegram(chatId, chunk, {
verbose: false, verbose: false,
token: telegramToken || undefined, token: telegramToken || undefined,
messageThreadId: topicId, messageThreadId,
}); });
} }
} else { } else {
@@ -552,7 +516,7 @@ export async function runCronIsolatedAgentTurn(params: {
verbose: false, verbose: false,
mediaUrl: url, mediaUrl: url,
token: telegramToken || undefined, token: telegramToken || undefined,
messageThreadId: topicId, messageThreadId,
}); });
} }
} }

View File

@@ -13,6 +13,7 @@ export type ServiceConfigIssue = {
code: string; code: string;
message: string; message: string;
detail?: string; detail?: string;
level?: "recommended" | "aggressive";
}; };
export type ServiceConfigAudit = { export type ServiceConfigAudit = {
@@ -84,6 +85,7 @@ async function auditSystemdUnit(
code: "systemd-after-network-online", code: "systemd-after-network-online",
message: "Missing systemd After=network-online.target", message: "Missing systemd After=network-online.target",
detail: unitPath, detail: unitPath,
level: "recommended",
}); });
} }
if (!parsed.wants.has("network-online.target")) { if (!parsed.wants.has("network-online.target")) {
@@ -91,6 +93,7 @@ async function auditSystemdUnit(
code: "systemd-wants-network-online", code: "systemd-wants-network-online",
message: "Missing systemd Wants=network-online.target", message: "Missing systemd Wants=network-online.target",
detail: unitPath, detail: unitPath,
level: "recommended",
}); });
} }
if (!isRestartSecPreferred(parsed.restartSec)) { if (!isRestartSecPreferred(parsed.restartSec)) {
@@ -98,6 +101,7 @@ async function auditSystemdUnit(
code: "systemd-restart-sec", code: "systemd-restart-sec",
message: "RestartSec does not match the recommended 5s", message: "RestartSec does not match the recommended 5s",
detail: unitPath, detail: unitPath,
level: "recommended",
}); });
} }
} }
@@ -121,6 +125,7 @@ async function auditLaunchdPlist(
code: "launchd-run-at-load", code: "launchd-run-at-load",
message: "LaunchAgent is missing RunAtLoad=true", message: "LaunchAgent is missing RunAtLoad=true",
detail: plistPath, detail: plistPath,
level: "recommended",
}); });
} }
if (!hasKeepAlive) { if (!hasKeepAlive) {
@@ -128,6 +133,7 @@ async function auditLaunchdPlist(
code: "launchd-keep-alive", code: "launchd-keep-alive",
message: "LaunchAgent is missing KeepAlive=true", message: "LaunchAgent is missing KeepAlive=true",
detail: plistPath, detail: plistPath,
level: "recommended",
}); });
} }
} }
@@ -141,6 +147,7 @@ function auditGatewayCommand(
issues.push({ issues.push({
code: "gateway-command-missing", code: "gateway-command-missing",
message: "Service command does not include the gateway subcommand", message: "Service command does not include the gateway subcommand",
level: "aggressive",
}); });
} }
} }

View File

@@ -302,6 +302,31 @@ describe("sendMessageTelegram", () => {
}); });
}); });
it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => {
const chatId = "-1001234567890";
const sendMessage = vi.fn().mockResolvedValue({
message_id: 55,
chat: { id: chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await sendMessageTelegram(
`telegram:group:${chatId}:topic:271`,
"hello forum",
{
token: "tok",
api,
},
);
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello forum", {
parse_mode: "HTML",
message_thread_id: 271,
});
});
it("includes reply_to_message_id for threaded replies", async () => { it("includes reply_to_message_id for threaded replies", async () => {
const chatId = "123"; const chatId = "123";
const sendMessage = vi.fn().mockResolvedValue({ const sendMessage = vi.fn().mockResolvedValue({

View File

@@ -11,6 +11,10 @@ import { loadWebMedia } from "../web/media.js";
import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramFetch } from "./fetch.js"; import { resolveTelegramFetch } from "./fetch.js";
import { markdownToTelegramHtml } from "./format.js"; import { markdownToTelegramHtml } from "./format.js";
import {
parseTelegramTarget,
stripTelegramInternalPrefixes,
} from "./targets.js";
type TelegramSendOpts = { type TelegramSendOpts = {
token?: string; token?: string;
@@ -65,7 +69,7 @@ function normalizeChatId(to: string): string {
// Common internal prefixes that sometimes leak into outbound sends. // Common internal prefixes that sometimes leak into outbound sends.
// - ctx.To uses `telegram:<id>` // - ctx.To uses `telegram:<id>`
// - group sessions often use `telegram:group:<id>` // - group sessions often use `telegram:group:<id>`
let normalized = trimmed.replace(/^(telegram|tg|group):/i, "").trim(); let normalized = stripTelegramInternalPrefixes(trimmed);
// Accept t.me links for public chats/channels. // Accept t.me links for public chats/channels.
// (Invite links like `t.me/+...` are not resolvable via Bot API.) // (Invite links like `t.me/+...` are not resolvable via Bot API.)
@@ -110,7 +114,8 @@ export async function sendMessageTelegram(
accountId: opts.accountId, accountId: opts.accountId,
}); });
const token = resolveToken(opts.token, account); const token = resolveToken(opts.token, account);
const chatId = normalizeChatId(to); const target = parseTelegramTarget(to);
const chatId = normalizeChatId(target.chatId);
// Use provided api or create a new Bot instance. The nullish coalescing // Use provided api or create a new Bot instance. The nullish coalescing
// operator ensures api is always defined (Bot.api is always non-null). // operator ensures api is always defined (Bot.api is always non-null).
const fetchImpl = resolveTelegramFetch(); const fetchImpl = resolveTelegramFetch();
@@ -123,8 +128,12 @@ export async function sendMessageTelegram(
// Build optional params for forum topics and reply threading. // Build optional params for forum topics and reply threading.
// Only include these if actually provided to keep API calls clean. // Only include these if actually provided to keep API calls clean.
const threadParams: Record<string, number> = {}; const threadParams: Record<string, number> = {};
if (opts.messageThreadId != null) { const messageThreadId =
threadParams.message_thread_id = Math.trunc(opts.messageThreadId); opts.messageThreadId != null
? opts.messageThreadId
: target.messageThreadId;
if (messageThreadId != null) {
threadParams.message_thread_id = Math.trunc(messageThreadId);
} }
if (opts.replyToMessageId != null) { if (opts.replyToMessageId != null) {
threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId); threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId);

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
parseTelegramTarget,
stripTelegramInternalPrefixes,
} from "./targets.js";
describe("stripTelegramInternalPrefixes", () => {
it("strips telegram prefix", () => {
expect(stripTelegramInternalPrefixes("telegram:123")).toBe("123");
});
it("strips telegram+group prefixes", () => {
expect(stripTelegramInternalPrefixes("telegram:group:-100123")).toBe(
"-100123",
);
});
it("is idempotent", () => {
expect(stripTelegramInternalPrefixes("@mychannel")).toBe("@mychannel");
});
});
describe("parseTelegramTarget", () => {
it("parses plain chatId", () => {
expect(parseTelegramTarget("-1001234567890")).toEqual({
chatId: "-1001234567890",
});
});
it("parses @username", () => {
expect(parseTelegramTarget("@mychannel")).toEqual({
chatId: "@mychannel",
});
});
it("parses chatId:topicId format", () => {
expect(parseTelegramTarget("-1001234567890:123")).toEqual({
chatId: "-1001234567890",
messageThreadId: 123,
});
});
it("parses chatId:topic:topicId format", () => {
expect(parseTelegramTarget("-1001234567890:topic:456")).toEqual({
chatId: "-1001234567890",
messageThreadId: 456,
});
});
it("trims whitespace", () => {
expect(parseTelegramTarget(" -1001234567890:99 ")).toEqual({
chatId: "-1001234567890",
messageThreadId: 99,
});
});
it("does not treat non-numeric suffix as topicId", () => {
expect(parseTelegramTarget("-1001234567890:abc")).toEqual({
chatId: "-1001234567890:abc",
});
});
it("strips internal prefixes before parsing", () => {
expect(
parseTelegramTarget("telegram:group:-1001234567890:topic:456"),
).toEqual({
chatId: "-1001234567890",
messageThreadId: 456,
});
});
});

43
src/telegram/targets.ts Normal file
View File

@@ -0,0 +1,43 @@
export type TelegramTarget = {
chatId: string;
messageThreadId?: number;
};
export function stripTelegramInternalPrefixes(to: string): string {
let trimmed = to.trim();
while (true) {
const next = trimmed.replace(/^(telegram|tg|group):/i, "").trim();
if (next === trimmed) return trimmed;
trimmed = next;
}
}
/**
* Parse a Telegram delivery target into chatId and optional topic/thread ID.
*
* Supported formats:
* - `chatId` (plain chat ID, t.me link, @username, or internal prefixes like `telegram:...`)
* - `chatId:topicId` (numeric topic/thread ID)
* - `chatId:topic:topicId` (explicit topic marker; preferred)
*/
export function parseTelegramTarget(to: string): TelegramTarget {
const normalized = stripTelegramInternalPrefixes(to);
const topicMatch = /^(.+?):topic:(\d+)$/.exec(normalized);
if (topicMatch) {
return {
chatId: topicMatch[1],
messageThreadId: Number.parseInt(topicMatch[2], 10),
};
}
const colonMatch = /^(.+):(\d+)$/.exec(normalized);
if (colonMatch) {
return {
chatId: colonMatch[1],
messageThreadId: Number.parseInt(colonMatch[2], 10),
};
}
return { chatId: normalized };
}

View File

@@ -118,6 +118,25 @@ export async function monitorWebInbox(options: {
{ subject?: string; participants?: string[]; expires: number } { subject?: string; participants?: string[]; expires: number }
>(); >();
const GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes const GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes
const lidLookup = sock.signalRepository?.lidMapping;
const resolveJidToE164 = async (
jid: string | null | undefined,
): Promise<string | null> => {
if (!jid) return null;
const direct = jidToE164(jid);
if (direct) return direct;
if (!/(@lid|@hosted\.lid)$/.test(jid)) return null;
if (!lidLookup?.getPNForLID) return null;
try {
const pnJid = await lidLookup.getPNForLID(jid);
if (!pnJid) return null;
return jidToE164(pnJid);
} catch (err) {
logVerbose(`LID mapping lookup failed for ${jid}: ${String(err)}`);
return null;
}
};
const getGroupMeta = async (jid: string) => { const getGroupMeta = async (jid: string) => {
const cached = groupMetaCache.get(jid); const cached = groupMetaCache.get(jid);
@@ -125,9 +144,14 @@ export async function monitorWebInbox(options: {
try { try {
const meta = await sock.groupMetadata(jid); const meta = await sock.groupMetadata(jid);
const participants = const participants =
meta.participants (
?.map((p) => jidToE164(p.id) ?? p.id) await Promise.all(
.filter(Boolean) ?? []; meta.participants?.map(async (p) => {
const mapped = await resolveJidToE164(p.id);
return mapped ?? p.id;
}) ?? [],
)
).filter(Boolean) ?? [];
const entry = { const entry = {
subject: meta.subject, subject: meta.subject,
participants, participants,
@@ -159,12 +183,12 @@ export async function monitorWebInbox(options: {
continue; continue;
const group = isJidGroup(remoteJid); const group = isJidGroup(remoteJid);
const participantJid = msg.key?.participant ?? undefined; const participantJid = msg.key?.participant ?? undefined;
const from = group ? remoteJid : jidToE164(remoteJid); const from = group ? remoteJid : await resolveJidToE164(remoteJid);
// Skip if we still can't resolve an id to key conversation // Skip if we still can't resolve an id to key conversation
if (!from) continue; if (!from) continue;
const senderE164 = group const senderE164 = group
? participantJid ? participantJid
? jidToE164(participantJid) ? await resolveJidToE164(participantJid)
: null : null
: from; : from;
let groupSubject: string | undefined; let groupSubject: string | undefined;

View File

@@ -50,6 +50,11 @@ vi.mock("./session.js", () => {
readMessages: vi.fn().mockResolvedValue(undefined), readMessages: vi.fn().mockResolvedValue(undefined),
updateMediaMessage: vi.fn(), updateMediaMessage: vi.fn(),
logger: {}, logger: {},
signalRepository: {
lidMapping: {
getPNForLID: vi.fn().mockResolvedValue(null),
},
},
user: { id: "123@s.whatsapp.net" }, user: { id: "123@s.whatsapp.net" },
}; };
return { return {
@@ -136,6 +141,89 @@ describe("web monitor inbox", () => {
await listener.close(); await listener.close();
}); });
it("resolves LID JIDs using Baileys LID mapping store", async () => {
const onMessage = vi.fn(async () => {
return;
});
const listener = await monitorWebInbox({ verbose: false, onMessage });
const sock = await createWaSocket();
const getPNForLID = vi.spyOn(
sock.signalRepository.lidMapping,
"getPNForLID",
);
sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce(
"999:0@s.whatsapp.net",
);
const upsert = {
type: "notify",
messages: [
{
key: { id: "abc", fromMe: false, remoteJid: "999@lid" },
message: { conversation: "ping" },
messageTimestamp: 1_700_000_000,
pushName: "Tester",
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
expect(getPNForLID).toHaveBeenCalledWith("999@lid");
expect(onMessage).toHaveBeenCalledWith(
expect.objectContaining({ body: "ping", from: "+999", to: "+123" }),
);
await listener.close();
});
it("resolves group participant LID JIDs via Baileys mapping", async () => {
const onMessage = vi.fn(async () => {
return;
});
const listener = await monitorWebInbox({ verbose: false, onMessage });
const sock = await createWaSocket();
const getPNForLID = vi.spyOn(
sock.signalRepository.lidMapping,
"getPNForLID",
);
sock.signalRepository.lidMapping.getPNForLID.mockResolvedValueOnce(
"444:0@s.whatsapp.net",
);
const upsert = {
type: "notify",
messages: [
{
key: {
id: "abc",
fromMe: false,
remoteJid: "123@g.us",
participant: "444@lid",
},
message: { conversation: "ping" },
messageTimestamp: 1_700_000_000,
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
expect(getPNForLID).toHaveBeenCalledWith("444@lid");
expect(onMessage).toHaveBeenCalledWith(
expect.objectContaining({
body: "ping",
from: "123@g.us",
senderE164: "+444",
chatType: "group",
}),
);
await listener.close();
});
it("does not block follow-up messages when handler is pending", async () => { it("does not block follow-up messages when handler is pending", async () => {
let resolveFirst: (() => void) | null = null; let resolveFirst: (() => void) | null = null;
const onMessage = vi.fn(async () => { const onMessage = vi.fn(async () => {