diff --git a/CHANGELOG.md b/CHANGELOG.md index ae65b97fd..aec03170b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,17 @@ ## Unreleased +- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging. +- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj +- Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223 +- Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj - Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles. - Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence. - Agent: enable adaptive context pruning by default for tool-result trimming. - 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: add KillMode=process to systemd units to avoid podman restart hangs. (#541) — thanks @ogulcancelik - Doctor: run legacy state migrations in non-interactive mode without prompts. - 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 @@ -33,6 +38,7 @@ - Signal: accept UUID-only senders for pairing/allowlists/routing when sourceNumber is missing. (#523) — thanks @neist - Agent system prompt: avoid automatic self-updates unless explicitly requested. - Onboarding: tighten QuickStart hint copy for configuring later. +- Onboarding: set Gemini 3 Pro as the default model for Gemini API key auth. (#489) — thanks @jonasjancarik - Onboarding: avoid “token expired” for Codex CLI when expiry is heuristic. - Onboarding: QuickStart jumps straight into provider selection with Telegram preselected when unset. - Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker). @@ -43,11 +49,14 @@ - Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints. - Providers/Doctor: add last inbound/outbound activity timestamps in `providers status` and extend `--probe` with Discord channel permission + Telegram group membership audits. - Docs: add provider troubleshooting index (`/providers/troubleshooting`) and link it from the main troubleshooting guide. +- Docs: clarify model allowlist errors and add safety notes for verbose/reasoning in groups. +- Docs: add Ansible installation guide. (#545) — thanks @pasogott - Telegram: include the user id in DM pairing messages and label it clearly in `clawdbot pairing list --provider telegram`. - Apps: refresh iOS/Android/macOS app icons for Clawdbot branding. (#521) — thanks @fishfisher - Docs: expand parameter descriptions for agent/wake hooks. (#532) — thanks @mcinteerj - Docs: add community showcase entries from Discord. (#476) — thanks @gupsammy - TUI: refresh status bar after think/verbose/reasoning changes. (#519) — thanks @jdrhyne +- Commands: treat mention-bypassed group command messages as mentioned so elevated directives respond. ## 2026.1.8 diff --git a/README.md b/README.md index c9f19ce2e..f6e8ce38c 100644 --- a/README.md +++ b/README.md @@ -465,5 +465,5 @@ Thanks to all clawtributors: Clawd conhecendocontato erikpr1994 gtsifrikas hrdwdmrbl hugobarauna Jarvis jonasjancarik Jonathan D. Rhyne (DJ-D) Keith the Silly Goose Kit kitze kkarimi loukotal mrdbstn MSch nexty5870 ngutman onutc prathamdby reeltimeapps RLTCmpe Rolf Fredheim snopoke wstock YuriNachos Azade ddyo Erik Manuel Maly - Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres Tobias Bischoff William Stock + Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres Tobias Bischoff William Stock pasogott ogulcancelik

