diff --git a/AGENTS.md b/AGENTS.md index 4ffde6349..021a8a9c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,6 +81,8 @@ - **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:** 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. - 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e63120e..64270e473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,15 @@ ## 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 +- Cron: parse Telegram topic targets for isolated delivery. (#478) — thanks @nachoiacovino - Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos - Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot - 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 +- 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: block reply ordering fix (duplicate PR superseded by #503). (#483) — thanks @AbhisekBasu1 - 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. - Telegram: retry long-polling conflicts with backoff to avoid fatal exits. - Telegram: fix grammY fetch type mismatch when injecting `fetch`. (#512) — thanks @YuriNachos +- WhatsApp: resolve @lid JIDs via Baileys mapping to unblock inbound messages. (#415) - Agent system prompt: avoid automatic self-updates unless explicitly requested. - Onboarding: tighten QuickStart hint copy for configuring later. - Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic. diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index a4ad5d5c8..0b9fdca23 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -6,62 +6,85 @@ read_when: --- # Cron jobs (Gateway scheduler) -Cron runs inside the Gateway and schedules background work so Clawdbot can -wake itself up, run isolated agent jobs, and deliver reminders on time. +Cron is the Gateway’s built-in scheduler. It persists jobs, wakes the agent at +the right time, and can optionally deliver output back to a chat. -## Update checklist (internal) -- [x] Audit cron + heartbeat behavior in code -- [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) +If you want *“run this every morning”* or *“poke the agent in 20 minutes”*, +cron is the mechanism. -## What cron is -- **Gateway-owned scheduler** that persists jobs under `~/.clawdbot/cron/`. -- **Two execution modes**: - - **Main session jobs** enqueue `System:` events and rely on the heartbeat runner. - - **Isolated jobs** run a dedicated agent turn in `cron:` sessions. -- **Wakeups** are first-class: a job can trigger the next heartbeat or run it now. +## TL;DR +- Cron runs **inside the Gateway** (not inside the model). +- Jobs persist under `~/.clawdbot/cron/` so restarts don’t lose schedules. +- Two execution styles: + - **Main session**: enqueue a system event, then run on the next heartbeat. + - **Isolated**: run a dedicated agent turn in `cron:`, optionally deliver output. +- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”. -## When to use it -- 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. +## Concepts -## 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: -- `at`: one-shot timestamp in ms. +- `at`: one-shot timestamp (ms since epoch). - `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 -server’s local timezone is used. +Cron expressions use `croner`. If a timezone is omitted, the Gateway host’s +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. -They **must** use `payload.kind = "systemEvent"`. +They must use `payload.kind = "systemEvent"`. -- **`wakeMode: "next-heartbeat"`** (default): the event waits for the next - scheduled heartbeat. -- **`wakeMode: "now"`**: the event triggers an immediate heartbeat run. +- `wakeMode: "next-heartbeat"` (default): event waits for the next scheduled heartbeat. +- `wakeMode: "now"`: event triggers an immediate heartbeat run. -### Isolated jobs -Isolated jobs run a dedicated agent turn in session `cron:` and can -optionally deliver a message. +This is the best fit when you want the normal heartbeat prompt + main-session context. +See [Heartbeat](/gateway/heartbeat). + +#### Isolated jobs (dedicated cron sessions) +Isolated jobs run a dedicated agent turn in session `cron:`. Key behaviors: - Prompt is prefixed with `[cron: ]` for traceability. -- A summary is posted to the main session with prefix `Cron` (or - `isolation.postToMainPrefix`). +- A summary is posted to the main session (prefix `Cron`, configurable). - `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 shouldn’t 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 session’s “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 -- Job store: `~/.clawdbot/cron/jobs.json` (JSON, Gateway-managed). +- Job store: `~/.clawdbot/cron/jobs.json` (Gateway-managed JSON). - Run history: `~/.clawdbot/cron/runs/.jsonl` (JSONL, auto-pruned). - Override store path: `cron.store` in config. @@ -70,16 +93,16 @@ Key behaviors: ```json5 { cron: { - enabled: true, // default true + enabled: true, // default true store: "~/.clawdbot/cron/jobs.json", - maxConcurrentRuns: 1 // default 1 + maxConcurrentRuns: 1 // default 1 } } ``` Disable cron entirely: - `cron.enabled: false` (config) -- or `CLAWDBOT_SKIP_CRON=1` (env) +- `CLAWDBOT_SKIP_CRON=1` (env) ## CLI quickstart @@ -106,6 +129,19 @@ clawdbot cron add \ --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): ```bash clawdbot cron run --force @@ -121,12 +157,19 @@ Immediate wake without creating a job: 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.run` (force or due), `cron.runs` - `wake` (enqueue system event + optional heartbeat) -## Tips -- Use **main session jobs** when you want the heartbeat prompt + existing context. -- Use **isolated jobs** for noisy, frequent, or long-running work. -- Keep messages short; cron turns are full agent runs and can burn tokens. +## Troubleshooting + +### “Nothing runs” +- 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:` so it’s explicit and unambiguous. +- If you see `telegram:...` prefixes in logs or stored “last route” targets, that’s normal; + cron delivery accepts them and still parses topic IDs correctly. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 97d3aa525..88ca0361a 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1176,6 +1176,8 @@ per session key at a time). Default: 1. Optional **Docker sandboxing** for the embedded agent. Intended for non-main sessions so they cannot access your host system. +Details: [Sandboxing](/gateway/sandboxing) + Defaults (if enabled): - scope: `"agent"` (one container + workspace per agent) - Debian bookworm-slim based image diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 2bfc221ab..15d4aa4d4 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -23,11 +23,24 @@ clawdbot doctor --yes 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 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. +Legacy state migrations run automatically when detected. ```bash clawdbot doctor --deep @@ -47,6 +60,7 @@ cat ~/.clawdbot/clawdbot.json - Legacy config migration and normalization. - Legacy on-disk state migration (sessions/agent dir/WhatsApp auth). - State integrity and permissions checks (sessions, transcripts, state dir). +- Config file permission checks (chmod 600) when running locally. - Legacy workspace dir detection (`~/clawdis`, `~/clawdbot`). - Sandbox image repair when sandboxing is enabled. - Legacy service migration and extra gateway detection. @@ -117,6 +131,8 @@ Doctor checks: split between installs). - **Remote mode reminder**: if `gateway.mode=remote`, doctor reminds you to run 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 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 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 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 diff --git a/docs/gateway/index.md b/docs/gateway/index.md index 9179b1c13..2b232303a 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -156,6 +156,8 @@ See also: [`docs/presence.md`](/concepts/presence) for how presence is produced/ - StandardOut/Err: file paths or `syslog` - 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). + - `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) diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md new file mode 100644 index 000000000..4ae1fa662 --- /dev/null +++ b/docs/gateway/sandboxing.md @@ -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 doesn’t 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) diff --git a/docs/gateway/security.md b/docs/gateway/security.md index e09347746..3dda917b7 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -95,6 +95,14 @@ This is social engineering 101. Create distrust, encourage snooping. ## 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 ```json5 @@ -138,10 +146,12 @@ We may add a single `readOnlyMode` flag later to simplify this configuration. ## Sandboxing (recommended) +Dedicated doc: [Sandboxing](/gateway/sandboxing) + Two complementary approaches: - **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) or `"session"` for stricter per-session isolation. `scope: "shared"` uses a diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index f7139ce33..f1295cb0c 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -55,6 +55,10 @@ the Gateway likely refused to bind. - If they don’t, you’re 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. +**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”** - 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. diff --git a/docs/install/docker.md b/docs/install/docker.md index 29c934798..1c47cb57b 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -9,10 +9,18 @@ read_when: 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**: you’re 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: - Containerized Gateway (full Clawdbot in Docker) - Per-session Agent Sandbox (host gateway + Docker-isolated agent tools) +Sandboxing details: [Sandboxing](/gateway/sandboxing) + ## Requirements - Docker Desktop (or Docker Engine) + Docker Compose v2 @@ -33,6 +41,11 @@ This script: - runs the onboarding wizard - prints optional provider setup hints - 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: - `~/.clawdbot/` @@ -92,6 +105,8 @@ pnpm test:docker:qr ## Agent Sandbox (host gateway + Docker tools) +Deep dive: [Sandboxing](/gateway/sandboxing) + ### What it does When `agent.sandbox` is enabled, **non-main sessions** run tools inside a Docker diff --git a/docs/multi-agent-sandbox-tools.md b/docs/multi-agent-sandbox-tools.md index b9ee2bcdc..5b5604e95 100644 --- a/docs/multi-agent-sandbox-tools.md +++ b/docs/multi-agent-sandbox-tools.md @@ -18,6 +18,8 @@ This allows you to run multiple agents with different security profiles: - Family/work agents with restricted tools - Public-facing agents in sandboxes +For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing). + --- ## Configuration Examples diff --git a/docs/platforms/exe-dev.md b/docs/platforms/exe-dev.md index b2ccae4a9..b07514eb7 100644 --- a/docs/platforms/exe-dev.md +++ b/docs/platforms/exe-dev.md @@ -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. +If you’re on any other Linux VPS, the same steps apply — you just won’t 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 - exe.dev account + `ssh exe.dev` working on your laptop diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md index 1c4913983..e83f9db4e 100644 --- a/docs/platforms/linux.md +++ b/docs/platforms/linux.md @@ -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. +## 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 @` +5) Open `http://127.0.0.1:18789/` and paste your token + +Step-by-step VPS guide: [exe.dev](/platforms/exe-dev) + ## Install - [Getting Started](/start/getting-started) - [Install & updates](/install/updating) @@ -35,12 +45,6 @@ clawdbot daemon install Or: -``` -clawdbot daemon install -``` - -Or: - ``` clawdbot configure ``` diff --git a/docs/platforms/mac/bun.md b/docs/platforms/mac/bun.md index 629d56a44..3b08a4ad7 100644 --- a/docs/platforms/mac/bun.md +++ b/docs/platforms/mac/bun.md @@ -63,6 +63,8 @@ Manager: Behavior: - “Clawdbot Active” enables/disables the LaunchAgent. - 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: - launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log` diff --git a/docs/platforms/windows.md b/docs/platforms/windows.md index ce55d0076..ce06086a4 100644 --- a/docs/platforms/windows.md +++ b/docs/platforms/windows.md @@ -36,12 +36,6 @@ clawdbot daemon install Or: -``` -clawdbot daemon install -``` - -Or: - ``` clawdbot configure ``` diff --git a/docs/start/faq.md b/docs/start/faq.md index 67540700f..a02d226a7 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -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. +### 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 does Clawdbot store its data? diff --git a/docs/start/hubs.md b/docs/start/hubs.md index dc6d27644..18ddda1b4 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -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) - [Doctor](https://docs.clawd.bot/gateway/doctor) - [Logging](https://docs.clawd.bot/gateway/logging) +- [Sandboxing](https://docs.clawd.bot/gateway/sandboxing) - [Dashboard](https://docs.clawd.bot/web/dashboard) - [Control UI](https://docs.clawd.bot/web/control-ui) - [Remote access](https://docs.clawd.bot/gateway/remote) diff --git a/src/agents/pi-embedded-helpers.test.ts b/src/agents/pi-embedded-helpers.test.ts index b88142c6a..df8c5c019 100644 --- a/src/agents/pi-embedded-helpers.test.ts +++ b/src/agents/pi-embedded-helpers.test.ts @@ -8,6 +8,7 @@ import { isMessagingToolDuplicate, normalizeTextForComparison, sanitizeGoogleTurnOrdering, + sanitizeSessionMessagesImages, validateGeminiTurns, } from "./pi-embedded-helpers.js"; 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", () => { it("lowercases text", () => { expect(normalizeTextForComparison("Hello World")).toBe("hello world"); diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 2d3941006..bbfd8e0c4 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -99,6 +99,28 @@ export async function sanitizeSessionMessagesImages( } } + if (role === "assistant") { + const assistantMsg = msg as Extract; + 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); } return out; diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index f135fc868..f2652f1d0 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -547,14 +547,14 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { defaultRuntime.log(`Daemon env: ${daemonEnvLines.join(" ")}`); } if (service.configAudit?.issues.length) { - defaultRuntime.error( - "Service config looks out of date or non-standard.", - ); + defaultRuntime.error("Service config looks out of date or non-standard."); for (const issue of service.configAudit.issues) { const detail = issue.detail ? ` (${issue.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) { const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`; diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 03ac40fda..7168a8054 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -74,6 +74,13 @@ function parsePort(raw: unknown): number | null { 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 { if (err instanceof Error) return err.message; if (typeof err === "string") return err; @@ -338,9 +345,10 @@ async function runGatewayCommand( } } 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 = authModeRaw === "token" || authModeRaw === "password" ? authModeRaw : null; if (authModeRaw && !authMode) { @@ -348,7 +356,7 @@ async function runGatewayCommand( defaultRuntime.exit(1); return; } - const tailscaleRaw = opts.tailscale ? String(opts.tailscale) : undefined; + const tailscaleRaw = toOptionString(opts.tailscale); const tailscaleMode = tailscaleRaw === "off" || tailscaleRaw === "serve" || @@ -362,6 +370,8 @@ async function runGatewayCommand( defaultRuntime.exit(1); return; } + const passwordRaw = toOptionString(opts.password); + const tokenRaw = toOptionString(opts.token); const configExists = fs.existsSync(CONFIG_PATH_CLAWDBOT); const mode = cfg.gateway?.mode; if (!opts.allowUnconfigured && mode !== "local") { @@ -377,7 +387,7 @@ async function runGatewayCommand( defaultRuntime.exit(1); return; } - const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback"); + const bindRaw = toOptionString(opts.bind) ?? cfg.gateway?.bind ?? "loopback"; const bind = bindRaw === "loopback" || bindRaw === "tailnet" || @@ -398,8 +408,8 @@ async function runGatewayCommand( const authConfig = { ...cfg.gateway?.auth, ...(authMode ? { mode: authMode } : {}), - ...(opts.password ? { password: String(opts.password) } : {}), - ...(opts.token ? { token: String(opts.token) } : {}), + ...(passwordRaw ? { password: passwordRaw } : {}), + ...(tokenRaw ? { token: tokenRaw } : {}), }; const resolvedAuth = resolveGatewayAuth({ authConfig, @@ -467,11 +477,11 @@ async function runGatewayCommand( await startGatewayServer(port, { bind, auth: - authMode || opts.password || opts.token || authModeRaw + authMode || passwordRaw || tokenRaw || authModeRaw ? { mode: authMode ?? undefined, - token: opts.token ? String(opts.token) : undefined, - password: opts.password ? String(opts.password) : undefined, + token: tokenRaw, + password: passwordRaw, } : undefined, tailscale: diff --git a/src/cli/program.ts b/src/cli/program.ts index 8c09a5758..827e3bc0b 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -328,6 +328,12 @@ export function buildProgram() { 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( "--non-interactive", "Run without prompts (safe migrations only)", @@ -339,6 +345,8 @@ export function buildProgram() { await doctorCommand(defaultRuntime, { workspaceSuggestions: opts.workspaceSuggestions, yes: Boolean(opts.yes), + repair: Boolean(opts.repair), + force: Boolean(opts.force), nonInteractive: Boolean(opts.nonInteractive), deep: Boolean(opts.deep), }); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 015fadb94..9087c6a19 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -142,7 +142,12 @@ export async function maybeRepairGatewayServiceConfig( } const service = resolveGatewayService(); - const command = await service.readCommand(process.env).catch(() => null); + let command: Awaited> | null = null; + try { + command = await service.readCommand(process.env); + } catch { + command = null; + } if (!command) return; const audit = await auditGatewayServiceConfig({ @@ -154,16 +159,39 @@ export async function maybeRepairGatewayServiceConfig( note( audit.issues .map((issue) => - issue.detail ? `- ${issue.message} (${issue.detail})` : `- ${issue.message}`, + issue.detail + ? `- ${issue.message} (${issue.detail})` + : `- ${issue.message}`, ) .join("\n"), "Gateway service config", ); - const repair = await prompter.confirmSkipInNonInteractive({ - message: "Update gateway service config to the recommended defaults now?", - initialValue: true, - }); + const aggressiveIssues = audit.issues.filter( + (issue) => issue.level === "aggressive", + ); + const _recommendedIssues = audit.issues.filter( + (issue) => issue.level !== "aggressive", + ); + const needsAggressive = aggressiveIssues.length > 0; + + if (needsAggressive && !prompter.shouldForce) { + 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; const devMode = diff --git a/src/commands/doctor-prompter.ts b/src/commands/doctor-prompter.ts index 66476399b..ad085d99d 100644 --- a/src/commands/doctor-prompter.ts +++ b/src/commands/doctor-prompter.ts @@ -8,14 +8,22 @@ export type DoctorOptions = { yes?: boolean; nonInteractive?: boolean; deep?: boolean; + repair?: boolean; + force?: boolean; }; export type DoctorPrompter = { confirm: (params: Parameters[0]) => Promise; + confirmRepair: (params: Parameters[0]) => Promise; + confirmAggressive: ( + params: Parameters[0], + ) => Promise; confirmSkipInNonInteractive: ( params: Parameters[0], ) => Promise; select: (params: Parameters[0], fallback: T) => Promise; + shouldRepair: boolean; + shouldForce: boolean; }; export function createDoctorPrompter(params: { @@ -24,24 +32,42 @@ export function createDoctorPrompter(params: { }): DoctorPrompter { const yes = params.options.yes === 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 nonInteractive = requestedNonInteractive || (!isTty && !yes); const canPrompt = isTty && !yes && !nonInteractive; const confirmDefault = async (p: Parameters[0]) => { + if (nonInteractive) return false; + if (shouldRepair) return true; if (!canPrompt) return Boolean(p.initialValue ?? false); return guardCancel(await confirm(p), params.runtime) === true; }; return { confirm: confirmDefault, - confirmSkipInNonInteractive: async (p) => { + confirmRepair: async (p) => { if (nonInteractive) return false; 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 (p: Parameters[0], fallback: T) => { - if (!canPrompt) return fallback; + if (!canPrompt || shouldRepair) return fallback; return guardCancel(await select(p), params.runtime) as T; }, + shouldRepair, + shouldForce, }; } diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 49ad12520..195b6f520 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -123,6 +123,7 @@ function findOtherStateDirs(stateDir: string): string[] { export async function noteStateIntegrity( cfg: ClawdbotConfig, prompter: DoctorPrompterLike, + configPath?: string, ) { const warnings: 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) { const dirCandidates = new Map(); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index d999d41e4..a415609a7 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -129,10 +129,13 @@ export async function doctorCommand( const legacyState = await detectLegacyStateMigrations({ cfg }); if (legacyState.preview.length > 0) { note(legacyState.preview.join("\n"), "Legacy state detected"); - const migrate = await prompter.confirm({ - message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?", - initialValue: true, - }); + const migrate = + options.nonInteractive === true + ? true + : await prompter.confirm({ + message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?", + initialValue: true, + }); if (migrate) { const migrated = await runLegacyStateMigrations({ 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); noteSandboxScopeWarnings(cfg); diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index dc37e762e..c0364fc91 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -20,10 +20,7 @@ vi.mock("../agents/model-catalog.js", () => ({ import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; -import { - parseTelegramTarget, - runCronIsolatedAgentTurn, -} from "./isolated-agent.js"; +import { runCronIsolatedAgentTurn } from "./isolated-agent.js"; async function withTempHome(fn: (home: string) => Promise): Promise { 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 () => { await withTempHome(async (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, - }); - }); -}); diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index fa093719f..3488fcd5f 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -46,49 +46,11 @@ import { saveSessionStore, } from "../config/sessions.js"; import { registerAgentRunContext } from "../infra/agent-events.js"; +import { parseTelegramTarget } from "../telegram/targets.js"; import { resolveTelegramToken } from "../telegram/token.js"; import { normalizeE164 } from "../utils.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:` / `:` 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 = { status: "ok" | "error" | "skipped"; summary?: string; @@ -526,7 +488,9 @@ export async function runCronIsolatedAgentTurn(params: { 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"); try { for (const payload of payloads) { @@ -540,7 +504,7 @@ export async function runCronIsolatedAgentTurn(params: { await params.deps.sendMessageTelegram(chatId, chunk, { verbose: false, token: telegramToken || undefined, - messageThreadId: topicId, + messageThreadId, }); } } else { @@ -552,7 +516,7 @@ export async function runCronIsolatedAgentTurn(params: { verbose: false, mediaUrl: url, token: telegramToken || undefined, - messageThreadId: topicId, + messageThreadId, }); } } diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index c8ae0c8b8..feb28dc4a 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -13,6 +13,7 @@ export type ServiceConfigIssue = { code: string; message: string; detail?: string; + level?: "recommended" | "aggressive"; }; export type ServiceConfigAudit = { @@ -84,6 +85,7 @@ async function auditSystemdUnit( code: "systemd-after-network-online", message: "Missing systemd After=network-online.target", detail: unitPath, + level: "recommended", }); } if (!parsed.wants.has("network-online.target")) { @@ -91,6 +93,7 @@ async function auditSystemdUnit( code: "systemd-wants-network-online", message: "Missing systemd Wants=network-online.target", detail: unitPath, + level: "recommended", }); } if (!isRestartSecPreferred(parsed.restartSec)) { @@ -98,6 +101,7 @@ async function auditSystemdUnit( code: "systemd-restart-sec", message: "RestartSec does not match the recommended 5s", detail: unitPath, + level: "recommended", }); } } @@ -121,6 +125,7 @@ async function auditLaunchdPlist( code: "launchd-run-at-load", message: "LaunchAgent is missing RunAtLoad=true", detail: plistPath, + level: "recommended", }); } if (!hasKeepAlive) { @@ -128,6 +133,7 @@ async function auditLaunchdPlist( code: "launchd-keep-alive", message: "LaunchAgent is missing KeepAlive=true", detail: plistPath, + level: "recommended", }); } } @@ -141,6 +147,7 @@ function auditGatewayCommand( issues.push({ code: "gateway-command-missing", message: "Service command does not include the gateway subcommand", + level: "aggressive", }); } } diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index ed192f85d..dfcb5c7f6 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -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 () => { const chatId = "123"; const sendMessage = vi.fn().mockResolvedValue({ diff --git a/src/telegram/send.ts b/src/telegram/send.ts index d0715c22c..4a72747fa 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -11,6 +11,10 @@ import { loadWebMedia } from "../web/media.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramFetch } from "./fetch.js"; import { markdownToTelegramHtml } from "./format.js"; +import { + parseTelegramTarget, + stripTelegramInternalPrefixes, +} from "./targets.js"; type TelegramSendOpts = { token?: string; @@ -65,7 +69,7 @@ function normalizeChatId(to: string): string { // Common internal prefixes that sometimes leak into outbound sends. // - ctx.To uses `telegram:` // - group sessions often use `telegram:group:` - let normalized = trimmed.replace(/^(telegram|tg|group):/i, "").trim(); + let normalized = stripTelegramInternalPrefixes(trimmed); // Accept t.me links for public chats/channels. // (Invite links like `t.me/+...` are not resolvable via Bot API.) @@ -110,7 +114,8 @@ export async function sendMessageTelegram( accountId: opts.accountId, }); 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 // operator ensures api is always defined (Bot.api is always non-null). const fetchImpl = resolveTelegramFetch(); @@ -123,8 +128,12 @@ export async function sendMessageTelegram( // Build optional params for forum topics and reply threading. // Only include these if actually provided to keep API calls clean. const threadParams: Record = {}; - if (opts.messageThreadId != null) { - threadParams.message_thread_id = Math.trunc(opts.messageThreadId); + const messageThreadId = + opts.messageThreadId != null + ? opts.messageThreadId + : target.messageThreadId; + if (messageThreadId != null) { + threadParams.message_thread_id = Math.trunc(messageThreadId); } if (opts.replyToMessageId != null) { threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId); diff --git a/src/telegram/targets.test.ts b/src/telegram/targets.test.ts new file mode 100644 index 000000000..e2bbd6c9f --- /dev/null +++ b/src/telegram/targets.test.ts @@ -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, + }); + }); +}); diff --git a/src/telegram/targets.ts b/src/telegram/targets.ts new file mode 100644 index 000000000..fd0f0e379 --- /dev/null +++ b/src/telegram/targets.ts @@ -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 }; +} diff --git a/src/web/inbound.ts b/src/web/inbound.ts index c70b7ab47..dcd97224c 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -118,6 +118,25 @@ export async function monitorWebInbox(options: { { subject?: string; participants?: string[]; expires: number } >(); const GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes + const lidLookup = sock.signalRepository?.lidMapping; + + const resolveJidToE164 = async ( + jid: string | null | undefined, + ): Promise => { + 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 cached = groupMetaCache.get(jid); @@ -125,9 +144,14 @@ export async function monitorWebInbox(options: { try { const meta = await sock.groupMetadata(jid); const participants = - meta.participants - ?.map((p) => jidToE164(p.id) ?? p.id) - .filter(Boolean) ?? []; + ( + await Promise.all( + meta.participants?.map(async (p) => { + const mapped = await resolveJidToE164(p.id); + return mapped ?? p.id; + }) ?? [], + ) + ).filter(Boolean) ?? []; const entry = { subject: meta.subject, participants, @@ -159,12 +183,12 @@ export async function monitorWebInbox(options: { continue; const group = isJidGroup(remoteJid); 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 if (!from) continue; const senderE164 = group ? participantJid - ? jidToE164(participantJid) + ? await resolveJidToE164(participantJid) : null : from; let groupSubject: string | undefined; diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts index 3ae395d66..39eb206fa 100644 --- a/src/web/monitor-inbox.test.ts +++ b/src/web/monitor-inbox.test.ts @@ -50,6 +50,11 @@ vi.mock("./session.js", () => { readMessages: vi.fn().mockResolvedValue(undefined), updateMediaMessage: vi.fn(), logger: {}, + signalRepository: { + lidMapping: { + getPNForLID: vi.fn().mockResolvedValue(null), + }, + }, user: { id: "123@s.whatsapp.net" }, }; return { @@ -136,6 +141,89 @@ describe("web monitor inbox", () => { 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 () => { let resolveFirst: (() => void) | null = null; const onMessage = vi.fn(async () => {