diff --git a/docs/automation/auth-monitoring.md b/docs/automation/auth-monitoring.md new file mode 100644 index 000000000..758c89265 --- /dev/null +++ b/docs/automation/auth-monitoring.md @@ -0,0 +1,41 @@ +--- +summary: "Monitor OAuth expiry for model providers" +read_when: + - Setting up auth expiry monitoring or alerts + - Automating Claude Code / Codex OAuth refresh checks +--- +# Auth monitoring + +Clawdbot exposes OAuth expiry health via `clawdbot models status`. Use that for +automation and alerting; scripts are optional extras for phone workflows. + +## Preferred: CLI check (portable) + +```bash +clawdbot models status --check +``` + +Exit codes: +- `0`: OK +- `1`: expired or missing credentials +- `2`: expiring soon (within 24h) + +This works in cron/systemd and requires no extra scripts. + +## Optional scripts (ops / phone workflows) + +These live under `scripts/` and are **optional**. They assume SSH access to the +gateway host and are tuned for systemd + Termux. + +- `scripts/claude-auth-status.sh` now uses `clawdbot models status --json` as the + source of truth (falling back to direct file reads if the CLI is unavailable), + so keep `clawdbot` on `PATH` for timers. +- `scripts/auth-monitor.sh`: cron/systemd timer target; sends alerts (ntfy or phone). +- `scripts/systemd/clawdbot-auth-monitor.{service,timer}`: systemd user timer. +- `scripts/claude-auth-status.sh`: Claude Code + Clawdbot auth checker (full/json/simple). +- `scripts/mobile-reauth.sh`: guided re‑auth flow over SSH. +- `scripts/termux-quick-auth.sh`: one‑tap widget status + open auth URL. +- `scripts/termux-auth-widget.sh`: full guided widget flow. +- `scripts/termux-sync-widget.sh`: sync Claude Code creds → Clawdbot. + +If you don’t need phone automation or systemd timers, skip these scripts. diff --git a/docs/cli/index.md b/docs/cli/index.md index 455e7cf3f..0e73e9120 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -20,6 +20,7 @@ This page describes the current CLI behavior. If commands change, update this do - ANSI colors and progress indicators only render in TTY sessions. - OSC-8 hyperlinks render as clickable links in supported terminals; otherwise we fall back to plain URLs. - `--json` (and `--plain` where supported) disables styling for clean output. +- `--no-color` disables ANSI styling where supported; `NO_COLOR=1` is also respected. - Long-running commands show a progress indicator (OSC 9;4 when supported). ## Color palette @@ -165,8 +166,9 @@ Options: - `--workspace ` - `--non-interactive` - `--mode ` -- `--auth-choice ` +- `--auth-choice ` - `--anthropic-api-key ` +- `--gemini-api-key ` - `--gateway-port ` - `--gateway-bind ` - `--gateway-auth ` @@ -442,10 +444,17 @@ Notes: ### `logs` Tail Gateway file logs via RPC. +Notes: +- TTY sessions render a colorized, structured view; non-TTY falls back to plain text. +- `--json` emits line-delimited JSON (one log event per line). + Examples: ```bash clawdbot logs --follow clawdbot logs --limit 200 +clawdbot logs --plain +clawdbot logs --json +clawdbot logs --no-color ``` ### `gateway ` @@ -479,6 +488,9 @@ Options: Options: - `--json` - `--plain` +- `--check` (exit 1=expired/missing, 2=expiring) + +Always includes the auth overview and OAuth expiry status for profiles in the auth store. ### `models set ` Set `agent.model.primary`. diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md index 83af0a43f..2b0ea5924 100644 --- a/docs/concepts/agent-workspace.md +++ b/docs/concepts/agent-workspace.md @@ -12,6 +12,11 @@ file tools and for workspace context. Keep it private and treat it as memory. This is separate from `~/.clawdbot/`, which stores config, credentials, and sessions. +**Important:** the workspace is the **default cwd**, not a hard sandbox. Tools +resolve relative paths against the workspace, but absolute paths can still reach +elsewhere on the host unless sandboxing is enabled. If you need isolation, use +[`agent.sandbox`](/gateway/sandboxing) (and/or per‑agent sandbox config). + ## Default location - Default: `~/clawd` diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 58a86aaed..67e786211 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -33,6 +33,37 @@ Related: Model refs are normalized to lowercase. Provider aliases like `z.ai/*` normalize to `zai/*`. +## “Model is not allowed” (and why replies stop) + +If `agent.models` is set, it becomes the **allowlist** for `/model` and for +session overrides. When a user selects a model that isn’t in that allowlist, +Clawdbot returns: + +``` +Model "provider/model" is not allowed. Use /model to list available models. +``` + +This happens **before** a normal reply is generated, so the message can feel +like it “didn’t respond.” The fix is to either: + +- Add the model to `agent.models`, or +- Clear the allowlist (remove `agent.models`), or +- Pick a model from `/model list`. + +Example allowlist config: + +```json5 +{ + agent: { + model: { primary: "anthropic/claude-sonnet-4-5" }, + models: { + "anthropic/claude-sonnet-4-5": { alias: "Sonnet" }, + "anthropic/claude-opus-4-5": { alias: "Opus" } + } + } +} +``` + ## CLI commands ```bash @@ -71,7 +102,14 @@ Shows configured models by default. Useful flags: ### `models status` Shows the resolved primary model, fallbacks, image model, and an auth overview -of configured providers. `--plain` prints only the resolved primary model. +of configured providers. It also surfaces OAuth expiry status for profiles found +in the auth store (warns within 24h by default). `--plain` prints only the +resolved primary model. +OAuth status is always shown (and included in `--json` output). If a configured +provider has no credentials, `models status` prints a **Missing auth** section. +JSON includes `auth.oauth` (warn window + profiles) and `auth.providers` +(effective auth per provider). +Use `--check` for automation (exit `1` when missing/expired, `2` when expiring). ## Scanning (OpenRouter free models) diff --git a/docs/concepts/multi-agent.md b/docs/concepts/multi-agent.md index 56a0521c8..67429b9e7 100644 --- a/docs/concepts/multi-agent.md +++ b/docs/concepts/multi-agent.md @@ -17,8 +17,16 @@ An **agent** is a fully scoped brain with its own: - **State directory** (`agentDir`) for auth profiles, model registry, and per-agent config. - **Session store** (chat history + routing state) under `~/.clawdbot/agents//sessions`. +Skills are per-agent via each workspace’s `skills/` folder, with shared skills +available from `~/.clawdbot/skills`. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills). + The Gateway can host **one agent** (default) or **many agents** side-by-side. +**Workspace note:** each agent’s workspace is the **default cwd**, not a hard +sandbox. Relative paths resolve inside the workspace, but absolute paths can +reach other host locations unless sandboxing is enabled. See +[Sandboxing](/gateway/sandboxing). + ## Paths (quick map) - Config: `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index 829984e69..15007bdcb 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -49,10 +49,13 @@ If you already signed in with the external CLIs *on the gateway host*, Clawdbot - Codex CLI: reads `~/.codex/auth.json` → profile `openai-codex:codex-cli` Sync happens when Clawdbot loads the auth store (so it stays up-to-date when the CLIs refresh tokens). +On macOS, the first read may trigger a Keychain prompt; run `clawdbot models status` +in a terminal once if the Gateway runs headless and can’t access the entry. How to verify: ```bash +clawdbot models status clawdbot providers list ``` diff --git a/docs/docs.json b/docs/docs.json index b89d8ac97..84c3179aa 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -97,6 +97,14 @@ "source": "/bun", "destination": "/install/bun" }, + { + "source": "/auth-monitoring", + "destination": "/automation/auth-monitoring" + }, + { + "source": "/scripts", + "destination": "/scripts" + }, { "source": "/camera", "destination": "/nodes/camera" @@ -576,6 +584,7 @@ "gateway/gateway-lock", "gateway/configuration", "gateway/configuration-examples", + "gateway/authentication", "gateway/background-process", "gateway/health", "gateway/heartbeat", @@ -616,6 +625,7 @@ { "group": "Automation & Hooks", "pages": [ + "automation/auth-monitoring", "automation/webhook", "automation/gmail-pubsub", "automation/cron-jobs", @@ -689,6 +699,7 @@ { "group": "Reference & Templates", "pages": [ + "scripts", "reference/rpc", "reference/device-models", "reference/test", diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md new file mode 100644 index 000000000..3f1e3be5a --- /dev/null +++ b/docs/gateway/authentication.md @@ -0,0 +1,82 @@ +--- +summary: "Model authentication: OAuth, API keys, and Claude Code token reuse" +read_when: + - Debugging model auth or OAuth expiry + - Documenting authentication or credential storage +--- +# Authentication + +Clawdbot supports OAuth and API keys for model providers. For Anthropic +subscription accounts, the most stable path is to **reuse Claude Code OAuth +credentials**, including the 1‑year token created by `claude setup-token`. + +See [/concepts/oauth](/concepts/oauth) for the full OAuth flow and storage +layout. + +## Recommended: long‑lived Claude Code token + +Run this on the **gateway host** (the machine running the Gateway): + +```bash +claude setup-token +``` + +This issues a long‑lived **OAuth token** (not an API key) and stores it for +Claude Code. Then sync and verify: + +```bash +clawdbot models status +clawdbot doctor +``` + +Automation-friendly check (exit `1` when expired/missing, `2` when expiring): + +```bash +clawdbot models status --check +``` + +Optional ops scripts (systemd/Termux) are documented here: +[/automation/auth-monitoring](/automation/auth-monitoring) + +`clawdbot models status` loads Claude Code credentials into Clawdbot’s +`auth-profiles.json` and shows expiry (warns within 24h by default). +`clawdbot doctor` also performs the sync when it runs. + +> `claude setup-token` requires an interactive TTY. + +## Checking model auth status + +```bash +clawdbot models status +clawdbot doctor +``` + +## How sync works + +1. **Claude Code** stores credentials in `~/.claude/.credentials.json` (or + Keychain on macOS). +2. **Clawdbot** syncs those into + `~/.clawdbot/agents//agent/auth-profiles.json` when the auth store is + loaded. +3. OAuth refresh happens automatically on use if a token is expired. + +## Troubleshooting + +### “No credentials found” + +If the Anthropic OAuth profile is missing, run `claude setup-token` on the +**gateway host**, then re-check: + +```bash +clawdbot models status +``` + +### Token expiring/expired + +Run `clawdbot models status` to confirm which profile is expiring. If the profile +is `anthropic:claude-cli`, rerun `claude setup-token`. + +## Requirements + +- Claude Max or Pro subscription (for `claude setup-token`) +- Claude Code CLI installed (`claude` command available) diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 501fb37b5..60e31841d 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -61,6 +61,7 @@ cat ~/.clawdbot/clawdbot.json - 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. +- Model auth health: checks OAuth expiry and can refresh expiring tokens. - Legacy workspace dir detection (`~/clawdis`, `~/clawdbot`). - Sandbox image repair when sandboxing is enabled. - Legacy service migration and extra gateway detection. @@ -135,33 +136,40 @@ Doctor checks: - **Config file permissions**: warns if `~/.clawdbot/clawdbot.json` is group/world readable and offers to tighten to `600`. -### 5) Sandbox image repair +### 5) Model auth health (OAuth expiry) +Doctor inspects OAuth profiles in the auth store, warns when tokens are +expiring/expired, and can refresh them when safe. If the Anthropic Claude Code +profile is stale, it suggests `claude setup-token` on the gateway host. +Refresh prompts only appear when running interactively (TTY); `--non-interactive` +skips refresh attempts. + +### 6) Sandbox image repair When sandboxing is enabled, doctor checks Docker images and offers to build or switch to legacy names if the current image is missing. -### 6) Gateway service migrations and cleanup hints +### 7) Gateway service migrations and cleanup hints Doctor detects legacy Clawdis gateway services (launchd/systemd/schtasks) and offers to remove them and install the Clawdbot service using the current gateway port. It can also scan for extra gateway-like services and print cleanup hints to ensure only one gateway runs per machine. -### 7) Security warnings +### 8) Security warnings Doctor emits warnings when a provider is open to DMs without an allowlist, or when a policy is configured in a dangerous way. -### 8) systemd linger (Linux) +### 9) systemd linger (Linux) If running as a systemd user service, doctor ensures lingering is enabled so the gateway stays alive after logout. -### 9) Skills status +### 10) Skills status Doctor prints a quick summary of eligible/missing/blocked skills for the current workspace. -### 10) Gateway health check + restart +### 11) Gateway health check + restart Doctor runs a health check and offers to restart the gateway when it looks unhealthy. -### 11) Supervisor config audit + repair +### 12) Supervisor config audit + repair Doctor checks the installed supervisor config (launchd/systemd/schtasks) for missing or outdated defaults (e.g., systemd network-online dependencies and restart delay). When it finds a mismatch, it recommends an update and can @@ -174,24 +182,24 @@ Notes: - `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 +### 13) 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 on the gateway port (default `18789`) and reports likely causes (gateway already running, SSH tunnel). -### 13) Gateway runtime best practices +### 14) Gateway runtime best practices Doctor warns when the gateway service runs on Bun or a version-managed Node path (`nvm`, `fnm`, `volta`, `asdf`, etc.). WhatsApp + Telegram providers require Node, and version-manager paths can break after upgrades because the daemon does not load your shell init. Doctor offers to migrate to a system Node install when available (Homebrew/apt/choco). -### 14) Config write + wizard metadata +### 15) Config write + wizard metadata Doctor persists any config changes and stamps wizard metadata to record the doctor run. -### 15) Workspace tips (backup + memory system) +### 16) Workspace tips (backup + memory system) Doctor suggests a workspace memory system when missing and prints a backup tip if the workspace is not already under git. diff --git a/docs/gateway/logging.md b/docs/gateway/logging.md index 2a8ed3478..0c91e2160 100644 --- a/docs/gateway/logging.md +++ b/docs/gateway/logging.md @@ -7,6 +7,8 @@ read_when: # Logging +For a user-facing overview (CLI + Control UI + config), see [/logging](/logging). + Clawdbot has two log “surfaces”: - **Console output** (what you see in the terminal / Debug UI). diff --git a/docs/gateway/security.md b/docs/gateway/security.md index c3730c152..0d8b62b48 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -77,6 +77,13 @@ Even with strong system prompts, **prompt injection is not solved**. What helps - Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem. - **Model choice matters:** we recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)). Using weaker models increases risk. +## Reasoning & verbose output in groups + +`/reasoning` and `/verbose` can expose internal reasoning or tool output that +was not meant for a public channel. In group settings, treat them as **debug +only** and keep them off unless you explicitly need them. If you enable them, +do so only in trusted DMs or tightly controlled rooms. + ## Lessons Learned (The Hard Way) ### The `find ~` Incident 🦞 diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 2c046fc3a..7d663ccae 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -33,6 +33,19 @@ Doctor/daemon will show runtime state (PID/last exit) and log hints. - Linux systemd (if installed): `journalctl --user -u clawdbot-gateway.service -n 200 --no-pager` - Windows: `schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST` +**Enable more logging:** +- Bump file log detail (persisted JSONL): + ```json + { "logging": { "level": "debug" } } + ``` +- Bump console verbosity (TTY output only): + ```json + { "logging": { "consoleLevel": "debug", "consoleStyle": "pretty" } } + ``` +- Quick tip: `--verbose` affects **console** output only. File logs remain controlled by `logging.level`. + +See [/logging](/logging) for a full overview of formats, config, and access. + ### Service Environment (PATH + runtime) The gateway daemon runs with a **minimal PATH** to avoid shell/manager cruft: diff --git a/docs/install/ansible.md b/docs/install/ansible.md new file mode 100644 index 000000000..802cef780 --- /dev/null +++ b/docs/install/ansible.md @@ -0,0 +1,205 @@ +--- +summary: "Automated, hardened Clawdbot installation with Ansible, Tailscale VPN, and firewall isolation" +read_when: + - You want automated server deployment with security hardening + - You need firewall-isolated setup with VPN access + - You're deploying to remote Debian/Ubuntu servers +--- + +# Ansible Installation + +The recommended way to deploy Clawdbot to production servers is via **[clawdbot-ansible](https://github.com/clawdbot/clawdbot-ansible)** — an automated installer with security-first architecture. + +## Quick Start + +One-command install: + +```bash +curl -fsSL https://raw.githubusercontent.com/clawdbot/clawdbot-ansible/main/install.sh | bash +``` + +> **📦 Full guide: [github.com/clawdbot/clawdbot-ansible](https://github.com/clawdbot/clawdbot-ansible)** +> +> The clawdbot-ansible repo is the source of truth for Ansible deployment. This page is a quick overview. + +## What You Get + +- 🔒 **Firewall-first security**: UFW + Docker isolation (only SSH + Tailscale accessible) +- 🔐 **Tailscale VPN**: Secure remote access without exposing services publicly +- 🐳 **Docker**: Isolated sandbox containers, localhost-only bindings +- 🛡️ **Defense in depth**: 4-layer security architecture +- 🚀 **One-command setup**: Complete deployment in minutes +- 🔧 **Systemd integration**: Auto-start on boot with hardening + +## Requirements + +- **OS**: Debian 11+ or Ubuntu 20.04+ +- **Access**: Root or sudo privileges +- **Network**: Internet connection for package installation +- **Ansible**: 2.14+ (installed automatically by quick-start script) + +## What Gets Installed + +The Ansible playbook installs and configures: + +1. **Tailscale** (mesh VPN for secure remote access) +2. **UFW firewall** (SSH + Tailscale ports only) +3. **Docker CE + Compose V2** (for agent sandboxes) +4. **Node.js 22.x + pnpm** (runtime dependencies) +5. **Clawdbot** (host-based, not containerized) +6. **Systemd service** (auto-start with security hardening) + +Note: The gateway runs **directly on the host** (not in Docker), but agent sandboxes use Docker for isolation. See [Sandboxing](/gateway/sandboxing) for details. + +## Post-Install Setup + +After installation completes, switch to the clawdbot user: + +```bash +sudo -i -u clawdbot +``` + +The post-install script will guide you through: + +1. **Onboarding wizard**: Configure Clawdbot settings +2. **Provider login**: Connect WhatsApp/Telegram/Discord/Signal +3. **Gateway testing**: Verify the installation +4. **Tailscale setup**: Connect to your VPN mesh + +### Quick commands + +```bash +# Check service status +sudo systemctl status clawdbot + +# View live logs +sudo journalctl -u clawdbot -f + +# Restart gateway +sudo systemctl restart clawdbot + +# Provider login (run as clawdbot user) +sudo -i -u clawdbot +clawdbot login +``` + +## Security Architecture + +### 4-Layer Defense + +1. **Firewall (UFW)**: Only SSH (22) + Tailscale (41641/udp) exposed publicly +2. **VPN (Tailscale)**: Gateway accessible only via VPN mesh +3. **Docker Isolation**: DOCKER-USER iptables chain prevents external port exposure +4. **Systemd Hardening**: NoNewPrivileges, PrivateTmp, unprivileged user + +### Verification + +Test external attack surface: + +```bash +nmap -p- YOUR_SERVER_IP +``` + +Should show **only port 22** (SSH) open. All other services (gateway, Docker) are locked down. + +### Docker Availability + +Docker is installed for **agent sandboxes** (isolated tool execution), not for running the gateway itself. The gateway binds to localhost only and is accessible via Tailscale VPN. + +See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for sandbox configuration. + +## Manual Installation + +If you prefer manual control over the automation: + +```bash +# 1. Install prerequisites +sudo apt update && sudo apt install -y ansible git + +# 2. Clone repository +git clone https://github.com/clawdbot/clawdbot-ansible.git +cd clawdbot-ansible + +# 3. Install Ansible collections +ansible-galaxy collection install -r requirements.yml + +# 4. Run playbook +./run-playbook.sh + +# Or run directly (then manually execute /tmp/clawdbot-setup.sh after) +# ansible-playbook playbook.yml --ask-become-pass +``` + +## Updating Clawdbot + +The Ansible installer sets up Clawdbot for manual updates. See [Updating](/install/updating) for the standard update flow. + +To re-run the Ansible playbook (e.g., for configuration changes): + +```bash +cd clawdbot-ansible +./run-playbook.sh +``` + +Note: This is idempotent and safe to run multiple times. + +## Troubleshooting + +### Firewall blocks my connection + +If you're locked out: +- Ensure you can access via Tailscale VPN first +- SSH access (port 22) is always allowed +- The gateway is **only** accessible via Tailscale by design + +### Service won't start + +```bash +# Check logs +sudo journalctl -u clawdbot -n 100 + +# Verify permissions +sudo ls -la /opt/clawdbot + +# Test manual start +sudo -i -u clawdbot +cd ~/clawdbot +pnpm start +``` + +### Docker sandbox issues + +```bash +# Verify Docker is running +sudo systemctl status docker + +# Check sandbox image +sudo docker images | grep clawdbot-sandbox + +# Build sandbox image if missing +cd /opt/clawdbot/clawdbot +sudo -u clawdbot ./scripts/sandbox-setup.sh +``` + +### Provider login fails + +Make sure you're running as the `clawdbot` user: + +```bash +sudo -i -u clawdbot +clawdbot login +``` + +## Advanced Configuration + +For detailed security architecture and troubleshooting: +- [Security Architecture](https://github.com/clawdbot/clawdbot-ansible/blob/main/docs/security.md) +- [Technical Details](https://github.com/clawdbot/clawdbot-ansible/blob/main/docs/architecture.md) +- [Troubleshooting Guide](https://github.com/clawdbot/clawdbot-ansible/blob/main/docs/troubleshooting.md) + +## Related + +- [clawdbot-ansible](https://github.com/clawdbot/clawdbot-ansible) — full deployment guide +- [Docker](/install/docker) — containerized gateway setup +- [Sandboxing](/gateway/sandboxing) — agent sandbox configuration +- [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) — per-agent isolation diff --git a/docs/logging.md b/docs/logging.md new file mode 100644 index 000000000..1f718be69 --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,144 @@ +--- +summary: "Logging overview: file logs, console output, CLI tailing, and the Control UI" +read_when: + - You need a beginner-friendly overview of logging + - You want to configure log levels or formats + - You are troubleshooting and need to find logs quickly +--- + +# Logging + +Clawdbot logs in two places: + +- **File logs** (JSON lines) written by the Gateway. +- **Console output** shown in terminals and the Control UI. + +This page explains where logs live, how to read them, and how to configure log +levels and formats. + +## Where logs live + +By default, the Gateway writes a rolling log file under: + +`/tmp/clawdbot/clawdbot-YYYY-MM-DD.log` + +You can override this in `~/.clawdbot/clawdbot.json`: + +```json +{ + "logging": { + "file": "/path/to/clawdbot.log" + } +} +``` + +## How to read logs + +### CLI: live tail (recommended) + +Use the CLI to tail the gateway log file via RPC: + +```bash +clawdbot logs --follow +``` + +Output modes: + +- **TTY sessions**: pretty, colorized, structured log lines. +- **Non-TTY sessions**: plain text. +- `--json`: line-delimited JSON (one log event per line). +- `--plain`: force plain text in TTY sessions. +- `--no-color`: disable ANSI colors. + +In JSON mode, the CLI emits `type`-tagged objects: + +- `meta`: stream metadata (file, cursor, size) +- `log`: parsed log entry +- `notice`: truncation / rotation hints +- `raw`: unparsed log line + +If the Gateway is unreachable, the CLI prints a short hint to run: + +```bash +clawdbot doctor +``` + +### Control UI (web) + +The Control UI’s **Logs** tab tails the same file using `logs.tail`. +See [/web/control-ui](/web/control-ui) for how to open it. + +### Provider-only logs + +To filter provider activity (WhatsApp/Telegram/etc), use: + +```bash +clawdbot providers logs --provider whatsapp +``` + +## Log formats + +### File logs (JSONL) + +Each line in the log file is a JSON object. The CLI and Control UI parse these +entries to render structured output (time, level, subsystem, message). + +### Console output + +Console logs are **TTY-aware** and formatted for readability: + +- Subsystem prefixes (e.g. `gateway/providers/whatsapp`) +- Level coloring (info/warn/error) +- Optional compact or JSON mode + +Console formatting is controlled by `logging.consoleStyle`. + +## Configuring logging + +All logging configuration lives under `logging` in `~/.clawdbot/clawdbot.json`. + +```json +{ + "logging": { + "level": "info", + "file": "/tmp/clawdbot/clawdbot-YYYY-MM-DD.log", + "consoleLevel": "info", + "consoleStyle": "pretty", + "redactSensitive": "tools", + "redactPatterns": [ + "sk-.*" + ] + } +} +``` + +### Log levels + +- `logging.level`: **file logs** (JSONL) level. +- `logging.consoleLevel`: **console** verbosity level. + +`--verbose` only affects console output; it does not change file log levels. + +### Console styles + +`logging.consoleStyle`: + +- `pretty`: human-friendly, colored, with timestamps. +- `compact`: tighter output (best for long sessions). +- `json`: JSON per line (for log processors). + +### Redaction + +Tool summaries can redact sensitive tokens before they hit the console: + +- `logging.redactSensitive`: `off` | `tools` (default: `tools`) +- `logging.redactPatterns`: list of regex strings to override the default set + +Redaction affects **console output only** and does not alter file logs. + +## Troubleshooting tips + +- **Gateway not reachable?** Run `clawdbot doctor` first. +- **Logs empty?** Check that the Gateway is running and writing to the file path + in `logging.file`. +- **Need more detail?** Set `logging.level` to `debug` or `trace` and retry. diff --git a/docs/scripts.md b/docs/scripts.md new file mode 100644 index 000000000..ed3b75a1b --- /dev/null +++ b/docs/scripts.md @@ -0,0 +1,26 @@ +--- +summary: "Repository scripts: purpose, scope, and safety notes" +read_when: + - Running scripts from the repo + - Adding or changing scripts under ./scripts +--- +# Scripts + +The `scripts/` directory contains helper scripts for local workflows and ops tasks. +Use these when a task is clearly tied to a script; otherwise prefer the CLI. + +## Conventions + +- Scripts are **optional** unless referenced in docs or release checklists. +- Prefer CLI surfaces when they exist (example: auth monitoring uses `clawdbot models status --check`). +- Assume scripts are host‑specific; read them before running on a new machine. + +## Auth monitoring scripts + +Auth monitoring scripts are documented here: +[/automation/auth-monitoring](/automation/auth-monitoring) + +## When adding scripts + +- Keep scripts focused and documented. +- Add a short entry in the relevant doc (or create one if missing). diff --git a/docs/start/faq.md b/docs/start/faq.md index e613463a7..e061f8c17 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -33,10 +33,14 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, Asks the running gateway for a full snapshot (WS-only). See [Health](/gateway/health). 5) **Tail the latest log** + ```bash + clawdbot logs --follow + ``` + If RPC is down, fall back to: ```bash tail -f "$(ls -t /tmp/clawdbot/clawdbot-*.log | head -1)" ``` - File logs are separate from service logs; see [Logging](/gateway/logging) and [Troubleshooting](/gateway/troubleshooting). + File logs are separate from service logs; see [Logging](/logging) and [Troubleshooting](/gateway/troubleshooting). ## What is Clawdbot? @@ -114,6 +118,26 @@ Legacy single‑agent path: `~/.clawdbot/agent/*` (migrated by `clawdbot doctor` Your **workspace** (AGENTS.md, memory files, skills, etc.) is separate and configured via `agent.workspace` (default: `~/clawd`). +### Can agents work outside the workspace? + +Yes. The workspace is the **default cwd** and memory anchor, not a hard sandbox. +Relative paths resolve inside the workspace, but absolute paths can access other +host locations unless sandboxing is enabled. If you need isolation, use +[`agent.sandbox`](/gateway/sandboxing) or per‑agent sandbox settings. If you +want a repo to be the default working directory, point that agent’s +`workspace` to the repo root. The Clawdbot repo is just source code; keep the +workspace separate unless you intentionally want the agent to work inside it. + +Example (repo as default cwd): + +```json5 +{ + agent: { + workspace: "~/Projects/my-repo" + } +} +``` + ### I’m in remote mode — where is the session store? Session state is owned by the **gateway host**. If you’re in remote mode, the session store you care about is on the remote machine, not your local laptop. See [Session management](/concepts/session). @@ -257,6 +281,18 @@ Use the `/model` command as a standalone message: You can list available models with `/model`, `/model list`, or `/model status`. +### Why do I see “Model … is not allowed” and then no reply? + +If `agent.models` is set, it becomes the **allowlist** for `/model` and any +session overrides. Choosing a model that isn’t in that list returns: + +``` +Model "provider/model" is not allowed. Use /model to list available models. +``` + +That error is returned **instead of** a normal reply. Fix: add the model to +`agent.models`, remove the allowlist, or pick a model from `/model list`. + ### Are opus / sonnet / gpt built‑in shortcuts? Yes. Clawdbot ships a few default shorthands (only applied when the model exists in `agent.models`): @@ -346,7 +382,7 @@ It means the system attempted to use the auth profile ID `anthropic:default`, bu - **Make sure you’re editing the correct agent** - Multi‑agent setups mean there can be multiple `auth-profiles.json` files. - **Sanity‑check model/auth status** - - Use `/model status` to see configured models and whether providers are authenticated. + - Use `clawdbot models status` to see configured models and whether providers are authenticated. ### Why did it also try Google Gemini and fail? diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 0e1cb6e90..d399d5b3b 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -170,6 +170,17 @@ clawdbot onboard --non-interactive \ Add `--json` for a machine‑readable summary. +Gemini example: + +```bash +clawdbot onboard --non-interactive \ + --mode local \ + --auth-choice gemini-api-key \ + --gemini-api-key "$GEMINI_API_KEY" \ + --gateway-port 18789 \ + --gateway-bind loopback +``` + Add agent (non‑interactive) example: ```bash diff --git a/docs/tools/clawdhub.md b/docs/tools/clawdhub.md index ecdef7a9c..298db66ec 100644 --- a/docs/tools/clawdhub.md +++ b/docs/tools/clawdhub.md @@ -50,7 +50,8 @@ bun add -g clawdhub By default, the CLI installs skills into `./skills` under your current working directory. Clawdbot loads workspace skills from `/skills` and will pick them up in the **next** session. If you already use `~/.clawdbot/skills` or bundled skills, workspace skills take precedence. -For more detail on how skills are loaded and gated, see `docs/skills.md`. +For more detail on how skills are loaded, shared, and gated, see +[Skills](/tools/skills). ## What the service provides (features) diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index 482341f54..746edc9cf 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -15,7 +15,7 @@ read_when: - **Global availability gate**: `agent.elevated` is global (not per-agent). If disabled or sender not allowlisted, elevated is unavailable everywhere. - **Per-session state**: `/elevated on|off` sets the elevated level for the current session key. - **Inline directive**: `/elevated on` inside a message applies to that message only. -- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. +- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned. - **Host execution**: elevated runs `bash` on the host (bypasses sandbox). - **Unsandboxed agents**: when there is no sandbox to bypass, elevated does not change where `bash` runs. - **Tool policy still applies**: if `bash` is denied by tool policy, elevated cannot be used. diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 784028ae8..4d6e04654 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -23,6 +23,36 @@ If a skill name conflicts, precedence is: Additionally, you can configure extra skill folders (lowest precedence) via `skills.load.extraDirs` in `~/.clawdbot/clawdbot.json`. +## Per-agent vs shared skills + +In **multi-agent** setups, each agent has its own workspace. That means: + +- **Per-agent skills** live in `/skills` for that agent only. +- **Shared skills** live in `~/.clawdbot/skills` (managed/local) and are visible + to **all agents** on the same machine. +- **Shared folders** can also be added via `skills.load.extraDirs` (lowest + precedence) if you want a common skills pack used by multiple agents. + +If the same skill name exists in more than one place, the usual precedence +applies: workspace wins, then managed/local, then bundled. + +## ClawdHub (install + sync) + +ClawdHub is the public skills registry for Clawdbot. Use it to discover, +install, update, and back up skills. Full guide: [ClawdHub](/tools/clawdhub). + +Common flows: + +- Install a skill into your workspace: + - `clawdhub install ` +- Update all installed skills: + - `clawdhub update --all` +- Sync (scan + publish updates): + - `clawdhub sync --all` + +By default, `clawdhub` installs into `./skills` under your current working +directory; Clawdbot picks that up as `/skills` on the next session. + ## Format (AgentSkills + Pi-compatible) `SKILL.md` must include at least: diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index fa815cbfe..0d9045fe7 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -53,6 +53,8 @@ Text-only: Notes: - Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`). +- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use. +- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats. ## Surface notes diff --git a/package.json b/package.json index 34fa1d973..f35da234f 100644 --- a/package.json +++ b/package.json @@ -97,10 +97,10 @@ "@grammyjs/runner": "^2.0.3", "@grammyjs/transformer-throttler": "^1.2.1", "@homebridge/ciao": "^1.3.4", - "@mariozechner/pi-agent-core": "^0.38.0", - "@mariozechner/pi-ai": "^0.38.0", - "@mariozechner/pi-coding-agent": "^0.38.0", - "@mariozechner/pi-tui": "^0.38.0", + "@mariozechner/pi-agent-core": "^0.40.0", + "@mariozechner/pi-ai": "^0.40.0", + "@mariozechner/pi-coding-agent": "^0.40.0", + "@mariozechner/pi-tui": "^0.40.0", "@sinclair/typebox": "0.34.47", "@slack/bolt": "^4.6.0", "@slack/web-api": "^7.13.0", @@ -165,7 +165,6 @@ "@sinclair/typebox": "0.34.47" }, "patchedDependencies": { - "@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch", "@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch" } }, @@ -202,7 +201,6 @@ ] }, "patchedDependencies": { - "@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch", "@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch", "@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch", "qrcode-terminal": "patches/qrcode-terminal.patch", diff --git a/patches/@mariozechner__pi-ai.patch b/patches/@mariozechner__pi-ai.patch deleted file mode 100644 index 842d9406a..000000000 --- a/patches/@mariozechner__pi-ai.patch +++ /dev/null @@ -1,434 +0,0 @@ -diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js -index b1d6a340e1817b6f5404c2a23efa49139249f754..6606b09bd4eeee475899a840e6f6fa62b77b6a05 100644 ---- a/dist/providers/google-gemini-cli.js -+++ b/dist/providers/google-gemini-cli.js -@@ -7,6 +7,94 @@ import { calculateCost } from "../models.js"; - import { AssistantMessageEventStream } from "../utils/event-stream.js"; - import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; - import { convertMessages, convertTools, isThinkingPart, mapStopReasonString, mapToolChoice, retainThoughtSignature, } from "./google-shared.js"; -+// ============================================================================ -+// ANTIGRAVITY SYSTEM INSTRUCTION (Ported from CLIProxyAPI v6.6.89) -+// ============================================================================ -+/** -+ * System instruction for Antigravity requests. -+ * This is injected into requests to match CLIProxyAPI v6.6.89 behavior. -+ * The instruction provides identity and guidelines for the Antigravity agent. -+ */ -+const ANTIGRAVITY_SYSTEM_INSTRUCTION = ` -+You are Antigravity, a powerful agentic AI coding assistant designed by the Google DeepMind team working on Advanced Agentic Coding. -+You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question. -+The USER will send you requests, which you must always prioritize addressing. Along with each USER request, we will attach additional metadata about their current state, such as what files they have open and where their cursor is. -+This information may or may not be relevant to the coding task, it is up for you to decide. -+ -+ -+ -+Call tools as you normally would. The following list provides additional guidance to help you avoid errors: -+ - **Absolute paths only**. When using tools that accept file path arguments, ALWAYS use the absolute file path. -+ -+ -+ -+## Technology Stack -+Your web applications should be built using the following technologies: -+1. **Core**: Use HTML for structure and JavaScript for logic. -+2. **Styling (CSS)**: Use Vanilla CSS for maximum flexibility and control. Avoid using TailwindCSS unless the USER explicitly requests it; in this case, first confirm which TailwindCSS version to use. -+3. **Web App**: If the USER specifies that they want a more complex web app, use a framework like Next.js or Vite. Only do this if the USER explicitly requests a web app. -+4. **New Project Creation**: If you need to use a framework for a new app, use \`npx\` with the appropriate script, but there are some rules to follow: -+ - Use \`npx -y\` to automatically install the script and its dependencies -+ - You MUST run the command with \`--help\` flag to see all available options first -+ - Initialize the app in the current directory with \`./\` (example: \`npx -y create-vite-app@latest ./\`) -+ - You should run in non-interactive mode so that the user doesn't need to input anything -+5. **Running Locally**: When running locally, use \`npm run dev\` or equivalent dev server. Only build the production bundle if the USER explicitly requests it or you are validating the code for correctness. -+ -+# Design Aesthetics -+1. **Use Rich Aesthetics**: The USER should be wowed at first glance by the design. Use best practices in modern web design (e.g. vibrant colors, dark modes, glassmorphism, and dynamic animations) to create a stunning first impression. Failure to do this is UNACCEPTABLE. -+2. **Prioritize Visual Excellence**: Implement designs that will WOW the user and feel extremely premium: -+ - Avoid generic colors (plain red, blue, green). Use curated, harmonious color palettes (e.g., HSL tailored colors, sleek dark modes). -+ - Using modern typography (e.g., from Google Fonts like Inter, Roboto, or Outfit) instead of browser defaults. -+ - Use smooth gradients -+ - Add subtle micro-animations for enhanced user experience -+3. **Use a Dynamic Design**: An interface that feels responsive and alive encourages interaction. Achieve this with hover effects and interactive elements. Micro-animations, in particular, are highly effective for improving user engagement. -+4. **Premium Designs**: Make a design that feels premium and state of the art. Avoid creating simple minimum viable products. -+5. **Don't use placeholders**: If you need an image, use your generate_image tool to create a working demonstration. -+ -+## Implementation Workflow -+Follow this systematic approach when building web applications: -+1. **Plan and Understand**: -+ - Fully understand the user's requirements -+ - Draw inspiration from modern, beautiful, and dynamic web designs -+ - Outline the features needed for the initial version -+2. **Build the Foundation**: -+ - Start by creating/modifying \`index.css\` -+ - Implement the core design system with all tokens and utilities -+3. **Create Components**: -+ - Build necessary components using your design system -+ - Ensure all components use predefined styles, not ad-hoc utilities -+ - Keep components focused and reusable -+4. **Assemble Pages**: -+ - Update the main application to incorporate your design and components -+ - Ensure proper routing and navigation -+ - Implement responsive layouts -+5. **Polish and Optimize**: -+ - Review the overall user experience -+ - Ensure smooth interactions and transitions -+ - Optimize performance where needed -+ -+## SEO Best Practices -+Automatically implement SEO best practices on every page: -+- **Title Tags**: Include proper, descriptive title tags for each page -+- **Meta Descriptions**: Add compelling meta descriptions that accurately summarize page content -+- **Heading Structure**: Use a single \`

\` per page with proper heading hierarchy -+- **Semantic HTML**: Use appropriate HTML5 semantic elements -+- **Unique IDs**: Ensure all interactive elements have unique, descriptive IDs for browser testing -+- **Performance**: Ensure fast page load times through optimization -+CRITICAL REMINDER: AESTHETICS ARE VERY IMPORTANT. If your web app looks simple and basic then you have FAILED! -+ -+ -+There will be an appearing in the conversation at times. This is not coming from the user, but instead injected by the system as important information to pay attention to. -+Do not respond to nor acknowledge those messages, but do follow them strictly. -+ -+ -+ -+ -+- **Formatting**. Format your responses in github-style markdown to make your responses easier for the USER to parse. For example, use headers to organize your responses and bolded or italicized text to highlight important keywords. Use backticks to format file, directory, function, and class names. If providing a URL to the user, format this in markdown as well, for example \`[label](example.com)\`. -+- **Proactiveness**. As an agent, you are allowed to be proactive, but only in the course of completing the user's task. For example, if the user asks you to add a new component, you can edit the code, verify build and test statuses, and take any other obvious follow-up actions, such as performing additional research. However, avoid surprising the user. For example, if the user asks HOW to approach something, you should answer their question and instead of jumping into editing a file. -+- **Helpfulness**. Respond like a helpful software engineer who is explaining your work to a friendly collaborator on the project. Acknowledge mistakes or any backtracking you do as a result of new information. -+- **Ask for clarification**. If you are unsure about the USER's intent, always ask for clarification rather than making assumptions. -+`; - const DEFAULT_ENDPOINT = "https://cloudcode-pa.googleapis.com"; - // Headers for Gemini CLI (prod endpoint) - const GEMINI_CLI_HEADERS = { -@@ -139,11 +227,12 @@ export const streamGoogleGeminiCli = (model, context, options) => { - if (!accessToken || !projectId) { - throw new Error("Missing token or projectId in Google Cloud credentials. Use /login to re-authenticate."); - } -- const requestBody = buildRequest(model, context, projectId, options); - const endpoint = model.baseUrl || DEFAULT_ENDPOINT; - const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`; - // Use Antigravity headers for sandbox endpoint, otherwise Gemini CLI headers - const isAntigravity = endpoint.includes("sandbox.googleapis.com"); -+ // PATCH: Pass isAntigravity to buildRequest for system instruction injection (CLIProxyAPI v6.6.89 compat) -+ const requestBody = buildRequest(model, context, projectId, options, isAntigravity); - const headers = isAntigravity ? ANTIGRAVITY_HEADERS : GEMINI_CLI_HEADERS; - // Fetch with retry logic for rate limits and transient errors - let response; -@@ -168,7 +257,12 @@ export const streamGoogleGeminiCli = (model, context, options) => { - break; // Success, exit retry loop - } - const errorText = await response.text(); -- // Check if retryable -+ // PATCH: Fail immediately on 429 to let caller rotate accounts -+ if (response.status === 429) { -+ console.log(`[pi-ai] 429 rate limit - failing fast to rotate account`); -+ throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`); -+ } -+ // Check if retryable (non-429 errors) - if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) { - // Use server-provided delay or exponential backoff - const serverDelay = extractRetryDelay(errorText); -@@ -183,6 +277,10 @@ export const streamGoogleGeminiCli = (model, context, options) => { - if (error instanceof Error && error.message === "Request was aborted") { - throw error; - } -+ // PATCH: Don't retry 429 errors - let caller rotate accounts -+ if (error instanceof Error && error.message.includes("429")) { -+ throw error; -+ } - lastError = error instanceof Error ? error : new Error(String(error)); - // Network errors are retryable - if (attempt < MAX_RETRIES) { -@@ -402,7 +500,7 @@ export const streamGoogleGeminiCli = (model, context, options) => { - })(); - return stream; - }; --function buildRequest(model, context, projectId, options = {}) { -+function buildRequest(model, context, projectId, options = {}, isAntigravity = false) { - const contents = convertMessages(model, context); - const generationConfig = {}; - if (options.temperature !== undefined) { -@@ -447,12 +545,23 @@ function buildRequest(model, context, projectId, options = {}) { - }; - } - } -- return { -+ // PATCH: Inject Antigravity system instruction with role "user" (CLIProxyAPI v6.6.89 compatibility) -+ if (isAntigravity) { -+ const existingText = request.systemInstruction?.parts?.[0]?.text || ""; -+ request.systemInstruction = { -+ role: "user", -+ parts: [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION + (existingText ? "\n\n" + existingText : "") }], -+ }; -+ } -+ // PATCH: Build wrapped body with requestType for Antigravity (CLIProxyAPI v6.6.89 compatibility) -+ const wrappedBody = { - project: projectId, - model: model.id, - request, -- userAgent: "pi-coding-agent", -- requestId: `pi-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`, -+ ...(isAntigravity && { requestType: "agent" }), -+ userAgent: isAntigravity ? "antigravity" : "pi-coding-agent", -+ requestId: `${isAntigravity ? "agent" : "pi"}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`, - }; -+ return wrappedBody; - } - //# sourceMappingURL=google-gemini-cli.js.map -diff --git a/dist/providers/google-shared.js b/dist/providers/google-shared.js -index dbb9c0e263919c9184a5f1c7dfde47d1c3a37ff4..f1866f423f30a4dfbe812d052679abd1f011769f 100644 ---- a/dist/providers/google-shared.js -+++ b/dist/providers/google-shared.js -@@ -41,13 +41,27 @@ export function retainThoughtSignature(existing, incoming) { - export function convertMessages(model, context) { - const contents = []; - const transformedMessages = transformMessages(context.messages, model); -+ /** -+ * Helper to add content while merging consecutive messages of the same role. -+ * Gemini/Cloud Code Assist requires strict role alternation (user/model/user/model). -+ * Consecutive messages of the same role cause "function call turn" errors. -+ */ -+ function addContent(role, parts) { -+ if (parts.length === 0) -+ return; -+ const lastContent = contents[contents.length - 1]; -+ if (lastContent?.role === role) { -+ // Merge into existing message of same role -+ lastContent.parts.push(...parts); -+ } -+ else { -+ contents.push({ role, parts }); -+ } -+ } - for (const msg of transformedMessages) { - if (msg.role === "user") { - if (typeof msg.content === "string") { -- contents.push({ -- role: "user", -- parts: [{ text: sanitizeSurrogates(msg.content) }], -- }); -+ addContent("user", [{ text: sanitizeSurrogates(msg.content) }]); - } - else { - const parts = msg.content.map((item) => { -@@ -66,10 +80,7 @@ export function convertMessages(model, context) { - const filteredParts = !model.input.includes("image") ? parts.filter((p) => p.text !== undefined) : parts; - if (filteredParts.length === 0) - continue; -- contents.push({ -- role: "user", -- parts: filteredParts, -- }); -+ addContent("user", filteredParts); - } - } - else if (msg.role === "assistant") { -@@ -82,9 +93,19 @@ export function convertMessages(model, context) { - parts.push({ text: sanitizeSurrogates(block.text) }); - } - else if (block.type === "thinking") { -- // Thinking blocks require signatures for Claude via Antigravity. -- // If signature is missing (e.g. from GPT-OSS), convert to regular text with delimiters. -- if (block.thinkingSignature) { -+ // Thinking blocks handling varies by model: -+ // - Claude via Antigravity: requires thinkingSignature -+ // - Gemini: skip entirely (doesn't understand thoughtSignature, and mimics tags) -+ // - Other models: convert to text with delimiters -+ const isGemini = model.id.toLowerCase().includes("gemini"); -+ const isClaude = model.id.toLowerCase().includes("claude"); -+ if (isGemini) { -+ // Skip thinking blocks entirely for Gemini - it doesn't support them -+ // and will mimic tags if we convert to text -+ continue; -+ } -+ if (block.thinkingSignature && isClaude) { -+ // Claude via Antigravity requires the signature - parts.push({ - thought: true, - text: sanitizeSurrogates(block.thinking), -@@ -92,6 +113,7 @@ export function convertMessages(model, context) { - }); - } - else { -+ // Other models: convert to text with delimiters - parts.push({ - text: `\n${sanitizeSurrogates(block.thinking)}\n`, - }); -@@ -116,10 +138,7 @@ export function convertMessages(model, context) { - } - if (parts.length === 0) - continue; -- contents.push({ -- role: "model", -- parts, -- }); -+ addContent("model", parts); - } - else if (msg.role === "toolResult") { - // Extract text and image content -@@ -156,27 +175,97 @@ export function convertMessages(model, context) { - } - // Cloud Code Assist API requires all function responses to be in a single user turn. - // Check if the last content is already a user turn with function responses and merge. -+ // Use addContent for proper role alternation handling. - const lastContent = contents[contents.length - 1]; - if (lastContent?.role === "user" && lastContent.parts?.some((p) => p.functionResponse)) { - lastContent.parts.push(functionResponsePart); - } - else { -- contents.push({ -- role: "user", -- parts: [functionResponsePart], -- }); -+ addContent("user", [functionResponsePart]); - } - // For older models, add images in a separate user message -+ // Note: This may create consecutive user messages, but addContent will merge them - if (hasImages && !supportsMultimodalFunctionResponse) { -- contents.push({ -- role: "user", -- parts: [{ text: "Tool result image:" }, ...imageParts], -- }); -+ addContent("user", [{ text: "Tool result image:" }, ...imageParts]); - } - } - } - return contents; - } -+/** -+ * Sanitize JSON Schema for Google Cloud Code Assist API. -+ * Removes unsupported keywords like patternProperties, const, anyOf, etc. -+ * and converts to a format compatible with Google's function declarations. -+ */ -+function sanitizeSchemaForGoogle(schema) { -+ if (!schema || typeof schema !== "object") { -+ return schema; -+ } -+ // If it's an array, sanitize each element -+ if (Array.isArray(schema)) { -+ return schema.map((item) => sanitizeSchemaForGoogle(item)); -+ } -+ const sanitized = {}; -+ // List of unsupported JSON Schema keywords that Google's API doesn't understand -+ const unsupportedKeywords = [ -+ "patternProperties", -+ "const", -+ "anyOf", -+ "oneOf", -+ "allOf", -+ "not", -+ "$schema", -+ "$id", -+ "$ref", -+ "$defs", -+ "definitions", -+ "if", -+ "then", -+ "else", -+ "dependentSchemas", -+ "dependentRequired", -+ "unevaluatedProperties", -+ "unevaluatedItems", -+ "contentEncoding", -+ "contentMediaType", -+ "contentSchema", -+ "deprecated", -+ "readOnly", -+ "writeOnly", -+ "examples", -+ "$comment", -+ "additionalProperties", -+ ]; -+ // TODO(steipete): lossy schema scrub; revisit when Google supports these keywords. -+ for (const [key, value] of Object.entries(schema)) { -+ // Skip unsupported keywords -+ if (unsupportedKeywords.includes(key)) { -+ continue; -+ } -+ // Recursively sanitize nested objects -+ if (key === "properties" && typeof value === "object" && value !== null) { -+ sanitized[key] = {}; -+ for (const [propKey, propValue] of Object.entries(value)) { -+ sanitized[key][propKey] = sanitizeSchemaForGoogle(propValue); -+ } -+ } -+ else if (key === "items" && typeof value === "object") { -+ sanitized[key] = sanitizeSchemaForGoogle(value); -+ } -+ else if (typeof value === "object" && value !== null && !Array.isArray(value)) { -+ sanitized[key] = sanitizeSchemaForGoogle(value); -+ } -+ else { -+ sanitized[key] = value; -+ } -+ } -+ // Ensure type: "object" is present when properties or required exist -+ // Google API requires type to be set when these fields are present -+ if (("properties" in sanitized || "required" in sanitized) && !("type" in sanitized)) { -+ sanitized.type = "object"; -+ } -+ return sanitized; -+} - /** - * Convert tools to Gemini function declarations format. - */ -@@ -188,7 +277,7 @@ export function convertTools(tools) { - functionDeclarations: tools.map((tool) => ({ - name: tool.name, - description: tool.description, -- parameters: tool.parameters, -+ parameters: sanitizeSchemaForGoogle(tool.parameters), - })), - }, - ]; -diff --git a/dist/providers/openai-completions.d.ts b/dist/providers/openai-completions.d.ts -index 723addf341696b5d69c079202e571e9917685ce4..a1d0584a70a7d1fad1332026e301e56ef4f700a8 100644 ---- a/dist/providers/openai-completions.d.ts -+++ b/dist/providers/openai-completions.d.ts -@@ -7,6 +7,8 @@ export interface OpenAICompletionsOptions extends StreamOptions { - }; - }; - reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh"; -+ /** Extra params to pass directly to the API (e.g., Z.AI GLM thinking mode params) */ -+ extraParams?: Record; - } - export declare const streamOpenAICompletions: StreamFunction<"openai-completions">; - //# sourceMappingURL=openai-completions.d.ts.map -diff --git a/dist/providers/openai-completions.js b/dist/providers/openai-completions.js -index 2590381cc5544c4e73c24c1c9a5853202f31361b..b76e1087dd31ccf099e02b1214b9e12d371b9b2d 100644 ---- a/dist/providers/openai-completions.js -+++ b/dist/providers/openai-completions.js -@@ -335,6 +335,11 @@ function buildParams(model, context, options) { - if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) { - params.reasoning_effort = options.reasoningEffort; - } -+ // PATCH: Support arbitrary extra params for provider-specific features -+ // (e.g., Z.AI GLM-4.7 thinking: { type: "enabled", clear_thinking: boolean }) -+ if (options?.extraParams && typeof options.extraParams === "object") { -+ Object.assign(params, options.extraParams); -+ } - return params; - } - function convertMessages(model, context, compat) { -diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js -index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..c2bc63f483f3285b00755901ba97db810221cea6 100644 ---- a/dist/providers/openai-responses.js -+++ b/dist/providers/openai-responses.js -@@ -486,7 +486,6 @@ function convertTools(tools) { - name: tool.name, - description: tool.description, - parameters: tool.parameters, // TypeBox already generates JSON Schema -- strict: null, - })); - } - function mapStopReason(status) { -diff --git a/dist/stream.js b/dist/stream.js -index da54f4270e9b8d9e9cf1f902af976cc239601d4c..7ed71597c3369f8e3c1a3da0eb870a68215b714d 100644 ---- a/dist/stream.js -+++ b/dist/stream.js -@@ -108,6 +108,8 @@ function mapOptionsForApi(model, options, apiKey) { - signal: options?.signal, - apiKey: apiKey || options?.apiKey, - sessionId: options?.sessionId, -+ // PATCH: Pass extraParams through to provider-specific API handlers -+ extraParams: options?.extraParams, - }; - // Helper to clamp xhigh to high for providers that don't support it - const clampReasoning = (effort) => (effort === "xhigh" ? "high" : effort); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c80c0b60e..ce5d58431 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ patchedDependencies: '@mariozechner/pi-agent-core': hash: 01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4 path: patches/@mariozechner__pi-agent-core.patch - '@mariozechner/pi-ai': - hash: 574a0ebc3772ef61f04b6dcffdcda31c7fe6384e6f44ce43dbd7adb3b24ec97a - path: patches/@mariozechner__pi-ai.patch importers: @@ -35,17 +32,17 @@ importers: specifier: ^1.3.4 version: 1.3.4 '@mariozechner/pi-agent-core': - specifier: ^0.38.0 - version: 0.38.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5) + specifier: ^0.40.0 + version: 0.40.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-ai': - specifier: ^0.38.0 - version: 0.38.0(patch_hash=574a0ebc3772ef61f04b6dcffdcda31c7fe6384e6f44ce43dbd7adb3b24ec97a)(ws@8.19.0)(zod@4.3.5) + specifier: ^0.40.0 + version: 0.40.0(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-coding-agent': - specifier: ^0.38.0 - version: 0.38.0(ws@8.19.0)(zod@4.3.5) + specifier: ^0.40.0 + version: 0.40.0(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-tui': - specifier: ^0.38.0 - version: 0.38.0 + specifier: ^0.40.0 + version: 0.40.0 '@sinclair/typebox': specifier: 0.34.47 version: 0.34.47 @@ -744,8 +741,8 @@ packages: '@lit-labs/signals@0.2.0': resolution: {integrity: sha512-68plyIbciumbwKaiilhLNyhz4Vg6/+nJwDufG2xxWA9r/fUw58jxLHCAlKs+q1CE5Lmh3cZ3ShyYKnOCebEpVA==} - '@lit-labs/ssr-dom-shim@1.5.0': - resolution: {integrity: sha512-HLomZXMmrCFHSRKESF5vklAKsDY7/fsT/ZhqCu3V0UoW/Qbv8wxmO4W9bx4KnCCF2Zak4yuk+AGraK/bPmI4kA==} + '@lit-labs/ssr-dom-shim@1.5.1': + resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} '@lit/context@1.1.6': resolution: {integrity: sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==} @@ -815,22 +812,22 @@ packages: peerDependencies: lit: ^3.3.1 - '@mariozechner/pi-agent-core@0.38.0': - resolution: {integrity: sha512-VtX2j0cSefdZ6X+osUZXLp8BRT2ZB6utxl7IWoebRq0iPpJScUGUNB+K0POUduW90MmraNUvFCrKhEZSWffs+g==} + '@mariozechner/pi-agent-core@0.40.0': + resolution: {integrity: sha512-l43rJlKJVTaKPIIMTKe6AHYLSN/6FU/zZ//uUK6BCp4CNJlcAN2iX4wdXC9t+QoAnpshJFheBP6kXS2ynFhxuw==} engines: {node: '>=20.0.0'} - '@mariozechner/pi-ai@0.38.0': - resolution: {integrity: sha512-AOH5LIsC6EgaTiYe0er9trZhuba/lk62xDlTxVNxskrF+wiNhuBWue7MQ9BQIyzWDh8sEVvNhnbXIKBX7LYdbw==} + '@mariozechner/pi-ai@0.40.0': + resolution: {integrity: sha512-OiE6ir7bVEFVnXY/Jd4uIDMTOTdXpDlMpmJ8qXhlp5SlVzjiZkuPEJS3Hki8j4DnwdkPGMWyOX4kZi8FCrtBUA==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-coding-agent@0.38.0': - resolution: {integrity: sha512-fBCgOUSrca/CpU+LPeEl0PJnOPAHlovbsEf3XbQ+MctreC5zMCvD61mdfdeHnuvu/jBer+WVjnGyNy0j0f0Z0Q==} + '@mariozechner/pi-coding-agent@0.40.0': + resolution: {integrity: sha512-IUTZxZkNjnzoZmpjPODmAkM9K2Eoq8LBDqYB1LZwr/f3JQXWxQNCIKfEnhMnkBmjijQ/0kba1mS2G45tlMDMPA==} engines: {node: '>=20.0.0'} hasBin: true - '@mariozechner/pi-tui@0.38.0': - resolution: {integrity: sha512-gMhvh0dQ40kjj7gOOWTkYaD2CTq/omh2bii0w8SUnrRERg/mIj03dCjay6sViG75WdMpoTuDlvQ4wXlG633rpA==} + '@mariozechner/pi-tui@0.40.0': + resolution: {integrity: sha512-fWp8hxpQq7PB2GxQN3dOCfy40e2kk3y0oPw9gSVsDxCjCeIZ1y9TYGHU8k2yrdz5I5B2TVpkvsjE6Z6Q5FdU1w==} engines: {node: '>=20.0.0'} '@mistralai/mistralai@1.10.0': @@ -2007,8 +2004,8 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} - iconv-lite@0.7.1: - resolution: {integrity: sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} ieee754@1.2.1: @@ -3548,7 +3545,7 @@ snapshots: lit: 3.3.2 signal-polyfill: 0.2.2 - '@lit-labs/ssr-dom-shim@1.5.0': {} + '@lit-labs/ssr-dom-shim@1.5.1': {} '@lit/context@1.1.6': dependencies: @@ -3556,7 +3553,7 @@ snapshots: '@lit/reactive-element@2.1.2': dependencies: - '@lit-labs/ssr-dom-shim': 1.5.0 + '@lit-labs/ssr-dom-shim': 1.5.1 '@mariozechner/clipboard-darwin-arm64@0.3.0': optional: true @@ -3614,10 +3611,10 @@ snapshots: transitivePeerDependencies: - tailwindcss - '@mariozechner/pi-agent-core@0.38.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)': + '@mariozechner/pi-agent-core@0.40.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)': dependencies: - '@mariozechner/pi-ai': 0.38.0(patch_hash=574a0ebc3772ef61f04b6dcffdcda31c7fe6384e6f44ce43dbd7adb3b24ec97a)(ws@8.19.0)(zod@4.3.5) - '@mariozechner/pi-tui': 0.38.0 + '@mariozechner/pi-ai': 0.40.0(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-tui': 0.40.0 transitivePeerDependencies: - '@modelcontextprotocol/sdk' - bufferutil @@ -3626,7 +3623,7 @@ snapshots: - ws - zod - '@mariozechner/pi-ai@0.38.0(patch_hash=574a0ebc3772ef61f04b6dcffdcda31c7fe6384e6f44ce43dbd7adb3b24ec97a)(ws@8.19.0)(zod@4.3.5)': + '@mariozechner/pi-ai@0.40.0(ws@8.19.0)(zod@4.3.5)': dependencies: '@anthropic-ai/sdk': 0.71.2(zod@4.3.5) '@google/genai': 1.34.0 @@ -3646,12 +3643,12 @@ snapshots: - ws - zod - '@mariozechner/pi-coding-agent@0.38.0(ws@8.19.0)(zod@4.3.5)': + '@mariozechner/pi-coding-agent@0.40.0(ws@8.19.0)(zod@4.3.5)': dependencies: '@mariozechner/clipboard': 0.3.0 - '@mariozechner/pi-agent-core': 0.38.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5) - '@mariozechner/pi-ai': 0.38.0(patch_hash=574a0ebc3772ef61f04b6dcffdcda31c7fe6384e6f44ce43dbd7adb3b24ec97a)(ws@8.19.0)(zod@4.3.5) - '@mariozechner/pi-tui': 0.38.0 + '@mariozechner/pi-agent-core': 0.40.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-ai': 0.40.0(ws@8.19.0)(zod@4.3.5) + '@mariozechner/pi-tui': 0.40.0 chalk: 5.6.2 cli-highlight: 2.1.11 diff: 8.0.2 @@ -3670,7 +3667,7 @@ snapshots: - ws - zod - '@mariozechner/pi-tui@0.38.0': + '@mariozechner/pi-tui@0.40.0': dependencies: '@types/mime-types': 2.1.4 chalk: 5.6.2 @@ -4373,7 +4370,7 @@ snapshots: content-type: 1.0.5 debug: 4.4.3 http-errors: 2.0.1 - iconv-lite: 0.7.1 + iconv-lite: 0.7.2 on-finished: 2.4.1 qs: 6.14.1 raw-body: 3.0.2 @@ -4915,7 +4912,7 @@ snapshots: transitivePeerDependencies: - supports-color - iconv-lite@0.7.1: + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -5107,7 +5104,7 @@ snapshots: lit-element@4.2.2: dependencies: - '@lit-labs/ssr-dom-shim': 1.5.0 + '@lit-labs/ssr-dom-shim': 1.5.1 '@lit/reactive-element': 2.1.2 lit-html: 3.3.2 @@ -5539,7 +5536,7 @@ snapshots: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.7.1 + iconv-lite: 0.7.2 unpipe: 1.0.0 react-is@17.0.2: diff --git a/scripts/auth-monitor.sh b/scripts/auth-monitor.sh new file mode 100755 index 000000000..eca6747d3 --- /dev/null +++ b/scripts/auth-monitor.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# Auth Expiry Monitor +# Run via cron or systemd timer to get proactive notifications +# before Claude Code auth expires. +# +# Suggested cron: */30 * * * * /home/admin/clawdbot/scripts/auth-monitor.sh +# +# Environment variables: +# NOTIFY_PHONE - Phone number to send Clawdbot notification (e.g., +1234567890) +# NOTIFY_NTFY - ntfy.sh topic for push notifications (e.g., clawdbot-alerts) +# WARN_HOURS - Hours before expiry to warn (default: 2) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLAUDE_CREDS="$HOME/.claude/.credentials.json" +STATE_FILE="$HOME/.clawdbot/auth-monitor-state" + +# Configuration +WARN_HOURS="${WARN_HOURS:-2}" +NOTIFY_PHONE="${NOTIFY_PHONE:-}" +NOTIFY_NTFY="${NOTIFY_NTFY:-}" + +# State tracking to avoid spam +mkdir -p "$(dirname "$STATE_FILE")" +LAST_NOTIFIED=$(cat "$STATE_FILE" 2>/dev/null || echo "0") +NOW=$(date +%s) + +# Only notify once per hour max +MIN_INTERVAL=3600 + +send_notification() { + local message="$1" + local priority="${2:-default}" + + echo "$(date '+%Y-%m-%d %H:%M:%S') - $message" + + # Check if we notified recently + if [ $((NOW - LAST_NOTIFIED)) -lt $MIN_INTERVAL ]; then + echo "Skipping notification (sent recently)" + return + fi + + # Send via Clawdbot if phone configured and auth still valid + if [ -n "$NOTIFY_PHONE" ]; then + # Check if we can still use clawdbot + if "$SCRIPT_DIR/claude-auth-status.sh" simple 2>/dev/null | grep -q "OK\|EXPIRING"; then + echo "Sending via Clawdbot to $NOTIFY_PHONE..." + clawdbot send --to "$NOTIFY_PHONE" --message "$message" 2>/dev/null || true + fi + fi + + # Send via ntfy.sh if configured + if [ -n "$NOTIFY_NTFY" ]; then + echo "Sending via ntfy.sh to $NOTIFY_NTFY..." + curl -s -o /dev/null \ + -H "Title: Clawdbot Auth Alert" \ + -H "Priority: $priority" \ + -H "Tags: warning,key" \ + -d "$message" \ + "https://ntfy.sh/$NOTIFY_NTFY" || true + fi + + # Update state + echo "$NOW" > "$STATE_FILE" +} + +# Check auth status +if [ ! -f "$CLAUDE_CREDS" ]; then + send_notification "Claude Code credentials missing! Run: claude setup-token" "high" + exit 1 +fi + +EXPIRES_AT=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS") +NOW_MS=$((NOW * 1000)) +DIFF_MS=$((EXPIRES_AT - NOW_MS)) +HOURS_LEFT=$((DIFF_MS / 3600000)) +MINS_LEFT=$(((DIFF_MS % 3600000) / 60000)) + +if [ "$DIFF_MS" -lt 0 ]; then + send_notification "Claude Code auth EXPIRED! Clawdbot is down. Run: ssh l36 '~/clawdbot/scripts/mobile-reauth.sh'" "urgent" + exit 1 +elif [ "$HOURS_LEFT" -lt "$WARN_HOURS" ]; then + send_notification "Claude Code auth expires in ${HOURS_LEFT}h ${MINS_LEFT}m. Consider re-auth soon." "high" + exit 0 +else + echo "$(date '+%Y-%m-%d %H:%M:%S') - Auth OK: ${HOURS_LEFT}h ${MINS_LEFT}m remaining" + exit 0 +fi diff --git a/scripts/claude-auth-status.sh b/scripts/claude-auth-status.sh new file mode 100755 index 000000000..cf10b197d --- /dev/null +++ b/scripts/claude-auth-status.sh @@ -0,0 +1,280 @@ +#!/bin/bash +# Claude Code Authentication Status Checker +# Checks both Claude Code and Clawdbot auth status + +set -euo pipefail + +CLAUDE_CREDS="$HOME/.claude/.credentials.json" +CLAWDBOT_AUTH="$HOME/.clawdbot/agents/main/agent/auth-profiles.json" + +# Colors for terminal output +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +NC='\033[0m' # No Color + +# Output mode: "full" (default), "json", or "simple" +OUTPUT_MODE="${1:-full}" + +fetch_models_status_json() { + clawdbot models status --json 2>/dev/null || true +} + +STATUS_JSON="$(fetch_models_status_json)" +USE_JSON=0 +if [ -n "$STATUS_JSON" ]; then + USE_JSON=1 +fi + +calc_status_from_expires() { + local expires_at="$1" + if ! [[ "$expires_at" =~ ^-?[0-9]+$ ]]; then + expires_at=0 + fi + local now_ms=$(( $(date +%s) * 1000 )) + local diff_ms=$((expires_at - now_ms)) + local hours=$((diff_ms / 3600000)) + local mins=$(((diff_ms % 3600000) / 60000)) + + if [ "$expires_at" -le 0 ]; then + echo "MISSING" + return 1 + elif [ "$diff_ms" -lt 0 ]; then + echo "EXPIRED" + return 1 + elif [ "$diff_ms" -lt 3600000 ]; then + echo "EXPIRING:${mins}m" + return 2 + else + echo "OK:${hours}h${mins}m" + return 0 + fi +} + +json_expires_for_claude_cli() { + echo "$STATUS_JSON" | jq -r ' + [.auth.oauth.profiles[] + | select(.provider == "anthropic" and .type == "oauth" and .source == "claude-cli") + | .expiresAt // 0] + | max // 0 + ' 2>/dev/null || echo "0" +} + +json_expires_for_anthropic_any() { + echo "$STATUS_JSON" | jq -r ' + [.auth.oauth.profiles[] + | select(.provider == "anthropic" and .type == "oauth") + | .expiresAt // 0] + | max // 0 + ' 2>/dev/null || echo "0" +} + +json_best_anthropic_profile() { + echo "$STATUS_JSON" | jq -r ' + [.auth.oauth.profiles[] + | select(.provider == "anthropic" and .type == "oauth") + | {id: .profileId, exp: (.expiresAt // 0)}] + | sort_by(.exp) | reverse | .[0].id // "none" + ' 2>/dev/null || echo "none" +} + +json_anthropic_api_key_count() { + echo "$STATUS_JSON" | jq -r ' + [.auth.providers[] | select(.provider == "anthropic") | .profiles.apiKey] + | max // 0 + ' 2>/dev/null || echo "0" +} + +check_claude_code_auth() { + if [ "$USE_JSON" -eq 1 ]; then + local expires_at + expires_at=$(json_expires_for_claude_cli) + calc_status_from_expires "$expires_at" + return $? + fi + + if [ ! -f "$CLAUDE_CREDS" ]; then + echo "MISSING" + return 1 + fi + + local expires_at + expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0") + calc_status_from_expires "$expires_at" +} + +check_clawdbot_auth() { + if [ "$USE_JSON" -eq 1 ]; then + local api_keys + api_keys=$(json_anthropic_api_key_count) + if ! [[ "$api_keys" =~ ^[0-9]+$ ]]; then + api_keys=0 + fi + local expires_at + expires_at=$(json_expires_for_anthropic_any) + + if [ "$expires_at" -le 0 ] && [ "$api_keys" -gt 0 ]; then + echo "OK:static" + return 0 + fi + + calc_status_from_expires "$expires_at" + return $? + fi + + if [ ! -f "$CLAWDBOT_AUTH" ]; then + echo "MISSING" + return 1 + fi + + local expires + expires=$(jq -r ' + [.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires] + | max // 0 + ' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0") + + calc_status_from_expires "$expires" +} + +# JSON output mode +if [ "$OUTPUT_MODE" = "json" ]; then + claude_status=$(check_claude_code_auth 2>/dev/null || true) + clawdbot_status=$(check_clawdbot_auth 2>/dev/null || true) + + claude_expires=0 + clawdbot_expires=0 + if [ "$USE_JSON" -eq 1 ]; then + claude_expires=$(json_expires_for_claude_cli) + clawdbot_expires=$(json_expires_for_anthropic_any) + else + claude_expires=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0") + clawdbot_expires=$(jq -r '.profiles["anthropic:default"].expires // 0' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0") + fi + + jq -n \ + --arg cs "$claude_status" \ + --arg ce "$claude_expires" \ + --arg bs "$clawdbot_status" \ + --arg be "$clawdbot_expires" \ + '{ + claude_code: {status: $cs, expires_at_ms: ($ce | tonumber)}, + clawdbot: {status: $bs, expires_at_ms: ($be | tonumber)}, + needs_reauth: (($cs | startswith("EXPIRED") or startswith("EXPIRING") or startswith("MISSING")) or ($bs | startswith("EXPIRED") or startswith("EXPIRING") or startswith("MISSING"))) + }' + exit 0 +fi + +# Simple output mode (for scripts/widgets) +if [ "$OUTPUT_MODE" = "simple" ]; then + claude_status=$(check_claude_code_auth 2>/dev/null || true) + clawdbot_status=$(check_clawdbot_auth 2>/dev/null || true) + + if [[ "$claude_status" == EXPIRED* ]] || [[ "$claude_status" == MISSING* ]]; then + echo "CLAUDE_EXPIRED" + exit 1 + elif [[ "$clawdbot_status" == EXPIRED* ]] || [[ "$clawdbot_status" == MISSING* ]]; then + echo "CLAWDBOT_EXPIRED" + exit 1 + elif [[ "$claude_status" == EXPIRING* ]]; then + echo "CLAUDE_EXPIRING" + exit 2 + elif [[ "$clawdbot_status" == EXPIRING* ]]; then + echo "CLAWDBOT_EXPIRING" + exit 2 + else + echo "OK" + exit 0 + fi +fi + +# Full output mode (default) +echo "=== Claude Code Auth Status ===" +echo "" + +# Claude Code credentials +echo "Claude Code (~/.claude/.credentials.json):" +if [ "$USE_JSON" -eq 1 ]; then + expires_at=$(json_expires_for_claude_cli) +else + expires_at=$(jq -r '.claudeAiOauth.expiresAt // 0' "$CLAUDE_CREDS" 2>/dev/null || echo "0") +fi + +if [ -f "$CLAUDE_CREDS" ]; then + sub_type=$(jq -r '.claudeAiOauth.subscriptionType // "unknown"' "$CLAUDE_CREDS" 2>/dev/null || echo "unknown") + rate_tier=$(jq -r '.claudeAiOauth.rateLimitTier // "unknown"' "$CLAUDE_CREDS" 2>/dev/null || echo "unknown") + echo " Subscription: $sub_type" + echo " Rate tier: $rate_tier" +fi + +if [ "$expires_at" -le 0 ]; then + echo -e " Status: ${RED}NOT FOUND${NC}" + echo " Action needed: Run 'claude setup-token'" +else + now_ms=$(( $(date +%s) * 1000 )) + diff_ms=$((expires_at - now_ms)) + hours=$((diff_ms / 3600000)) + mins=$(((diff_ms % 3600000) / 60000)) + + if [ "$diff_ms" -lt 0 ]; then + echo -e " Status: ${RED}EXPIRED${NC}" + echo " Action needed: Run 'claude setup-token' or re-authenticate" + elif [ "$diff_ms" -lt 3600000 ]; then + echo -e " Status: ${YELLOW}EXPIRING SOON (${mins}m remaining)${NC}" + echo " Consider running: claude setup-token" + else + echo -e " Status: ${GREEN}OK${NC}" + echo " Expires: $(date -d @$((expires_at/1000))) (${hours}h ${mins}m)" + fi +fi + +echo "" +echo "Clawdbot Auth (~/.clawdbot/agents/main/agent/auth-profiles.json):" +if [ "$USE_JSON" -eq 1 ]; then + best_profile=$(json_best_anthropic_profile) + expires=$(json_expires_for_anthropic_any) + api_keys=$(json_anthropic_api_key_count) +else + best_profile=$(jq -r ' + .profiles | to_entries + | map(select(.value.provider == "anthropic")) + | sort_by(.value.expires) | reverse + | .[0].key // "none" + ' "$CLAWDBOT_AUTH" 2>/dev/null || echo "none") + expires=$(jq -r ' + [.profiles | to_entries[] | select(.value.provider == "anthropic") | .value.expires] + | max // 0 + ' "$CLAWDBOT_AUTH" 2>/dev/null || echo "0") + api_keys=0 +fi + +echo " Profile: $best_profile" + +if [ "$expires" -le 0 ] && [ "$api_keys" -gt 0 ]; then + echo -e " Status: ${GREEN}OK${NC} (API key)" +elif [ "$expires" -le 0 ]; then + echo -e " Status: ${RED}NOT FOUND${NC}" + echo " Note: Run 'clawdbot doctor --yes' to sync from Claude Code" +else + now_ms=$(( $(date +%s) * 1000 )) + diff_ms=$((expires - now_ms)) + hours=$((diff_ms / 3600000)) + mins=$(((diff_ms % 3600000) / 60000)) + + if [ "$diff_ms" -lt 0 ]; then + echo -e " Status: ${RED}EXPIRED${NC}" + echo " Note: Run 'clawdbot doctor --yes' to sync from Claude Code" + elif [ "$diff_ms" -lt 3600000 ]; then + echo -e " Status: ${YELLOW}EXPIRING SOON (${mins}m remaining)${NC}" + else + echo -e " Status: ${GREEN}OK${NC}" + echo " Expires: $(date -d @$((expires/1000))) (${hours}h ${mins}m)" + fi +fi + +echo "" +echo "=== Service Status ===" +if systemctl --user is-active clawdbot >/dev/null 2>&1; then + echo -e "Clawdbot service: ${GREEN}running${NC}" +else + echo -e "Clawdbot service: ${RED}NOT running${NC}" +fi diff --git a/scripts/mobile-reauth.sh b/scripts/mobile-reauth.sh new file mode 100755 index 000000000..d6979cc3a --- /dev/null +++ b/scripts/mobile-reauth.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# Mobile-friendly Claude Code re-authentication +# Designed for use via SSH from Termux +# +# This script handles the authentication flow in a way that works +# from a mobile device by: +# 1. Checking if auth is needed +# 2. Running claude setup-token for long-lived auth +# 3. Outputting URLs that can be easily opened on phone + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +echo "=== Claude Code Mobile Re-Auth ===" +echo "" + +# Check current auth status +echo "Checking auth status..." +AUTH_STATUS=$("$SCRIPT_DIR/claude-auth-status.sh" simple 2>/dev/null || echo "ERROR") + +case "$AUTH_STATUS" in + OK) + echo -e "${GREEN}Auth is valid!${NC}" + "$SCRIPT_DIR/claude-auth-status.sh" full + exit 0 + ;; + CLAUDE_EXPIRING|CLAWDBOT_EXPIRING) + echo -e "${YELLOW}Auth is expiring soon.${NC}" + echo "" + ;; + *) + echo -e "${RED}Auth needs refresh.${NC}" + echo "" + ;; +esac + +echo "Starting long-lived token setup..." +echo "" +echo -e "${CYAN}Instructions:${NC}" +echo "1. Open this URL on your phone:" +echo "" +echo -e " ${CYAN}https://console.anthropic.com/settings/api-keys${NC}" +echo "" +echo "2. Sign in if needed" +echo "3. Create a new API key or use existing 'Claude Code' key" +echo "4. Copy the key (starts with sk-ant-...)" +echo "5. When prompted below, paste the key" +echo "" +echo "Press Enter when ready to continue..." +read -r + +# Run setup-token interactively +echo "" +echo "Running 'claude setup-token'..." +echo "(Follow the prompts and paste your API key when asked)" +echo "" + +if claude setup-token; then + echo "" + echo -e "${GREEN}Authentication successful!${NC}" + echo "" + "$SCRIPT_DIR/claude-auth-status.sh" full + + # Restart clawdbot service if running + if systemctl --user is-active clawdbot >/dev/null 2>&1; then + echo "" + echo "Restarting clawdbot service..." + systemctl --user restart clawdbot + echo -e "${GREEN}Service restarted.${NC}" + fi +else + echo "" + echo -e "${RED}Authentication failed.${NC}" + echo "Please try again or check the Claude Code documentation." + exit 1 +fi diff --git a/scripts/setup-auth-system.sh b/scripts/setup-auth-system.sh new file mode 100755 index 000000000..d7b6ccfdf --- /dev/null +++ b/scripts/setup-auth-system.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# Setup Clawdbot Auth Management System +# Run this once to set up: +# 1. Long-lived Claude Code token +# 2. Auth monitoring with notifications +# 3. Instructions for Termux widgets + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "=== Clawdbot Auth System Setup ===" +echo "" + +# Step 1: Check current auth status +echo "Step 1: Checking current auth status..." +"$SCRIPT_DIR/claude-auth-status.sh" full || true +echo "" + +# Step 2: Set up long-lived token +echo "Step 2: Long-lived token setup" +echo "" +echo "Option A: Use 'claude setup-token' (recommended)" +echo " - Creates a long-lived API token" +echo " - No daily re-auth needed" +echo " - Run: claude setup-token" +echo "" +echo "Would you like to set up a long-lived token now? [y/N]" +read -r SETUP_TOKEN + +if [[ "$SETUP_TOKEN" =~ ^[Yy] ]]; then + echo "" + echo "Opening https://console.anthropic.com/settings/api-keys" + echo "Create a new key or copy existing one, then paste below." + echo "" + claude setup-token +fi + +echo "" + +# Step 3: Set up auth monitoring +echo "Step 3: Auth monitoring setup" +echo "" +echo "The auth monitor checks expiry every 30 minutes and notifies you." +echo "" +echo "Configure notification channels:" +echo "" + +# Check for ntfy +echo " ntfy.sh: Free push notifications to your phone" +echo " 1. Install ntfy app on your phone" +echo " 2. Subscribe to a topic (e.g., 'clawdbot-alerts')" +echo "" +echo "Enter ntfy.sh topic (or leave blank to skip):" +read -r NTFY_TOPIC + +# Phone notification +echo "" +echo " Clawdbot message: Send warning via Clawdbot itself" +echo "Enter your phone number for alerts (or leave blank to skip):" +read -r PHONE_NUMBER + +# Update service file +SERVICE_FILE="$SCRIPT_DIR/systemd/clawdbot-auth-monitor.service" +if [ -n "$NTFY_TOPIC" ]; then + sed -i "s|# Environment=NOTIFY_NTFY=.*|Environment=NOTIFY_NTFY=$NTFY_TOPIC|" "$SERVICE_FILE" +fi +if [ -n "$PHONE_NUMBER" ]; then + sed -i "s|# Environment=NOTIFY_PHONE=.*|Environment=NOTIFY_PHONE=$PHONE_NUMBER|" "$SERVICE_FILE" +fi + +# Install systemd units +echo "" +echo "Installing systemd timer..." +mkdir -p ~/.config/systemd/user +cp "$SCRIPT_DIR/systemd/clawdbot-auth-monitor.service" ~/.config/systemd/user/ +cp "$SCRIPT_DIR/systemd/clawdbot-auth-monitor.timer" ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now clawdbot-auth-monitor.timer + +echo "Auth monitor installed and running." +echo "" + +# Step 4: Termux widget setup +echo "Step 4: Termux widget setup (for phone)" +echo "" +echo "To set up quick auth from your phone:" +echo "" +echo "1. Install Termux and Termux:Widget from F-Droid" +echo "2. Create ~/.shortcuts/ directory in Termux:" +echo " mkdir -p ~/.shortcuts" +echo "" +echo "3. Copy the widget scripts:" +echo " scp $SCRIPT_DIR/termux-quick-auth.sh phone:~/.shortcuts/ClawdAuth" +echo " scp $SCRIPT_DIR/termux-auth-widget.sh phone:~/.shortcuts/ClawdAuth-Full" +echo "" +echo "4. Make them executable on phone:" +echo " ssh phone 'chmod +x ~/.shortcuts/Clawd*'" +echo "" +echo "5. Add Termux:Widget to your home screen" +echo "6. Tap the widget to see your auth scripts" +echo "" +echo "The quick widget (ClawdAuth) shows status and opens auth URL if needed." +echo "The full widget (ClawdAuth-Full) provides guided re-auth flow." +echo "" + +# Summary +echo "=== Setup Complete ===" +echo "" +echo "What's configured:" +echo " - Auth status: $SCRIPT_DIR/claude-auth-status.sh" +echo " - Mobile re-auth: $SCRIPT_DIR/mobile-reauth.sh" +echo " - Auth monitor: systemctl --user status clawdbot-auth-monitor.timer" +echo "" +echo "Quick commands:" +echo " Check auth: $SCRIPT_DIR/claude-auth-status.sh" +echo " Re-auth: $SCRIPT_DIR/mobile-reauth.sh" +echo " Test monitor: $SCRIPT_DIR/auth-monitor.sh" +echo "" diff --git a/scripts/systemd/clawdbot-auth-monitor.service b/scripts/systemd/clawdbot-auth-monitor.service new file mode 100644 index 000000000..1391a2466 --- /dev/null +++ b/scripts/systemd/clawdbot-auth-monitor.service @@ -0,0 +1,14 @@ +[Unit] +Description=Clawdbot Auth Expiry Monitor +After=network.target + +[Service] +Type=oneshot +ExecStart=/home/admin/clawdbot/scripts/auth-monitor.sh +# Configure notification channels via environment +Environment=WARN_HOURS=2 +# Environment=NOTIFY_PHONE=+1234567890 +# Environment=NOTIFY_NTFY=clawdbot-alerts + +[Install] +WantedBy=default.target diff --git a/scripts/systemd/clawdbot-auth-monitor.timer b/scripts/systemd/clawdbot-auth-monitor.timer new file mode 100644 index 000000000..19952dcc7 --- /dev/null +++ b/scripts/systemd/clawdbot-auth-monitor.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Check Clawdbot auth expiry every 30 minutes + +[Timer] +OnBootSec=5min +OnUnitActiveSec=30min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/scripts/termux-auth-widget.sh b/scripts/termux-auth-widget.sh new file mode 100644 index 000000000..d248b2eb8 --- /dev/null +++ b/scripts/termux-auth-widget.sh @@ -0,0 +1,81 @@ +#!/data/data/com.termux/files/usr/bin/bash +# Clawdbot Auth Widget for Termux +# Place in ~/.shortcuts/ for Termux:Widget +# +# This widget checks auth status and helps with re-auth if needed. +# It's designed for quick one-tap checking from phone home screen. + +# Server hostname (via Tailscale or SSH config) +SERVER="${CLAWDBOT_SERVER:-l36}" + +# Check auth status +termux-toast "Checking Clawdbot auth..." + +STATUS=$(ssh "$SERVER" '$HOME/clawdbot/scripts/claude-auth-status.sh simple' 2>&1) +EXIT_CODE=$? + +case "$STATUS" in + OK) + # Get remaining time + DETAILS=$(ssh "$SERVER" '$HOME/clawdbot/scripts/claude-auth-status.sh json' 2>&1) + HOURS=$(echo "$DETAILS" | jq -r '.claude_code.status' | grep -oP '\d+(?=h)' || echo "?") + + termux-vibrate -d 50 + termux-toast "Auth OK (${HOURS}h left)" + ;; + + CLAUDE_EXPIRING|CLAWDBOT_EXPIRING) + termux-vibrate -d 100 + + # Ask if user wants to re-auth now + CHOICE=$(termux-dialog radio -t "Auth Expiring Soon" -v "Re-auth now,Check later,Dismiss") + SELECTED=$(echo "$CHOICE" | jq -r '.text // "Dismiss"') + + case "$SELECTED" in + "Re-auth now") + termux-toast "Opening auth page..." + termux-open-url "https://console.anthropic.com/settings/api-keys" + + # Show instructions + termux-dialog confirm -t "Re-auth Instructions" -i "1. Create/copy API key from browser +2. Return here and tap OK +3. SSH to server and paste key" + + # Open terminal to server + am start -n com.termux/com.termux.app.TermuxActivity -a android.intent.action.MAIN + termux-toast "Run: ssh $SERVER '$HOME/clawdbot/scripts/mobile-reauth.sh'" + ;; + *) + termux-toast "Reminder: Auth expires soon" + ;; + esac + ;; + + CLAUDE_EXPIRED|CLAWDBOT_EXPIRED) + termux-vibrate -d 300 + + CHOICE=$(termux-dialog radio -t "Auth Expired!" -v "Re-auth now,Dismiss") + SELECTED=$(echo "$CHOICE" | jq -r '.text // "Dismiss"') + + case "$SELECTED" in + "Re-auth now") + termux-toast "Opening auth page..." + termux-open-url "https://console.anthropic.com/settings/api-keys" + + termux-dialog confirm -t "Re-auth Steps" -i "1. Create/copy API key from browser +2. Return here and tap OK to SSH" + + am start -n com.termux/com.termux.app.TermuxActivity -a android.intent.action.MAIN + termux-toast "Run: ssh $SERVER '$HOME/clawdbot/scripts/mobile-reauth.sh'" + ;; + *) + termux-toast "Warning: Clawdbot won't work until re-auth" + ;; + esac + ;; + + *) + termux-vibrate -d 200 + termux-toast "Error: $STATUS" + ;; +esac diff --git a/scripts/termux-quick-auth.sh b/scripts/termux-quick-auth.sh new file mode 100644 index 000000000..4bcb32436 --- /dev/null +++ b/scripts/termux-quick-auth.sh @@ -0,0 +1,30 @@ +#!/data/data/com.termux/files/usr/bin/bash +# Quick Auth Check - Minimal widget for Termux +# Place in ~/.shortcuts/ for Termux:Widget +# +# One-tap: shows status toast +# If expired: directly opens auth URL + +SERVER="${CLAWDBOT_SERVER:-l36}" + +STATUS=$(ssh -o ConnectTimeout=5 "$SERVER" '$HOME/clawdbot/scripts/claude-auth-status.sh simple' 2>&1) + +case "$STATUS" in + OK) + termux-toast -s "Auth OK" + ;; + *EXPIRING*) + termux-vibrate -d 100 + termux-toast "Auth expiring soon - tap again if needed" + ;; + *EXPIRED*|*MISSING*) + termux-vibrate -d 200 + termux-toast "Auth expired - opening console..." + termux-open-url "https://console.anthropic.com/settings/api-keys" + sleep 2 + termux-notification -t "Clawdbot Re-Auth" -c "After getting key, run: ssh $SERVER '~/clawdbot/scripts/mobile-reauth.sh'" --id clawd-auth + ;; + *) + termux-toast "Connection error" + ;; +esac diff --git a/scripts/termux-sync-widget.sh b/scripts/termux-sync-widget.sh new file mode 100644 index 000000000..b5675071e --- /dev/null +++ b/scripts/termux-sync-widget.sh @@ -0,0 +1,24 @@ +#!/data/data/com.termux/files/usr/bin/bash +# Clawdbot OAuth Sync Widget +# Syncs Claude Code tokens to Clawdbot on l36 server +# Place in ~/.shortcuts/ on phone for Termux:Widget + +termux-toast "Syncing Clawdbot auth..." + +# Run sync on l36 server +RESULT=$(ssh l36 '/home/admin/clawdbot/scripts/sync-claude-code-auth.sh' 2>&1) +EXIT_CODE=$? + +if [ $EXIT_CODE -eq 0 ]; then + # Extract expiry time from output + EXPIRY=$(echo "$RESULT" | grep "Token expires:" | cut -d: -f2-) + + termux-vibrate -d 100 + termux-toast "Clawdbot synced! Expires:${EXPIRY}" + + # Optional: restart clawdbot service + ssh l36 'systemctl --user restart clawdbot' 2>/dev/null +else + termux-vibrate -d 300 + termux-toast "Sync failed: ${RESULT}" +fi diff --git a/src/agents/auth-health.test.ts b/src/agents/auth-health.test.ts new file mode 100644 index 000000000..4d11f9329 --- /dev/null +++ b/src/agents/auth-health.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + buildAuthHealthSummary, + DEFAULT_OAUTH_WARN_MS, +} from "./auth-health.js"; + +describe("buildAuthHealthSummary", () => { + const now = 1_700_000_000_000; + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("classifies OAuth and API key profiles", () => { + vi.spyOn(Date, "now").mockReturnValue(now); + const store = { + version: 1, + profiles: { + "anthropic:ok": { + type: "oauth" as const, + provider: "anthropic", + access: "access", + refresh: "refresh", + expires: now + DEFAULT_OAUTH_WARN_MS + 60_000, + }, + "anthropic:expiring": { + type: "oauth" as const, + provider: "anthropic", + access: "access", + refresh: "refresh", + expires: now + 10_000, + }, + "anthropic:expired": { + type: "oauth" as const, + provider: "anthropic", + access: "access", + refresh: "refresh", + expires: now - 10_000, + }, + "anthropic:api": { + type: "api_key" as const, + provider: "anthropic", + key: "sk-ant-api", + }, + }, + }; + + const summary = buildAuthHealthSummary({ + store, + warnAfterMs: DEFAULT_OAUTH_WARN_MS, + }); + + const statuses = Object.fromEntries( + summary.profiles.map((profile) => [profile.profileId, profile.status]), + ); + + expect(statuses["anthropic:ok"]).toBe("ok"); + expect(statuses["anthropic:expiring"]).toBe("expiring"); + expect(statuses["anthropic:expired"]).toBe("expired"); + expect(statuses["anthropic:api"]).toBe("static"); + + const provider = summary.providers.find( + (entry) => entry.provider === "anthropic", + ); + expect(provider?.status).toBe("expired"); + }); +}); diff --git a/src/agents/auth-health.ts b/src/agents/auth-health.ts new file mode 100644 index 000000000..51e969b94 --- /dev/null +++ b/src/agents/auth-health.ts @@ -0,0 +1,227 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import { + type AuthProfileCredential, + type AuthProfileStore, + CLAUDE_CLI_PROFILE_ID, + CODEX_CLI_PROFILE_ID, + resolveAuthProfileDisplayLabel, +} from "./auth-profiles.js"; + +export type AuthProfileSource = "claude-cli" | "codex-cli" | "store"; + +export type AuthProfileHealthStatus = + | "ok" + | "expiring" + | "expired" + | "missing" + | "static"; + +export type AuthProfileHealth = { + profileId: string; + provider: string; + type: "oauth" | "api_key"; + status: AuthProfileHealthStatus; + expiresAt?: number; + remainingMs?: number; + source: AuthProfileSource; + label: string; +}; + +export type AuthProviderHealthStatus = + | "ok" + | "expiring" + | "expired" + | "missing" + | "static"; + +export type AuthProviderHealth = { + provider: string; + status: AuthProviderHealthStatus; + expiresAt?: number; + remainingMs?: number; + profiles: AuthProfileHealth[]; +}; + +export type AuthHealthSummary = { + now: number; + warnAfterMs: number; + profiles: AuthProfileHealth[]; + providers: AuthProviderHealth[]; +}; + +export const DEFAULT_OAUTH_WARN_MS = 24 * 60 * 60 * 1000; + +export function resolveAuthProfileSource(profileId: string): AuthProfileSource { + if (profileId === CLAUDE_CLI_PROFILE_ID) return "claude-cli"; + if (profileId === CODEX_CLI_PROFILE_ID) return "codex-cli"; + return "store"; +} + +export function formatRemainingShort(remainingMs?: number): string { + if (remainingMs === undefined || Number.isNaN(remainingMs)) return "unknown"; + if (remainingMs <= 0) return "0m"; + const minutes = Math.max(1, Math.round(remainingMs / 60_000)); + if (minutes < 60) return `${minutes}m`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours}h`; + const days = Math.round(hours / 24); + return `${days}d`; +} + +function resolveOAuthStatus( + expiresAt: number | undefined, + now: number, + warnAfterMs: number, +): { status: AuthProfileHealthStatus; remainingMs?: number } { + if (!expiresAt || !Number.isFinite(expiresAt) || expiresAt <= 0) { + return { status: "missing" }; + } + const remainingMs = expiresAt - now; + if (remainingMs <= 0) { + return { status: "expired", remainingMs }; + } + if (remainingMs <= warnAfterMs) { + return { status: "expiring", remainingMs }; + } + return { status: "ok", remainingMs }; +} + +function buildProfileHealth(params: { + profileId: string; + credential: AuthProfileCredential; + store: AuthProfileStore; + cfg?: ClawdbotConfig; + now: number; + warnAfterMs: number; +}): AuthProfileHealth { + const { profileId, credential, store, cfg, now, warnAfterMs } = params; + const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); + const source = resolveAuthProfileSource(profileId); + + if (credential.type === "api_key") { + return { + profileId, + provider: credential.provider, + type: "api_key", + status: "static", + source, + label, + }; + } + + const { status, remainingMs } = resolveOAuthStatus( + credential.expires, + now, + warnAfterMs, + ); + return { + profileId, + provider: credential.provider, + type: "oauth", + status, + expiresAt: credential.expires, + remainingMs, + source, + label, + }; +} + +export function buildAuthHealthSummary(params: { + store: AuthProfileStore; + cfg?: ClawdbotConfig; + warnAfterMs?: number; + providers?: string[]; +}): AuthHealthSummary { + const now = Date.now(); + const warnAfterMs = params.warnAfterMs ?? DEFAULT_OAUTH_WARN_MS; + const providerFilter = params.providers + ? new Set(params.providers.map((p) => p.trim()).filter(Boolean)) + : null; + + const profiles = Object.entries(params.store.profiles) + .filter(([_, cred]) => + providerFilter ? providerFilter.has(cred.provider) : true, + ) + .map(([profileId, credential]) => + buildProfileHealth({ + profileId, + credential, + store: params.store, + cfg: params.cfg, + now, + warnAfterMs, + }), + ) + .sort((a, b) => { + if (a.provider !== b.provider) { + return a.provider.localeCompare(b.provider); + } + return a.profileId.localeCompare(b.profileId); + }); + + const providersMap = new Map(); + for (const profile of profiles) { + const existing = providersMap.get(profile.provider); + if (!existing) { + providersMap.set(profile.provider, { + provider: profile.provider, + status: "missing", + profiles: [profile], + }); + } else { + existing.profiles.push(profile); + } + } + + if (providerFilter) { + for (const provider of providerFilter) { + if (!providersMap.has(provider)) { + providersMap.set(provider, { + provider, + status: "missing", + profiles: [], + }); + } + } + } + + for (const provider of providersMap.values()) { + if (provider.profiles.length === 0) { + provider.status = "missing"; + continue; + } + + const oauthProfiles = provider.profiles.filter((p) => p.type === "oauth"); + const apiKeyProfiles = provider.profiles.filter( + (p) => p.type === "api_key", + ); + + if (oauthProfiles.length === 0) { + provider.status = apiKeyProfiles.length > 0 ? "static" : "missing"; + continue; + } + + const expiryCandidates = oauthProfiles + .map((p) => p.expiresAt) + .filter((v): v is number => typeof v === "number" && Number.isFinite(v)); + if (expiryCandidates.length > 0) { + provider.expiresAt = Math.min(...expiryCandidates); + provider.remainingMs = provider.expiresAt - now; + } + + const statuses = oauthProfiles.map((p) => p.status); + if (statuses.includes("expired") || statuses.includes("missing")) { + provider.status = "expired"; + } else if (statuses.includes("expiring")) { + provider.status = "expiring"; + } else { + provider.status = "ok"; + } + } + + const providers = Array.from(providersMap.values()).sort((a, b) => + a.provider.localeCompare(b.provider), + ); + + return { now, warnAfterMs, profiles, providers }; +} diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index 55ed6edec..a690ad505 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -159,6 +159,9 @@ describe("directive behavior", () => { expect(text).toContain( "Current queue settings: mode=collect, debounce=1500ms, cap=9, drop=summarize.", ); + expect(text).toContain( + "Options: modes steer, followup, collect, steer+backlog, interrupt; debounce:, cap:, drop:old|new|summarize.", + ); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -182,6 +185,7 @@ describe("directive behavior", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toContain("Current thinking level: high"); + expect(text).toContain("Options: off, minimal, low, medium, high."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -204,6 +208,7 @@ describe("directive behavior", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toContain("Current thinking level: off"); + expect(text).toContain("Options: off, minimal, low, medium, high."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -358,6 +363,7 @@ describe("directive behavior", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toContain("Current thinking level: high"); + expect(text).toContain("Options: off, minimal, low, medium, high."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -380,6 +386,7 @@ describe("directive behavior", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toContain("Current thinking level: off"); + expect(text).toContain("Options: off, minimal, low, medium, high."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -403,6 +410,7 @@ describe("directive behavior", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toContain("Current verbose level: on"); + expect(text).toContain("Options: on, off."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -425,6 +433,7 @@ describe("directive behavior", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toContain("Current reasoning level: off"); + expect(text).toContain("Options: on, off, stream."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); @@ -458,6 +467,7 @@ describe("directive behavior", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toContain("Current elevated level: on"); + expect(text).toContain("Options: on, off."); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply/directive-handling.ts b/src/auto-reply/reply/directive-handling.ts index cdc2fc8ac..30c71ccf1 100644 --- a/src/auto-reply/reply/directive-handling.ts +++ b/src/auto-reply/reply/directive-handling.ts @@ -54,6 +54,9 @@ import { } from "./queue.js"; const SYSTEM_MARK = "⚙️"; +const formatOptionsLine = (options: string) => `Options: ${options}.`; +const withOptions = (line: string, options: string) => + `${line}\n${formatOptionsLine(options)}`; const maskApiKey = (value: string): string => { const trimmed = value.trim(); @@ -417,7 +420,12 @@ export async function handleDirectiveOnly(params: { // If no argument was provided, show the current level if (!directives.rawThinkLevel) { const level = currentThinkLevel ?? "off"; - return { text: `Current thinking level: ${level}.` }; + return { + text: withOptions( + `Current thinking level: ${level}.`, + "off, minimal, low, medium, high", + ), + }; } return { text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: off, minimal, low, medium, high.`, @@ -426,7 +434,9 @@ export async function handleDirectiveOnly(params: { if (directives.hasVerboseDirective && !directives.verboseLevel) { if (!directives.rawVerboseLevel) { const level = currentVerboseLevel ?? "off"; - return { text: `Current verbose level: ${level}.` }; + return { + text: withOptions(`Current verbose level: ${level}.`, "on, off"), + }; } return { text: `Unrecognized verbose level "${directives.rawVerboseLevel}". Valid levels: off, on.`, @@ -435,7 +445,12 @@ export async function handleDirectiveOnly(params: { if (directives.hasReasoningDirective && !directives.reasoningLevel) { if (!directives.rawReasoningLevel) { const level = currentReasoningLevel ?? "off"; - return { text: `Current reasoning level: ${level}.` }; + return { + text: withOptions( + `Current reasoning level: ${level}.`, + "on, off, stream", + ), + }; } return { text: `Unrecognized reasoning level "${directives.rawReasoningLevel}". Valid levels: on, off, stream.`, @@ -447,7 +462,9 @@ export async function handleDirectiveOnly(params: { return { text: "elevated is not available right now." }; } const level = currentElevatedLevel ?? "off"; - return { text: `Current elevated level: ${level}.` }; + return { + text: withOptions(`Current elevated level: ${level}.`, "on, off"), + }; } return { text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`, @@ -483,7 +500,10 @@ export async function handleDirectiveOnly(params: { typeof settings.cap === "number" ? String(settings.cap) : "default"; const dropLabel = settings.dropPolicy ?? "default"; return { - text: `Current queue settings: mode=${settings.mode}, debounce=${debounceLabel}, cap=${capLabel}, drop=${dropLabel}.`, + text: withOptions( + `Current queue settings: mode=${settings.mode}, debounce=${debounceLabel}, cap=${capLabel}, drop=${dropLabel}.`, + "modes steer, followup, collect, steer+backlog, interrupt; debounce:, cap:, drop:old|new|summarize", + ), }; } diff --git a/src/cli/gateway.sigterm.test.ts b/src/cli/gateway.sigterm.test.ts index 533cd06f4..5d722cf16 100644 --- a/src/cli/gateway.sigterm.test.ts +++ b/src/cli/gateway.sigterm.test.ts @@ -90,8 +90,10 @@ describe("gateway SIGTERM", () => { const err: string[] = []; child = spawn( - "bun", + process.execPath, [ + "--import", + "tsx", "src/index.ts", "gateway", "--port", diff --git a/src/cli/logs-cli.ts b/src/cli/logs-cli.ts index 6cb4dc660..e55222396 100644 --- a/src/cli/logs-cli.ts +++ b/src/cli/logs-cli.ts @@ -1,6 +1,9 @@ import { setTimeout as delay } from "node:timers/promises"; import type { Command } from "commander"; +import { buildGatewayConnectionDetails } from "../gateway/call.js"; +import { parseLogLine } from "../logging/parse-log-line.js"; import { defaultRuntime } from "../runtime.js"; +import { colorize, isRich, theme } from "../terminal/theme.js"; import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js"; type LogsTailPayload = { @@ -18,6 +21,8 @@ type LogsCliOptions = { follow?: boolean; interval?: string; json?: boolean; + plain?: boolean; + color?: boolean; url?: string; token?: string; timeout?: string; @@ -47,6 +52,92 @@ async function fetchLogs( return payload as LogsTailPayload; } +function formatLogTimestamp(value?: string, mode: "pretty" | "plain" = "plain") { + if (!value) return ""; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return value; + if (mode === "pretty") return parsed.toISOString().slice(11, 19); + return parsed.toISOString(); +} + +function formatLogLine( + raw: string, + opts: { + pretty: boolean; + rich: boolean; + }, +): string { + const parsed = parseLogLine(raw); + if (!parsed) return raw; + const label = parsed.subsystem ?? parsed.module ?? ""; + const time = formatLogTimestamp(parsed.time, opts.pretty ? "pretty" : "plain"); + const level = parsed.level ?? ""; + const levelLabel = level.padEnd(5).trim(); + const message = parsed.message || parsed.raw; + + if (!opts.pretty) { + return [time, level, label, message].filter(Boolean).join(" ").trim(); + } + + const timeLabel = colorize(opts.rich, theme.muted, time); + const labelValue = colorize(opts.rich, theme.accent, label); + const levelValue = + level === "error" || level === "fatal" + ? colorize(opts.rich, theme.error, levelLabel) + : level === "warn" + ? colorize(opts.rich, theme.warn, levelLabel) + : level === "debug" || level === "trace" + ? colorize(opts.rich, theme.muted, levelLabel) + : colorize(opts.rich, theme.info, levelLabel); + const messageValue = + level === "error" || level === "fatal" + ? colorize(opts.rich, theme.error, message) + : level === "warn" + ? colorize(opts.rich, theme.warn, message) + : level === "debug" || level === "trace" + ? colorize(opts.rich, theme.muted, message) + : colorize(opts.rich, theme.info, message); + + const head = [timeLabel, levelValue, labelValue].filter(Boolean).join(" "); + return [head, messageValue].filter(Boolean).join(" ").trim(); +} + +function emitJsonLine(payload: Record, toStdErr = false) { + const text = `${JSON.stringify(payload)}\n`; + if (toStdErr) process.stderr.write(text); + else process.stdout.write(text); +} + +function emitGatewayError( + err: unknown, + opts: LogsCliOptions, + mode: "json" | "text", + rich: boolean, +) { + const details = buildGatewayConnectionDetails({ url: opts.url }); + const message = "Gateway not reachable. Is it running and accessible?"; + const hint = "Hint: run `clawdbot doctor`."; + const errorText = err instanceof Error ? err.message : String(err); + + if (mode === "json") { + emitJsonLine( + { + type: "error", + message, + error: errorText, + details, + hint, + }, + true, + ); + return; + } + + defaultRuntime.error(colorize(rich, theme.error, message)); + defaultRuntime.error(details.message); + defaultRuntime.error(colorize(rich, theme.muted, hint)); +} + export function registerLogsCli(program: Command) { const logs = program .command("logs") @@ -55,7 +146,9 @@ export function registerLogsCli(program: Command) { .option("--max-bytes ", "Max bytes to read", "250000") .option("--follow", "Follow log output", false) .option("--interval ", "Polling interval in ms", "1000") - .option("--json", "Emit JSON payloads", false); + .option("--json", "Emit JSON log lines", false) + .option("--plain", "Plain text output (no ANSI styling)", false) + .option("--no-color", "Disable ANSI colors"); addGatewayClientOptions(logs); @@ -63,18 +156,63 @@ export function registerLogsCli(program: Command) { const interval = parsePositiveInt(opts.interval, 1000); let cursor: number | undefined; let first = true; + const jsonMode = Boolean(opts.json); + const pretty = !jsonMode && Boolean(process.stdout.isTTY) && !opts.plain; + const rich = isRich() && opts.color !== false; while (true) { - const payload = await fetchLogs(opts, cursor); + let payload: LogsTailPayload; + try { + payload = await fetchLogs(opts, cursor); + } catch (err) { + emitGatewayError(err, opts, jsonMode ? "json" : "text", rich); + defaultRuntime.exit(1); + return; + } const lines = Array.isArray(payload.lines) ? payload.lines : []; - if (opts.json) { - defaultRuntime.log(JSON.stringify(payload, null, 2)); - } else { - if (first && payload.file) { - defaultRuntime.log(`Log file: ${payload.file}`); + if (jsonMode) { + if (first) { + emitJsonLine({ + type: "meta", + file: payload.file, + cursor: payload.cursor, + size: payload.size, + }); } for (const line of lines) { - defaultRuntime.log(line); + const parsed = parseLogLine(line); + if (parsed) { + emitJsonLine({ type: "log", ...parsed }); + } else { + emitJsonLine({ type: "raw", raw: line }); + } + } + if (payload.truncated) { + emitJsonLine({ + type: "notice", + message: "Log tail truncated (increase --max-bytes).", + }); + } + if (payload.reset) { + emitJsonLine({ + type: "notice", + message: "Log cursor reset (file rotated).", + }); + } + } else { + if (first && payload.file) { + const prefix = pretty + ? colorize(rich, theme.muted, "Log file:") + : "Log file:"; + defaultRuntime.log(`${prefix} ${payload.file}`); + } + for (const line of lines) { + defaultRuntime.log( + formatLogLine(line, { + pretty, + rich, + }), + ); } if (payload.truncated) { defaultRuntime.error("Log tail truncated (increase --max-bytes)."); diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 484f512ea..85d7d8149 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -57,6 +57,11 @@ export function registerModelsCli(program: Command) { .description("Show configured model state") .option("--json", "Output JSON", false) .option("--plain", "Plain output", false) + .option( + "--check", + "Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)", + false, + ) .action(async (opts) => { try { await modelsStatusCommand(opts, defaultRuntime); diff --git a/src/cli/program.ts b/src/cli/program.ts index 827e3bc0b..bccfd049e 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -232,9 +232,10 @@ export function buildProgram() { .option("--mode ", "Wizard mode: local|remote") .option( "--auth-choice ", - "Auth: oauth|claude-cli|openai-codex|codex-cli|antigravity|apiKey|minimax|skip", + "Auth: oauth|claude-cli|openai-codex|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip", ) .option("--anthropic-api-key ", "Anthropic API key") + .option("--gemini-api-key ", "Gemini API key") .option("--gateway-port ", "Gateway port") .option("--gateway-bind ", "Gateway bind: loopback|lan|tailnet|auto") .option("--gateway-auth ", "Gateway auth: off|token|password") @@ -263,11 +264,13 @@ export function buildProgram() { | "openai-codex" | "codex-cli" | "antigravity" + | "gemini-api-key" | "apiKey" | "minimax" | "skip" | undefined, anthropicApiKey: opts.anthropicApiKey as string | undefined, + geminiApiKey: opts.geminiApiKey as string | undefined, gatewayPort: typeof opts.gatewayPort === "string" ? Number.parseInt(opts.gatewayPort, 10) diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 0355eb2c7..6c161ad51 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -85,6 +85,7 @@ export function buildAuthChoiceOptions(params: { value: "antigravity", label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)", }); + options.push({ value: "gemini-api-key", label: "Google Gemini API key" }); options.push({ value: "apiKey", label: "Anthropic API key" }); options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" }); if (params.includeSkip) { diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 36c4d0fe8..42eba1ad3 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -25,11 +25,16 @@ import { isRemoteEnvironment, loginAntigravityVpsAware, } from "./antigravity-oauth.js"; +import { + applyGoogleGeminiModelDefault, + GOOGLE_GEMINI_DEFAULT_MODEL, +} from "./google-gemini-model-default.js"; import { applyAuthProfileConfig, applyMinimaxConfig, applyMinimaxProviderConfig, setAnthropicApiKey, + setGeminiApiKey, writeOAuthCredentials, } from "./onboard-auth.js"; import { openUrl } from "./onboard-helpers.js"; @@ -415,6 +420,30 @@ export async function applyAuthChoice(params: { "OAuth help", ); } + } else if (params.authChoice === "gemini-api-key") { + const key = await params.prompter.text({ + message: "Enter Gemini API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + await setGeminiApiKey(String(key).trim(), params.agentDir); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "google:default", + provider: "google", + mode: "api_key", + }); + if (params.setDefaultModel) { + const applied = applyGoogleGeminiModelDefault(nextConfig); + nextConfig = applied.next; + if (applied.changed) { + await params.prompter.note( + `Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`, + "Model configured", + ); + } + } else { + agentModelOverride = GOOGLE_GEMINI_DEFAULT_MODEL; + await noteAgentModel(GOOGLE_GEMINI_DEFAULT_MODEL); + } } else if (params.authChoice === "apiKey") { const key = await params.prompter.text({ message: "Enter Anthropic API key", diff --git a/src/commands/configure.ts b/src/commands/configure.ts index eab3a7934..08bf0598a 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -50,11 +50,16 @@ import { GATEWAY_DAEMON_RUNTIME_OPTIONS, type GatewayDaemonRuntime, } from "./daemon-runtime.js"; +import { + applyGoogleGeminiModelDefault, + GOOGLE_GEMINI_DEFAULT_MODEL, +} from "./google-gemini-model-default.js"; import { healthCommand } from "./health.js"; import { applyAuthProfileConfig, applyMinimaxConfig, setAnthropicApiKey, + setGeminiApiKey, writeOAuthCredentials, } from "./onboard-auth.js"; import { @@ -300,6 +305,7 @@ async function promptAuthConfig( | "openai-codex" | "codex-cli" | "antigravity" + | "gemini-api-key" | "apiKey" | "minimax" | "skip"; @@ -513,6 +519,28 @@ async function promptAuthConfig( runtime.error(String(err)); note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth"); } + } else if (authChoice === "gemini-api-key") { + const key = guardCancel( + await text({ + message: "Enter Gemini API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ); + await setGeminiApiKey(String(key).trim()); + next = applyAuthProfileConfig(next, { + profileId: "google:default", + provider: "google", + mode: "api_key", + }); + const applied = applyGoogleGeminiModelDefault(next); + next = applied.next; + if (applied.changed) { + note( + `Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`, + "Model configured", + ); + } } else if (authChoice === "apiKey") { const key = guardCancel( await text({ diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index 4b6b662bc..d02cb4974 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -1,8 +1,16 @@ import { note } from "@clack/prompts"; import { + buildAuthHealthSummary, + DEFAULT_OAUTH_WARN_MS, + formatRemainingShort, +} from "../agents/auth-health.js"; +import { + CLAUDE_CLI_PROFILE_ID, + CODEX_CLI_PROFILE_ID, ensureAuthProfileStore, repairOAuthProfileIdMismatch, + resolveApiKeyForProfile, } from "../agents/auth-profiles.js"; import type { ClawdbotConfig } from "../config/config.js"; import type { DoctorPrompter } from "./doctor-prompter.js"; @@ -28,3 +36,114 @@ export async function maybeRepairAnthropicOAuthProfileId( if (!apply) return cfg; return repair.config; } + +type AuthIssue = { + profileId: string; + provider: string; + status: string; + remainingMs?: number; +}; + +function formatAuthIssueHint(issue: AuthIssue): string | null { + if ( + issue.provider === "anthropic" && + issue.profileId === CLAUDE_CLI_PROFILE_ID + ) { + return "Run `claude setup-token` on the gateway host."; + } + if ( + issue.provider === "openai-codex" && + issue.profileId === CODEX_CLI_PROFILE_ID + ) { + return "Run `codex login` (or `clawdbot configure` → OpenAI Codex OAuth)."; + } + return "Re-auth via `clawdbot configure` or `clawdbot onboard`."; +} + +function formatAuthIssueLine(issue: AuthIssue): string { + const remaining = + issue.remainingMs !== undefined + ? ` (${formatRemainingShort(issue.remainingMs)})` + : ""; + const hint = formatAuthIssueHint(issue); + return `- ${issue.profileId}: ${issue.status}${remaining}${hint ? ` — ${hint}` : ""}`; +} + +export async function noteAuthProfileHealth(params: { + cfg: ClawdbotConfig; + prompter: DoctorPrompter; + allowKeychainPrompt: boolean; +}): Promise { + const store = ensureAuthProfileStore(undefined, { + allowKeychainPrompt: params.allowKeychainPrompt, + }); + let summary = buildAuthHealthSummary({ + store, + cfg: params.cfg, + warnAfterMs: DEFAULT_OAUTH_WARN_MS, + }); + + const findIssues = () => + summary.profiles.filter( + (profile) => + profile.type === "oauth" && + (profile.status === "expired" || + profile.status === "expiring" || + profile.status === "missing"), + ); + + let issues = findIssues(); + if (issues.length === 0) return; + + const shouldRefresh = await params.prompter.confirmRepair({ + message: "Refresh expiring OAuth tokens now?", + initialValue: true, + }); + + if (shouldRefresh) { + const refreshTargets = issues.filter((issue) => + ["expired", "expiring", "missing"].includes(issue.status), + ); + const errors: string[] = []; + for (const profile of refreshTargets) { + try { + await resolveApiKeyForProfile({ + cfg: params.cfg, + store, + profileId: profile.profileId, + }); + } catch (err) { + errors.push( + `- ${profile.profileId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + if (errors.length > 0) { + note(errors.join("\n"), "OAuth refresh errors"); + } + summary = buildAuthHealthSummary({ + store: ensureAuthProfileStore(undefined, { + allowKeychainPrompt: false, + }), + cfg: params.cfg, + warnAfterMs: DEFAULT_OAUTH_WARN_MS, + }); + issues = findIssues(); + } + + if (issues.length > 0) { + note( + issues + .map((issue) => + formatAuthIssueLine({ + profileId: issue.profileId, + provider: issue.provider, + status: issue.status, + remainingMs: issue.remainingMs, + }), + ) + .join("\n"), + "Model auth", + ); + } +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 70514c279..2db1a1cbf 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -26,7 +26,10 @@ import { GATEWAY_DAEMON_RUNTIME_OPTIONS, type GatewayDaemonRuntime, } from "./daemon-runtime.js"; -import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js"; +import { + maybeRepairAnthropicOAuthProfileId, + noteAuthProfileHealth, +} from "./doctor-auth.js"; import { buildGatewayRuntimeHints, formatGatewayRuntimeSummary, @@ -124,6 +127,12 @@ export async function doctorCommand( } cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter); + await noteAuthProfileHealth({ + cfg, + prompter, + allowKeychainPrompt: + options.nonInteractive !== true && Boolean(process.stdin.isTTY), + }); const gatewayDetails = buildGatewayConnectionDetails({ config: cfg }); if (gatewayDetails.remoteFallbackNote) { note(gatewayDetails.remoteFallbackNote, "Gateway"); diff --git a/src/commands/google-gemini-model-default.test.ts b/src/commands/google-gemini-model-default.test.ts new file mode 100644 index 000000000..9dff42e8c --- /dev/null +++ b/src/commands/google-gemini-model-default.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; +import { + applyGoogleGeminiModelDefault, + GOOGLE_GEMINI_DEFAULT_MODEL, +} from "./google-gemini-model-default.js"; + +describe("applyGoogleGeminiModelDefault", () => { + it("sets gemini default when model is unset", () => { + const cfg: ClawdbotConfig = { agent: {} }; + const applied = applyGoogleGeminiModelDefault(cfg); + expect(applied.changed).toBe(true); + expect(applied.next.agent?.model).toEqual({ + primary: GOOGLE_GEMINI_DEFAULT_MODEL, + }); + }); + + it("overrides existing model", () => { + const cfg: ClawdbotConfig = { + agent: { model: "anthropic/claude-opus-4-5" }, + }; + const applied = applyGoogleGeminiModelDefault(cfg); + expect(applied.changed).toBe(true); + expect(applied.next.agent?.model).toEqual({ + primary: GOOGLE_GEMINI_DEFAULT_MODEL, + }); + }); + + it("no-ops when already gemini default", () => { + const cfg: ClawdbotConfig = { + agent: { model: GOOGLE_GEMINI_DEFAULT_MODEL }, + }; + const applied = applyGoogleGeminiModelDefault(cfg); + expect(applied.changed).toBe(false); + expect(applied.next).toEqual(cfg); + }); +}); diff --git a/src/commands/google-gemini-model-default.ts b/src/commands/google-gemini-model-default.ts new file mode 100644 index 000000000..6ae4917db --- /dev/null +++ b/src/commands/google-gemini-model-default.ts @@ -0,0 +1,38 @@ +import type { ClawdbotConfig } from "../config/config.js"; +import type { AgentModelListConfig } from "../config/types.js"; + +export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3-pro-preview"; + +function resolvePrimaryModel( + model?: AgentModelListConfig | string, +): string | undefined { + if (typeof model === "string") return model; + if (model && typeof model === "object" && typeof model.primary === "string") { + return model.primary; + } + return undefined; +} + +export function applyGoogleGeminiModelDefault(cfg: ClawdbotConfig): { + next: ClawdbotConfig; + changed: boolean; +} { + const current = resolvePrimaryModel(cfg.agent?.model)?.trim(); + if (current === GOOGLE_GEMINI_DEFAULT_MODEL) { + return { next: cfg, changed: false }; + } + + return { + next: { + ...cfg, + agent: { + ...cfg.agent, + model: + cfg.agent?.model && typeof cfg.agent.model === "object" + ? { ...cfg.agent.model, primary: GOOGLE_GEMINI_DEFAULT_MODEL } + : { primary: GOOGLE_GEMINI_DEFAULT_MODEL }, + }, + }, + changed: true, + }; +} diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index b4310fad1..eab6df45a 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -77,12 +77,17 @@ vi.mock("../../agents/agent-paths.js", () => ({ resolveClawdbotAgentDir: mocks.resolveClawdbotAgentDir, })); -vi.mock("../../agents/auth-profiles.js", () => ({ - ensureAuthProfileStore: mocks.ensureAuthProfileStore, - listProfilesForProvider: mocks.listProfilesForProvider, - resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel, - resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay, -})); +vi.mock("../../agents/auth-profiles.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + ensureAuthProfileStore: mocks.ensureAuthProfileStore, + listProfilesForProvider: mocks.listProfilesForProvider, + resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel, + resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay, + }; +}); vi.mock("../../agents/model-auth.js", () => ({ resolveEnvApiKey: mocks.resolveEnvApiKey, @@ -126,6 +131,9 @@ describe("modelsStatusCommand auth overview", () => { expect(payload.auth.shellEnvFallback.appliedKeys).toContain( "OPENAI_API_KEY", ); + expect(payload.auth.missingProvidersInUse).toEqual([]); + expect(payload.auth.oauth.warnAfterMs).toBeGreaterThan(0); + expect(payload.auth.oauth.profiles.length).toBeGreaterThan(0); const providers = payload.auth.providers as Array<{ provider: string; @@ -152,4 +160,27 @@ describe("modelsStatusCommand auth overview", () => { ), ).toBe(true); }); + + it("exits non-zero when auth is missing", async () => { + const originalProfiles = { ...mocks.store.profiles }; + mocks.store.profiles = {}; + const localRuntime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + const originalEnvImpl = mocks.resolveEnvApiKey.getMockImplementation(); + mocks.resolveEnvApiKey.mockImplementation(() => null); + + try { + await modelsStatusCommand( + { check: true, plain: true }, + localRuntime as never, + ); + expect(localRuntime.exit).toHaveBeenCalledWith(1); + } finally { + mocks.store.profiles = originalProfiles; + mocks.resolveEnvApiKey.mockImplementation(originalEnvImpl); + } + }); }); diff --git a/src/commands/models/list.ts b/src/commands/models/list.ts index a7ad9445d..5a3e178a3 100644 --- a/src/commands/models/list.ts +++ b/src/commands/models/list.ts @@ -7,6 +7,11 @@ import { } from "@mariozechner/pi-coding-agent"; import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; +import { + buildAuthHealthSummary, + DEFAULT_OAUTH_WARN_MS, + formatRemainingShort, +} from "../../agents/auth-health.js"; import { type AuthProfileStore, ensureAuthProfileStore, @@ -599,7 +604,7 @@ export async function modelsListCommand( } export async function modelsStatusCommand( - opts: { json?: boolean; plain?: boolean }, + opts: { json?: boolean; plain?: boolean; check?: boolean }, runtime: RuntimeEnv, ) { ensureFlagCompatibility(opts); @@ -656,6 +661,7 @@ export async function modelsStatusCommand( .filter(Boolean), ); const providersFromModels = new Set(); + const providersInUse = new Set(); for (const raw of [ defaultLabel, ...fallbacks, @@ -666,6 +672,15 @@ export async function modelsStatusCommand( const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); if (parsed?.provider) providersFromModels.add(parsed.provider); } + for (const raw of [ + defaultLabel, + ...fallbacks, + imageModel, + ...imageFallbacks, + ]) { + const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); + if (parsed?.provider) providersInUse.add(parsed.provider); + } const providersFromEnv = new Set(); // Keep in sync with resolveEnvApiKey() mappings (we want visibility even when @@ -715,6 +730,12 @@ export async function modelsStatusCommand( Boolean(entry.modelsJson); return hasAny; }); + const providerAuthMap = new Map( + providerAuth.map((entry) => [entry.provider, entry]), + ); + const missingProvidersInUse = Array.from(providersInUse) + .filter((provider) => !providerAuthMap.has(provider)) + .sort((a, b) => a.localeCompare(b)); const providersWithOauth = providerAuth .filter( @@ -726,6 +747,29 @@ export async function modelsStatusCommand( return `${entry.provider} (${count})`; }); + const authHealth = buildAuthHealthSummary({ + store, + cfg, + warnAfterMs: DEFAULT_OAUTH_WARN_MS, + providers, + }); + const oauthProfiles = authHealth.profiles.filter( + (profile) => profile.type === "oauth", + ); + + const checkStatus = (() => { + const hasExpiredOrMissing = + oauthProfiles.some((profile) => + ["expired", "missing"].includes(profile.status), + ) || missingProvidersInUse.length > 0; + const hasExpiring = oauthProfiles.some( + (profile) => profile.status === "expiring", + ); + if (hasExpiredOrMissing) return 1; + if (hasExpiring) return 2; + return 0; + })(); + if (opts.json) { runtime.log( JSON.stringify( @@ -746,18 +790,30 @@ export async function modelsStatusCommand( appliedKeys: applied, }, providersWithOAuth: providersWithOauth, + missingProvidersInUse, providers: providerAuth, + oauth: { + warnAfterMs: authHealth.warnAfterMs, + profiles: authHealth.profiles, + providers: authHealth.providers, + }, }, }, null, 2, ), ); + if (opts.check) { + runtime.exit(checkStatus); + } return; } if (opts.plain) { runtime.log(resolvedLabel); + if (opts.check) { + runtime.exit(checkStatus); + } return; } @@ -933,4 +989,48 @@ export async function modelsStatusCommand( } runtime.log(`- ${theme.heading(entry.provider)} ${bits.join(separator)}`); } + + if (missingProvidersInUse.length > 0) { + runtime.log(""); + runtime.log(colorize(rich, theme.heading, "Missing auth")); + for (const provider of missingProvidersInUse) { + const hint = + provider === "anthropic" + ? "Run `claude setup-token` or `clawdbot configure`." + : "Run `clawdbot configure` or set an API key env var."; + runtime.log(`- ${theme.heading(provider)} ${hint}`); + } + } + + runtime.log(""); + runtime.log(colorize(rich, theme.heading, "OAuth status")); + if (oauthProfiles.length === 0) { + runtime.log(colorize(rich, theme.muted, "- none")); + return; + } + + const formatStatus = (status: string) => { + if (status === "ok") return colorize(rich, theme.success, "ok"); + if (status === "expiring") return colorize(rich, theme.warn, "expiring"); + if (status === "missing") return colorize(rich, theme.warn, "unknown"); + return colorize(rich, theme.error, "expired"); + }; + + for (const profile of oauthProfiles) { + const labelText = profile.label || profile.profileId; + const label = colorize(rich, theme.accent, labelText); + const status = formatStatus(profile.status); + const expiry = profile.expiresAt + ? ` expires in ${formatRemainingShort(profile.remainingMs)}` + : " expires unknown"; + const source = + profile.source !== "store" + ? colorize(rich, theme.muted, ` (${profile.source})`) + : ""; + runtime.log(`- ${label} ${status}${expiry}${source}`); + } + + if (opts.check) { + runtime.exit(checkStatus); + } } diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index db51f4b84..a4d367d48 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -33,6 +33,19 @@ export async function setAnthropicApiKey(key: string, agentDir?: string) { }); } +export async function setGeminiApiKey(key: string, agentDir?: string) { + // Write to the multi-agent path so gateway finds credentials on startup + upsertAuthProfile({ + profileId: "google:default", + credential: { + type: "api_key", + provider: "google", + key, + }, + agentDir: agentDir ?? resolveDefaultAgentDir(), + }); +} + export function applyAuthProfileConfig( cfg: ClawdbotConfig, params: { diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 382317506..51bb6ee84 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -23,11 +23,13 @@ import { DEFAULT_GATEWAY_DAEMON_RUNTIME, isGatewayDaemonRuntime, } from "./daemon-runtime.js"; +import { applyGoogleGeminiModelDefault } from "./google-gemini-model-default.js"; import { healthCommand } from "./health.js"; import { applyAuthProfileConfig, applyMinimaxConfig, setAnthropicApiKey, + setGeminiApiKey, } from "./onboard-auth.js"; import { applyWizardMetadata, @@ -119,6 +121,20 @@ export async function runNonInteractiveOnboarding( provider: "anthropic", mode: "api_key", }); + } else if (authChoice === "gemini-api-key") { + const key = opts.geminiApiKey?.trim(); + if (!key) { + runtime.error("Missing --gemini-api-key"); + runtime.exit(1); + return; + } + await setGeminiApiKey(key); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId: "google:default", + provider: "google", + mode: "api_key", + }); + nextConfig = applyGoogleGeminiModelDefault(nextConfig).next; } else if (authChoice === "claude-cli") { const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false, diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 09feace3b..6ee3b5fc9 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -9,6 +9,7 @@ export type AuthChoice = | "codex-cli" | "antigravity" | "apiKey" + | "gemini-api-key" | "minimax" | "skip"; export type GatewayAuthChoice = "off" | "token" | "password"; @@ -24,6 +25,7 @@ export type OnboardOptions = { nonInteractive?: boolean; authChoice?: AuthChoice; anthropicApiKey?: string; + geminiApiKey?: string; gatewayPort?: number; gatewayBind?: GatewayBind; gatewayAuth?: GatewayAuthChoice; diff --git a/src/commands/providers/logs.ts b/src/commands/providers/logs.ts index a287c8c5d..642597248 100644 --- a/src/commands/providers/logs.ts +++ b/src/commands/providers/logs.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import { getResolvedLoggerSettings } from "../../logging.js"; +import { parseLogLine } from "../../logging/parse-log-line.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { theme } from "../../terminal/theme.js"; @@ -10,14 +11,7 @@ export type ProvidersLogsOptions = { json?: boolean; }; -type LogLine = { - time?: string; - level?: string; - subsystem?: string; - module?: string; - message: string; - raw: string; -}; +type LogLine = ReturnType; const DEFAULT_LIMIT = 200; const MAX_BYTES = 1_000_000; @@ -37,59 +31,7 @@ function parseProviderFilter(raw?: string) { return PROVIDERS.has(trimmed) ? trimmed : "all"; } -function extractMessage(value: Record): string { - const parts: string[] = []; - for (const key of Object.keys(value)) { - if (!/^\d+$/.test(key)) continue; - const item = value[key]; - if (typeof item === "string") { - parts.push(item); - } else if (item != null) { - parts.push(JSON.stringify(item)); - } - } - return parts.join(" "); -} - -function parseMetaName(raw?: unknown): { subsystem?: string; module?: string } { - if (typeof raw !== "string") return {}; - try { - const parsed = JSON.parse(raw) as Record; - return { - subsystem: - typeof parsed.subsystem === "string" ? parsed.subsystem : undefined, - module: typeof parsed.module === "string" ? parsed.module : undefined, - }; - } catch { - return {}; - } -} - -function parseLogLine(raw: string): LogLine | null { - try { - const parsed = JSON.parse(raw) as Record; - const meta = parsed._meta as Record | undefined; - const nameMeta = parseMetaName(meta?.name); - return { - time: - typeof parsed.time === "string" - ? parsed.time - : typeof meta?.date === "string" - ? meta.date - : undefined, - level: - typeof meta?.logLevelName === "string" ? meta.logLevelName : undefined, - subsystem: nameMeta.subsystem, - module: nameMeta.module, - message: extractMessage(parsed), - raw, - }; - } catch { - return null; - } -} - -function matchesProvider(line: LogLine, provider: string) { +function matchesProvider(line: NonNullable, provider: string) { if (provider === "all") return true; const needle = `gateway/providers/${provider}`; if (line.subsystem?.includes(needle)) return true; @@ -139,7 +81,7 @@ export async function providersLogsCommand( const rawLines = await readTailLines(file, limit * 4); const parsed = rawLines .map(parseLogLine) - .filter((line): line is LogLine => Boolean(line)); + .filter((line): line is NonNullable => Boolean(line)); const filtered = parsed.filter((line) => matchesProvider(line, provider)); const lines = filtered.slice(Math.max(0, filtered.length - limit)); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index f5fdc4829..3f50cd58a 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -154,6 +154,10 @@ function buildSystemdUnit({ `ExecStart=${execStart}`, "Restart=always", "RestartSec=5", + // KillMode=process ensures systemd only waits for the main process to exit. + // Without this, podman's conmon (container monitor) processes block shutdown + // since they run as children of the gateway and stay in the same cgroup. + "KillMode=process", workingDirLine, ...envLines, "", diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 224c63a70..829cb1014 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -785,6 +785,7 @@ export function createDiscordMessageHandler(params: { !hasAnyMention && commandAuthorized && hasControlCommand(baseText); + const effectiveWasMentioned = wasMentioned || shouldBypassMention; const canDetectMention = Boolean(botId) || mentionRegexes.length > 0; if (isGuildMessage && shouldRequireMention) { if (botId && !wasMentioned && !shouldBypassMention) { @@ -981,7 +982,7 @@ export function createDiscordMessageHandler(params: { : undefined, Provider: "discord" as const, Surface: "discord" as const, - WasMentioned: wasMentioned, + WasMentioned: effectiveWasMentioned, MessageSid: message.id, ParentSessionKey: threadKeys.parentSessionKey, ThreadStarterBody: threadStarterBody, diff --git a/src/imessage/monitor.ts b/src/imessage/monitor.ts index b8c0aef4a..44db2e1ba 100644 --- a/src/imessage/monitor.ts +++ b/src/imessage/monitor.ts @@ -326,6 +326,7 @@ export async function monitorIMessageProvider( !mentioned && commandAuthorized && hasControlCommand(messageText); + const effectiveWasMentioned = mentioned || shouldBypassMention; if ( isGroup && requireMention && @@ -387,7 +388,7 @@ export async function monitorIMessageProvider( MediaPath: mediaPath, MediaType: mediaType, MediaUrl: mediaPath, - WasMentioned: mentioned, + WasMentioned: effectiveWasMentioned, CommandAuthorized: commandAuthorized, // Originating channel for reply routing. OriginatingChannel: "imessage" as const, diff --git a/src/logging/parse-log-line.test.ts b/src/logging/parse-log-line.test.ts new file mode 100644 index 000000000..09da3a554 --- /dev/null +++ b/src/logging/parse-log-line.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import { parseLogLine } from "./parse-log-line.js"; + +describe("parseLogLine", () => { + it("parses structured JSON log lines", () => { + const line = JSON.stringify({ + time: "2026-01-09T01:38:41.523Z", + 0: '{"subsystem":"gateway/providers/whatsapp"}', + 1: "connected", + _meta: { + name: '{"subsystem":"gateway/providers/whatsapp"}', + logLevelName: "INFO", + }, + }); + + const parsed = parseLogLine(line); + + expect(parsed).not.toBeNull(); + expect(parsed?.time).toBe("2026-01-09T01:38:41.523Z"); + expect(parsed?.level).toBe("info"); + expect(parsed?.subsystem).toBe("gateway/providers/whatsapp"); + expect(parsed?.message).toBe("{\"subsystem\":\"gateway/providers/whatsapp\"} connected"); + expect(parsed?.raw).toBe(line); + }); + + it("falls back to meta timestamp when top-level time is missing", () => { + const line = JSON.stringify({ + 0: "hello", + _meta: { + name: "{\"subsystem\":\"gateway\"}", + logLevelName: "WARN", + date: "2026-01-09T02:10:00.000Z", + }, + }); + + const parsed = parseLogLine(line); + + expect(parsed?.time).toBe("2026-01-09T02:10:00.000Z"); + expect(parsed?.level).toBe("warn"); + }); + + it("returns null for invalid JSON", () => { + expect(parseLogLine("not-json")).toBeNull(); + }); +}); diff --git a/src/logging/parse-log-line.ts b/src/logging/parse-log-line.ts new file mode 100644 index 000000000..658d27213 --- /dev/null +++ b/src/logging/parse-log-line.ts @@ -0,0 +1,63 @@ +export type ParsedLogLine = { + time?: string; + level?: string; + subsystem?: string; + module?: string; + message: string; + raw: string; +}; + +function extractMessage(value: Record): string { + const parts: string[] = []; + for (const key of Object.keys(value)) { + if (!/^\d+$/.test(key)) continue; + const item = value[key]; + if (typeof item === "string") { + parts.push(item); + } else if (item != null) { + parts.push(JSON.stringify(item)); + } + } + return parts.join(" "); +} + +function parseMetaName( + raw?: unknown, +): { subsystem?: string; module?: string } { + if (typeof raw !== "string") return {}; + try { + const parsed = JSON.parse(raw) as Record; + return { + subsystem: + typeof parsed.subsystem === "string" ? parsed.subsystem : undefined, + module: typeof parsed.module === "string" ? parsed.module : undefined, + }; + } catch { + return {}; + } +} + +export function parseLogLine(raw: string): ParsedLogLine | null { + try { + const parsed = JSON.parse(raw) as Record; + const meta = parsed._meta as Record | undefined; + const nameMeta = parseMetaName(meta?.name); + const levelRaw = + typeof meta?.logLevelName === "string" ? meta.logLevelName : undefined; + return { + time: + typeof parsed.time === "string" + ? parsed.time + : typeof meta?.date === "string" + ? meta.date + : undefined, + level: levelRaw ? levelRaw.toLowerCase() : undefined, + subsystem: nameMeta.subsystem, + module: nameMeta.module, + message: extractMessage(parsed), + raw, + }; + } catch { + return null; + } +} diff --git a/src/providers/google-shared.test.ts b/src/providers/google-shared.test.ts index fef51a316..80d7f3889 100644 --- a/src/providers/google-shared.test.ts +++ b/src/providers/google-shared.test.ts @@ -27,7 +27,7 @@ const makeModel = (id: string): Model<"google-generative-ai"> => }) as Model<"google-generative-ai">; describe("google-shared convertTools", () => { - it("adds type:object when properties/required exist but type is missing", () => { + it("preserves parameters when type is missing", () => { const tools = [ { name: "noType", @@ -46,12 +46,12 @@ describe("google-shared convertTools", () => { converted?.[0]?.functionDeclarations?.[0]?.parameters, ); - expect(params.type).toBe("object"); + expect(params.type).toBeUndefined(); expect(params.properties).toBeDefined(); expect(params.required).toEqual(["action"]); }); - it("strips unsupported JSON Schema keywords", () => { + it("keeps unsupported JSON Schema keywords intact", () => { const tools = [ { name: "example", @@ -93,11 +93,11 @@ describe("google-shared convertTools", () => { const list = asRecord(properties.list); const items = asRecord(list.items); - expect(params).not.toHaveProperty("patternProperties"); - expect(params).not.toHaveProperty("additionalProperties"); - expect(mode).not.toHaveProperty("const"); - expect(options).not.toHaveProperty("anyOf"); - expect(items).not.toHaveProperty("const"); + expect(params).toHaveProperty("patternProperties"); + expect(params).toHaveProperty("additionalProperties"); + expect(mode).toHaveProperty("const"); + expect(options).toHaveProperty("anyOf"); + expect(items).toHaveProperty("const"); expect(params.required).toEqual(["mode"]); }); @@ -147,7 +147,7 @@ describe("google-shared convertTools", () => { }); describe("google-shared convertMessages", () => { - it("skips thinking blocks for Gemini to avoid mimicry", () => { + it("keeps thinking blocks when provider/model match", () => { const model = makeModel("gemini-1.5-pro"); const context = { messages: [ @@ -184,7 +184,13 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(0); + expect(contents).toHaveLength(1); + expect(contents[0].role).toBe("model"); + expect(contents[0].parts).toHaveLength(1); + expect(contents[0].parts?.[0]).toMatchObject({ + thought: true, + thoughtSignature: "sig", + }); }); it("keeps thought signatures for Claude models", () => { @@ -232,7 +238,7 @@ describe("google-shared convertMessages", () => { }); }); - it("merges consecutive user messages to satisfy Gemini role alternation", () => { + it("does not merge consecutive user messages for Gemini", () => { const model = makeModel("gemini-1.5-pro"); const context = { messages: [ @@ -248,12 +254,12 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(1); + expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); - expect(contents[0].parts).toHaveLength(2); + expect(contents[1].role).toBe("user"); }); - it("merges consecutive user messages for non-Gemini Google models", () => { + it("does not merge consecutive user messages for non-Gemini Google models", () => { const model = makeModel("claude-3-opus"); const context = { messages: [ @@ -269,12 +275,12 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(1); + expect(contents).toHaveLength(2); expect(contents[0].role).toBe("user"); - expect(contents[0].parts).toHaveLength(2); + expect(contents[1].role).toBe("user"); }); - it("merges consecutive model messages to satisfy Gemini role alternation", () => { + it("does not merge consecutive model messages for Gemini", () => { const model = makeModel("gemini-1.5-pro"); const context = { messages: [ @@ -332,10 +338,10 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(2); + expect(contents).toHaveLength(3); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); - expect(contents[1].parts).toHaveLength(2); + expect(contents[2].role).toBe("model"); }); it("handles user message after tool result without model response in between", () => { @@ -392,10 +398,11 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(3); + expect(contents).toHaveLength(4); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); expect(contents[2].role).toBe("user"); + expect(contents[3].role).toBe("user"); const toolResponsePart = contents[2].parts?.find( (part) => typeof part === "object" && part !== null && "functionResponse" in part, @@ -469,10 +476,11 @@ describe("google-shared convertMessages", () => { } as unknown as Context; const contents = convertMessages(model, context); - expect(contents).toHaveLength(2); + expect(contents).toHaveLength(3); expect(contents[0].role).toBe("user"); expect(contents[1].role).toBe("model"); - const toolCallPart = contents[1].parts?.find( + expect(contents[2].role).toBe("model"); + const toolCallPart = contents[2].parts?.find( (part) => typeof part === "object" && part !== null && "functionCall" in part, ); diff --git a/src/slack/monitor.tool-result.test.ts b/src/slack/monitor.tool-result.test.ts index 5c63f7468..551cb8ba8 100644 --- a/src/slack/monitor.tool-result.test.ts +++ b/src/slack/monitor.tool-result.test.ts @@ -250,6 +250,39 @@ describe("monitorSlackProvider tool results", () => { expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); }); + it("treats control commands as mentions for group bypass", async () => { + replyMock.mockResolvedValue({ text: "ok" }); + + const controller = new AbortController(); + const run = monitorSlackProvider({ + botToken: "bot-token", + appToken: "app-token", + abortSignal: controller.signal, + }); + + await waitForEvent("message"); + const handler = getSlackHandlers()?.get("message"); + if (!handler) throw new Error("Slack message handler not registered"); + + await handler({ + event: { + type: "message", + user: "U1", + text: "/elevated off", + ts: "123", + channel: "C1", + channel_type: "channel", + }, + }); + + await flush(); + controller.abort(); + await run; + + expect(replyMock).toHaveBeenCalledTimes(1); + expect(replyMock.mock.calls[0][0].WasMentioned).toBe(true); + }); + it("threads replies when incoming message is in a thread", async () => { replyMock.mockResolvedValue({ text: "thread reply" }); diff --git a/src/slack/monitor.ts b/src/slack/monitor.ts index 352370d3e..f97cd42df 100644 --- a/src/slack/monitor.ts +++ b/src/slack/monitor.ts @@ -913,6 +913,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { !hasAnyMention && commandAuthorized && hasControlCommand(message.text ?? ""); + const effectiveWasMentioned = wasMentioned || shouldBypassMention; const canDetectMention = Boolean(botUserId) || mentionRegexes.length > 0; if ( isRoom && @@ -1058,7 +1059,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { ThreadStarterBody: threadStarterBody, ThreadLabel: threadLabel, Timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined, - WasMentioned: isRoomish ? wasMentioned : undefined, + WasMentioned: isRoomish ? effectiveWasMentioned : undefined, MediaPath: media?.path, MediaType: media?.contentType, MediaUrl: media?.path, diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index f3563e3f3..80d99028d 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -486,6 +486,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { !hasAnyMention && commandAuthorized && hasControlCommand(msg.text ?? msg.caption ?? ""); + const effectiveWasMentioned = wasMentioned || shouldBypassMention; const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0; if (isGroup && requireMention && canDetectMention) { if (!wasMentioned && !shouldBypassMention) { @@ -592,7 +593,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { ReplyToBody: replyTarget?.body, ReplyToSender: replyTarget?.sender, Timestamp: msg.date ? msg.date * 1000 : undefined, - WasMentioned: isGroup ? wasMentioned : undefined, + WasMentioned: isGroup ? effectiveWasMentioned : undefined, MediaPath: allMedia[0]?.path, MediaType: allMedia[0]?.contentType, MediaUrl: allMedia[0]?.path, diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index cf85f0414..e2fe2a7f0 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -1132,6 +1132,45 @@ describe("web auto-reply", () => { expect(payload.Body).toContain("[from: Bob (+222)]"); }); + it("sets OriginatingTo to the sender for queued routing", async () => { + const sendMedia = vi.fn(); + const reply = vi.fn().mockResolvedValue(undefined); + const sendComposing = vi.fn(); + const resolver = vi.fn().mockResolvedValue({ text: "ok" }); + + let capturedOnMessage: + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) + | undefined; + const listenerFactory = async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + return { close: vi.fn() }; + }; + + await monitorWebProvider(false, listenerFactory, false, resolver); + expect(capturedOnMessage).toBeDefined(); + + await capturedOnMessage?.({ + body: "hello", + from: "+15551234567", + to: "+19998887777", + id: "m-originating", + sendComposing, + reply, + sendMedia, + }); + + expect(resolver).toHaveBeenCalledTimes(1); + const payload = resolver.mock.calls[0][0]; + expect(payload.OriginatingChannel).toBe("whatsapp"); + expect(payload.OriginatingTo).toBe("+15551234567"); + expect(payload.To).toBe("+19998887777"); + expect(payload.OriginatingTo).not.toBe(payload.To); + }); + it("uses per-agent mention patterns for group gating", async () => { const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 0ab2b7ddf..b7fca639a 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -1261,7 +1261,7 @@ export async function monitorWebProvider( Provider: "whatsapp", Surface: "whatsapp", OriginatingChannel: "whatsapp", - OriginatingTo: msg.to, + OriginatingTo: msg.from, }, cfg, dispatcher,