Compare commits

..
46 changed files with 225 additions and 2361 deletions

No files matched your search

+1 -11
View File
@@ -6,34 +6,25 @@
- Node bridge: harden keepalive + heartbeat handling (TCP keepalive, better disconnects, and keepalive config tests). (#577) — thanks @steipete - Node bridge: harden keepalive + heartbeat handling (TCP keepalive, better disconnects, and keepalive config tests). (#577) — thanks @steipete
- Control UI: improve mobile responsiveness. (#558) — thanks @carlulsoe - Control UI: improve mobile responsiveness. (#558) — thanks @carlulsoe
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott - CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
- Docs: add Hetzner Docker VPS guide. (#556) — thanks @Iamadig
- Docs: link Hetzner guide from install + platforms docs. (#592) — thanks @steipete
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc - Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
- Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro - Slack: honor reply tags + replyToMode while keeping threaded replies in-thread. (#574) — thanks @bolismauro
- Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete
- Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow
- Commands: accept /models as an alias for /model. - Commands: accept /models as an alias for /model.
- Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp - Commands: add `/usage` as an alias for `/status`. (#492) — thanks @lc0rp
- Models/Auth: add MiniMax Anthropic-compatible API onboarding (minimax-api). (#590) — thanks @mneves75 - Commands: add `/commands` list including text-only entries. (#497) — thanks @lc0rp
- Commands: harden slash command registry and list text-only commands in `/commands`.
- Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete - Models/Auth: show per-agent auth candidates in `/model status`, and add `clawdbot models auth order {get,set,clear}` (per-agent auth rotation overrides). — thanks @steipete
- Debugging: add raw model stream logging flags and document gateway watch mode. - Debugging: add raw model stream logging flags and document gateway watch mode.
- Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete - Gateway: decode dns-sd escaped UTF-8 in discovery output and show scan progress immediately. — thanks @steipete
- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled). - Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled).
- CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured. - CLI: move `clawdbot message` to subcommands (`message send|poll|…`), fold Discord/Slack/Telegram/WhatsApp tools into `message`, and require `--provider` unless only one provider is configured.
- CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging. - CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging.
- Hooks: default hook agent delivery to true. (#533) — thanks @mcinteerj
- Hooks: normalize hook agent providers (aliases + msteams support).
- WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj - 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 - 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 - Deps: bump Pi to 0.40.0 and drop pi-ai patch (upstream 429 fix). (#543) — thanks @mcinteerj
- Agent: skip empty error assistant messages when rebuilding session context to avoid tool-chain corruption. (#561) — thanks @mukhtharcm
- Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles. - 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. - 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. - Agent: enable adaptive context pruning by default for tool-result trimming.
- Doctor: check config/state permissions and offer to tighten them. — thanks @steipete - Doctor: check config/state permissions and offer to tighten them. — thanks @steipete
- Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete - Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete
- Doctor: repair gateway service entrypoint when switching between npm and git installs; add Docker e2e coverage. — thanks @steipete
- Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c - Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
- Daemon: add KillMode=process to systemd units to avoid podman restart hangs. (#541) — thanks @ogulcancelik - 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. - Doctor: run legacy state migrations in non-interactive mode without prompts.
@@ -124,7 +115,6 @@
- Gateway/CLI: allow dev profile (`clawdbot --dev`) to auto-create the dev config + workspace. — thanks @steipete - Gateway/CLI: allow dev profile (`clawdbot --dev`) to auto-create the dev config + workspace. — thanks @steipete
- Dev templates: ship C-3PO dev workspace defaults as docs templates and use them for dev bootstrap. — thanks @steipete - Dev templates: ship C-3PO dev workspace defaults as docs templates and use them for dev bootstrap. — thanks @steipete
- Config: fix Minimax hosted onboarding to write `agents.defaults` and allow `msteams` as a heartbeat target. — thanks @steipete - Config: fix Minimax hosted onboarding to write `agents.defaults` and allow `msteams` as a heartbeat target. — thanks @steipete
- Discord: add channel/category management actions (create/edit/move/delete + category removal). (#487) - thanks @NicholasSpisak
## 2026.1.8 ## 2026.1.8
@@ -331,8 +331,7 @@ actor MacNodeBridgeSession {
let now = self.clock.now let now = self.clock.now
if now > last.advanced(by: timeout) { if now > last.advanced(by: timeout) {
let age = last.duration(to: now) let age = last.duration(to: now)
self.logger.warning( self.logger.warning("Node bridge heartbeat timed out; disconnecting (age: \(String(describing: age), privacy: .public)).")
"Node bridge heartbeat timed out; disconnecting (age: \(String(describing: age), privacy: .public)).")
await self.disconnect() await self.disconnect()
return return
} }
@@ -342,8 +341,7 @@ actor MacNodeBridgeSession {
do { do {
try await self.send(BridgePing(type: "ping", id: id)) try await self.send(BridgePing(type: "ping", id: id))
} catch { } catch {
self.logger.warning( self.logger.warning("Node bridge ping send failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
"Node bridge ping send failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
await self.disconnect() await self.disconnect()
return return
} }
@@ -358,8 +356,7 @@ actor MacNodeBridgeSession {
private func handleConnectionState(_ state: NWConnection.State) async { private func handleConnectionState(_ state: NWConnection.State) async {
switch state { switch state {
case let .failed(error): case let .failed(error):
self.logger.warning( self.logger.warning("Node bridge connection failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
"Node bridge connection failed; disconnecting (error: \(String(describing: error), privacy: .public)).")
await self.disconnect() await self.disconnect()
case .cancelled: case .cancelled:
self.logger.warning("Node bridge connection cancelled; disconnecting.") self.logger.warning("Node bridge connection cancelled; disconnecting.")
+3 -3
View File
@@ -57,7 +57,7 @@ Payload:
"name": "Email", "name": "Email",
"sessionKey": "hook:email:msg-123", "sessionKey": "hook:email:msg-123",
"wakeMode": "now", "wakeMode": "now",
"deliver": true, "deliver": false,
"provider": "last", "provider": "last",
"to": "+15551234567", "to": "+15551234567",
"model": "openai/gpt-5.2-mini", "model": "openai/gpt-5.2-mini",
@@ -70,8 +70,8 @@ Payload:
- `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries. - `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries.
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context. - `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check. - `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped. - `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging provider. Defaults to `false`. Responses that are only heartbeat acknowledgments are automatically skipped.
- `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `msteams`. Defaults to `last`. - `provider` optional (string): The messaging service for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`. Defaults to `last`.
- `to` optional (string): The recipient identifier for the provider (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack). Defaults to the last recipient in the main session. - `to` optional (string): The recipient identifier for the provider (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack). Defaults to the last recipient in the main session.
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted. - `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`). - `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
+1 -5
View File
@@ -177,11 +177,7 @@ Options:
- `--workspace <dir>` - `--workspace <dir>`
- `--non-interactive` - `--non-interactive`
- `--mode <local|remote>` - `--mode <local|remote>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip>` - `--auth-choice <oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
- `--token-expires-in <duration>` (non-interactive; e.g. `365d`, `12h`)
- `--anthropic-api-key <key>` - `--anthropic-api-key <key>`
- `--openai-api-key <key>` - `--openai-api-key <key>`
- `--gemini-api-key <key>` - `--gemini-api-key <key>`
-1
View File
@@ -679,7 +679,6 @@
"platforms/android", "platforms/android",
"platforms/windows", "platforms/windows",
"platforms/linux", "platforms/linux",
"platforms/hetzner",
"platforms/exe-dev" "platforms/exe-dev"
] ]
}, },
+2 -17
View File
@@ -29,19 +29,6 @@ clawdbot models status
clawdbot doctor clawdbot doctor
``` ```
Alternative: run the wrapper (also updates Clawdbot config):
```bash
clawdbot models auth setup-token --provider anthropic
```
Manual token entry (any provider; writes `auth-profiles.json` + updates config):
```bash
clawdbot models auth paste-token --provider anthropic
clawdbot models auth paste-token --provider openrouter
```
## Recommended: long‑lived Claude Code token ## Recommended: long‑lived Claude Code token
Run this on the **gateway host** (the machine running the Gateway): Run this on the **gateway host** (the machine running the Gateway):
@@ -105,15 +92,13 @@ Use `--agent <id>` to target a specific agent; omit it to use the configured def
2. **Clawdbot** syncs those into 2. **Clawdbot** syncs those into
`~/.clawdbot/agents/<agentId>/agent/auth-profiles.json` when the auth store is `~/.clawdbot/agents/<agentId>/agent/auth-profiles.json` when the auth store is
loaded. loaded.
3. Refreshable OAuth profiles can be refreshed automatically on use. Static 3. OAuth refresh happens automatically on use if a token is expired.
token profiles (including Claude CLI setup-token) are not refreshable by
Clawdbot.
## Troubleshooting ## Troubleshooting
### “No credentials found” ### “No credentials found”
If the Anthropic token profile is missing, run `claude setup-token` on the If the Anthropic OAuth profile is missing, run `claude setup-token` on the
**gateway host**, then re-check: **gateway host**, then re-check:
```bash ```bash
-61
View File
@@ -1464,67 +1464,6 @@ Notes:
- Responses API enables clean reasoning/output separation; WhatsApp sees only final text. - Responses API enables clean reasoning/output separation; WhatsApp sees only final text.
- Adjust `contextWindow`/`maxTokens` if your LM Studio context length differs. - Adjust `contextWindow`/`maxTokens` if your LM Studio context length differs.
### MiniMax API (platform.minimax.io)
Use MiniMax's Anthropic-compatible API directly without LM Studio:
```json5
{
agent: {
model: { primary: "minimax/MiniMax-M2.1" },
models: {
"anthropic/claude-opus-4-5": { alias: "Opus" },
"minimax/MiniMax-M2.1": { alias: "Minimax" }
}
},
models: {
mode: "merge",
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
apiKey: "${MINIMAX_API_KEY}",
api: "anthropic-messages",
models: [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs.
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
contextWindow: 200000,
maxTokens: 8192
},
{
id: "MiniMax-M2.1-lightning",
name: "MiniMax M2.1 Lightning",
reasoning: false,
input: ["text"],
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
contextWindow: 200000,
maxTokens: 8192
},
{
id: "MiniMax-M2",
name: "MiniMax M2",
reasoning: true,
input: ["text"],
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
contextWindow: 200000,
maxTokens: 8192
}
]
}
}
}
}
```
Notes:
- Set `MINIMAX_API_KEY` environment variable or use `clawdbot onboard --auth-choice minimax-api`
- Available models: `MiniMax-M2.1` (default), `MiniMax-M2.1-lightning` (~100 tps), `MiniMax-M2` (reasoning)
- Pricing is a placeholder; MiniMax doesn't publish public rates. Override in `models.json` for accurate cost tracking.
Notes: Notes:
- Supported APIs: `openai-completions`, `openai-responses`, `anthropic-messages`, - Supported APIs: `openai-completions`, `openai-responses`, `anthropic-messages`,
`google-generative-ai` `google-generative-ai`
-2
View File
@@ -51,8 +51,6 @@ It writes config/workspace on the host:
- `~/.clawdbot/` - `~/.clawdbot/`
- `~/clawd` - `~/clawd`
Running on a VPS? See [Hetzner (Docker VPS)](/platforms/hetzner).
### Manual flow (compose) ### Manual flow (compose)
```bash ```bash
-296
View File
@@ -1,296 +0,0 @@
---
summary: "Run Clawdbot Gateway on Hetzner (Docker + VPS) with durable state and baked-in binaries"
read_when:
- You want a production-grade, always-on Gateway on your own VPS
- You want full control over persistence, binaries, and restart behavior
- You are running Clawdbot in Docker on Hetzner or a similar provider
---
# Clawdbot on Hetzner (Docker, Production VPS Guide)
## Goal
Run a persistent Clawdbot Gateway on a Hetzner VPS using Docker, with durable state, baked-in binaries, and safe restart behavior.
The Gateway can be accessed via:
- SSH port forwarding from your laptop
- Direct port exposure if you manage firewalling and tokens yourself
This guide assumes Ubuntu or Debian on Hetzner.
If you are on another Linux VPS, map packages accordingly.
For the generic Docker flow, see [Docker](/install/docker).
---
## Quick path (experienced operators)
1) Provision Hetzner VPS
2) Install Docker
3) Clone Clawdbot repository
4) Create persistent host directories
5) Configure `.env` and `docker-compose.yml`
6) Bake required binaries into the image
7) `docker compose up -d`
8) Verify persistence and Gateway access
---
## What you need
- Hetzner VPS with root access
- SSH access from your laptop
- Docker and Docker Compose
- Model auth credentials
- Optional provider credentials
- WhatsApp QR
- Telegram bot token
- Gmail OAuth
---
## 1) Provision the VPS
Create an Ubuntu or Debian VPS in Hetzner.
Connect as root:
```bash
ssh root@YOUR_VPS_IP
```
This guide assumes the VPS is stateful.
Do not treat it as disposable infrastructure.
---
## 2) Install Docker (on the VPS)
```bash
apt-get update
apt-get install -y git curl ca-certificates
curl -fsSL https://get.docker.com | sh
```
Verify:
```bash
docker --version
docker compose version
```
---
## 3) Clone the Clawdbot repository
```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
```
This guide assumes you will build a custom image to guarantee binary persistence.
---
## 4) Create persistent host directories
Docker containers are ephemeral.
All long-lived state must live on the host.
```bash
mkdir -p /root/.clawdbot
mkdir -p /root/clawd
# Set ownership to the container user (uid 1000):
chown -R 1000:1000 /root/.clawdbot
chown -R 1000:1000 /root/clawd
```
---
## 5) Configure environment variables
Create `.env` in the repository root.
```bash
CLAWDBOT_IMAGE=clawdbot:latest
CLAWDBOT_GATEWAY_TOKEN=change-me-now
CLAWDBOT_GATEWAY_BIND=lan
CLAWDBOT_GATEWAY_PORT=18789
CLAWDBOT_BRIDGE_PORT=18790
CLAWDBOT_CONFIG_DIR=/root/.clawdbot
CLAWDBOT_WORKSPACE_DIR=/root/clawd
GOG_KEYRING_PASSWORD=change-me-now
XDG_CONFIG_HOME=/home/node/.clawdbot
```
**Do not commit this file.**
---
## 6) Docker Compose configuration
Create or update `docker-compose.yml`.
```yaml
services:
clawdbot-gateway:
image: ${CLAWDBOT_IMAGE}
build: .
restart: unless-stopped
env_file:
- .env
environment:
- HOME=/home/node
- NODE_ENV=production
- TERM=xterm-256color
- CLAWDBOT_GATEWAY_BIND=${CLAWDBOT_GATEWAY_BIND}
- CLAWDBOT_GATEWAY_PORT=${CLAWDBOT_GATEWAY_PORT}
- CLAWDBOT_BRIDGE_PORT=${CLAWDBOT_BRIDGE_PORT}
- CLAWDBOT_GATEWAY_TOKEN=${CLAWDBOT_GATEWAY_TOKEN}
- GOG_KEYRING_PASSWORD=${GOG_KEYRING_PASSWORD}
- XDG_CONFIG_HOME=${XDG_CONFIG_HOME}
- PATH=/home/linuxbrew/.linuxbrew/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
volumes:
- ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
- ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
ports:
- "${CLAWDBOT_GATEWAY_PORT}:18789"
- "${CLAWDBOT_BRIDGE_PORT}:18790"
- "18793:18793"
command:
[
"node",
"dist/index.js",
"gateway-daemon",
"--bind",
"${CLAWDBOT_GATEWAY_BIND}",
"--port",
"${CLAWDBOT_GATEWAY_PORT}"
]
```
---
## 7) Bake required binaries into the image (critical)
Installing binaries inside a running container is a trap.
Anything installed at runtime will be lost on restart.
All external binaries required by skills must be installed at image build time.
The examples below show three common binaries only:
- `gog` for Gmail access
- `goplaces` for Google Places
- `wacli` for WhatsApp
These are examples, not a complete list.
You may install as many binaries as needed using the same pattern.
If you add new skills later that depend on additional binaries, you must:
1. Update the Dockerfile
2. Rebuild the image
3. Restart the containers
**Example Dockerfile**
```dockerfile
FROM node:22-bookworm
RUN apt-get update && apt-get install -y socat && rm -rf /var/lib/apt/lists/*
# Example binary 1: Gmail CLI
RUN curl -L https://github.com/steipete/gog/releases/latest/download/gog_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/gog
# Example binary 2: Google Places CLI
RUN curl -L https://github.com/steipete/goplaces/releases/latest/download/goplaces_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/goplaces
# Example binary 3: WhatsApp CLI
RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/wacli
# Add more binaries below using the same pattern
WORKDIR /app
COPY . .
RUN corepack enable
RUN pnpm install --frozen-lockfile
RUN pnpm build
RUN pnpm ui:install
RUN pnpm ui:build
CMD ["node","dist/index.js"]
```
---
## 8) Build and launch
```bash
docker compose build
docker compose up -d clawdbot-gateway
```
Verify binaries:
```bash
docker compose exec clawdbot-gateway which gog
docker compose exec clawdbot-gateway which goplaces
docker compose exec clawdbot-gateway which wacli
```
Expected output:
```
/usr/local/bin/gog
/usr/local/bin/goplaces
/usr/local/bin/wacli
```
---
## 9) Verify Gateway
```bash
docker compose logs -f clawdbot-gateway
```
Success:
```
[gateway] listening on ws://0.0.0.0:18789
```
From your laptop:
```bash
ssh -N -L 18789:127.0.0.1:18789 root@YOUR_VPS_IP
```
Open:
`http://127.0.0.1:18789/`
Paste your gateway token.
---
## What persists where (source of truth)
Clawdbot runs in Docker, but Docker is not the source of truth.
All long-lived state must survive restarts, rebuilds, and reboots.
| Component | Location | Persistence mechanism | Notes |
|---|---|---|---|
| Gateway config | `/home/node/.clawdbot/` | Host volume mount | Includes `clawdbot.json`, tokens |
| Model auth profiles | `/home/node/.clawdbot/` | Host volume mount | OAuth tokens, API keys |
| Skill configs | `/home/node/.clawdbot/skills/` | Host volume mount | Skill-level state |
| Agent workspace | `/home/node/clawd/` | Host volume mount | Code and agent artifacts |
| WhatsApp session | `/home/node/.clawdbot/` | Host volume mount | Preserves QR login |
| Gmail keyring | `/home/node/.clawdbot/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` |
| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time |
| Node runtime | Container filesystem | Docker image | Rebuilt every image build |
| OS packages | Container filesystem | Docker image | Do not install at runtime |
| Docker container | Ephemeral | Restartable | Safe to destroy |
-5
View File
@@ -20,11 +20,6 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
- Windows: [Windows](/platforms/windows) - Windows: [Windows](/platforms/windows)
- Linux: [Linux](/platforms/linux) - Linux: [Linux](/platforms/linux)
## VPS & hosting
- Hetzner (Docker): [Hetzner](/platforms/hetzner)
- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
## Common links ## Common links
- Install guide: [Getting Started](/start/getting-started) - Install guide: [Getting Started](/start/getting-started)
-1
View File
@@ -36,7 +36,6 @@ Directives (`/think`, `/verbose`, `/reasoning`, `/elevated`) are parsed even whe
Text + native (when enabled): Text + native (when enabled):
- `/help` - `/help`
- `/commands` - `/commands`
- `/status`
- `/status` (show current status; includes a short usage line when available) - `/status` (show current status; includes a short usage line when available)
- `/usage` (alias: `/status`) - `/usage` (alias: `/status`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only) - `/debug show|set|unset|reset` (runtime overrides, owner-only)
+2 -6
View File
@@ -80,7 +80,6 @@
"test:e2e": "vitest run --config vitest.e2e.config.ts", "test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:live": "vitest run --config vitest.live.config.ts", "test:live": "vitest run --config vitest.live.config.ts",
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh", "test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
"test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh",
"protocol:gen": "tsx scripts/protocol-gen.ts", "protocol:gen": "tsx scripts/protocol-gen.ts",
"protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts", "protocol:gen:swift": "tsx scripts/protocol-gen-swift.ts",
"protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift", "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift",
@@ -94,7 +93,7 @@
}, },
"packageManager": "pnpm@10.23.0", "packageManager": "pnpm@10.23.0",
"dependencies": { "dependencies": {
"@buape/carbon": "0.0.0-beta-20260109165250", "@buape/carbon": "0.13.0",
"@clack/prompts": "^0.11.0", "@clack/prompts": "^0.11.0",
"@grammyjs/runner": "^2.0.3", "@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1", "@grammyjs/transformer-throttler": "^1.2.1",
@@ -170,9 +169,7 @@
"@sinclair/typebox": "0.34.47" "@sinclair/typebox": "0.34.47"
}, },
"patchedDependencies": { "patchedDependencies": {
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch", "@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch"
"@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch",
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch"
} }
}, },
"vitest": { "vitest": {
@@ -210,7 +207,6 @@
"patchedDependencies": { "patchedDependencies": {
"@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch", "@mariozechner/pi-agent-core": "patches/@mariozechner__pi-agent-core.patch",
"@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch", "@mariozechner/pi-coding-agent": "patches/@mariozechner__pi-coding-agent.patch",
"@mariozechner/pi-ai": "patches/@mariozechner__pi-ai.patch",
"qrcode-terminal": "patches/qrcode-terminal.patch", "qrcode-terminal": "patches/qrcode-terminal.patch",
"playwright-core@1.57.0": "patches/playwright-core@1.57.0.patch" "playwright-core@1.57.0": "patches/playwright-core@1.57.0.patch"
} }
-17
View File
@@ -1,17 +0,0 @@
diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js
index 0000000..1111111 100644
--- a/dist/providers/google-gemini-cli.js
+++ b/dist/providers/google-gemini-cli.js
@@ -248,6 +248,12 @@ async function* streamGeminiCli(model, context, credentials, options) {
break; // Success, exit retry loop
}
const errorText = await response.text();
+ // PATCH: Fail immediately on 429 for Antigravity to let caller rotate accounts.
+ // Antigravity rate limits can have very long retry delays (10+ minutes).
+ if (isAntigravity && 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
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
// Use server-provided delay or exponential backoff
@@ -26,23 +26,3 @@ index 0000000..1111111 100644
} }
/** Result from createAgentSession */ /** Result from createAgentSession */
export interface CreateAgentSessionResult { export interface CreateAgentSessionResult {
diff --git a/dist/core/session-manager.js b/dist/core/session-manager.js
index b2aba5280d002253b0938b75aedbb9e6e6c4dcf8..67464efff535dbd7a8e6ed825aab2b305ca2aee2 100644
--- a/dist/core/session-manager.js
+++ b/dist/core/session-manager.js
@@ -161,6 +161,15 @@ export function buildSessionContext(entries, leafId, byId) {
const messages = [];
const appendMessage = (entry) => {
if (entry.type === "message") {
+ // PATCH: Filter out empty error assistant messages to prevent session corruption
+ // When 429/500 errors occur during tool execution, empty error messages get persisted
+ // to the session file. These break the tool_use -> tool_result chain for Claude/Gemini.
+ const msg = entry.message;
+ if (msg.role === "assistant" &&
+ msg.stopReason === "error" &&
+ (!msg.content || msg.content.length === 0)) {
+ return; // Skip empty error messages
+ }
messages.push(entry.message);
}
else if (entry.type === "custom_message") {
+61 -87
View File
@@ -11,20 +11,14 @@ patchedDependencies:
'@mariozechner/pi-agent-core': '@mariozechner/pi-agent-core':
hash: 01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4 hash: 01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4
path: patches/@mariozechner__pi-agent-core.patch path: patches/@mariozechner__pi-agent-core.patch
'@mariozechner/pi-ai':
hash: 3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c
path: patches/@mariozechner__pi-ai.patch
'@mariozechner/pi-coding-agent':
hash: 58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e
path: patches/@mariozechner__pi-coding-agent.patch
importers: importers:
.: .:
dependencies: dependencies:
'@buape/carbon': '@buape/carbon':
specifier: 0.0.0-beta-20260109165250 specifier: 0.13.0
version: 0.0.0-beta-20260109165250(hono@4.11.3) version: 0.13.0(@types/react@19.2.7)(hono@4.11.3)
'@clack/prompts': '@clack/prompts':
specifier: ^0.11.0 specifier: ^0.11.0
version: 0.11.0 version: 0.11.0
@@ -42,10 +36,10 @@ importers:
version: 0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5) version: 0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': '@mariozechner/pi-ai':
specifier: ^0.41.0 specifier: ^0.41.0
version: 0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5) version: 0.41.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-coding-agent': '@mariozechner/pi-coding-agent':
specifier: ^0.41.0 specifier: ^0.41.0
version: 0.41.0(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5) version: 0.41.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': '@mariozechner/pi-tui':
specifier: ^0.41.0 specifier: ^0.41.0
version: 0.41.0 version: 0.41.0
@@ -372,8 +366,8 @@ packages:
'@borewit/text-codec@0.2.1': '@borewit/text-codec@0.2.1':
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
'@buape/carbon@0.0.0-beta-20260109165250': '@buape/carbon@0.13.0':
resolution: {integrity: sha512-Xt8W0RYZxaprp2YxMU1rZRfGkHQWEJ2KZUurMwK/G4WVTfJSWAUeaeOGhpO40fCm/g0iPMuSLgQ4eudGOQHigw==} resolution: {integrity: sha512-N52sGIJj832IezL+JmekC4gE7cCORj8r8mCJ1vsHOZiyr3O2pvsUA930E1j+rjStkd67TLxURPRMrpyqAFveIg==}
'@cacheable/memory@2.0.7': '@cacheable/memory@2.0.7':
resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==} resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==}
@@ -391,12 +385,8 @@ packages:
'@clack/prompts@0.11.0': '@clack/prompts@0.11.0':
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
'@cloudflare/workers-types@4.20251205.0': '@cloudflare/workers-types@4.20250513.0':
resolution: {integrity: sha512-7pup7fYkuQW5XD8RUS/vkxF9SXlrGyCXuZ4ro3uVQvca/GTeSa+8bZ8T4wbq1Aea5lmLIGSlKbhl2msME7bRBA==} resolution: {integrity: sha512-TXaQyWLqhxEmi/DHx+VSaHZ4DHF/uJCPVv/hRyC7M/eWBo/I7mBtAkUEsrhqcKKO9oCeeRUHUHoeRLh5Gd96Gg==}
'@discordjs/voice@0.19.0':
resolution: {integrity: sha512-UyX6rGEXzVyPzb1yvjHtPfTlnLvB5jX/stAMdiytHhfoydX+98hfympdOwsnTktzr+IRvphxTbdErgYDJkEsvw==}
engines: {node: '>=22.12.0'}
'@emnapi/core@1.8.1': '@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -603,8 +593,8 @@ packages:
resolution: {integrity: sha512-qK6ZgGx0wwOubq/MY6eTbhApQHBUQCvCOsTYpQE01uLvfA2/Prm6egySHlZouKaina1RPuDwfLhCmsRCxwHj3Q==} resolution: {integrity: sha512-qK6ZgGx0wwOubq/MY6eTbhApQHBUQCvCOsTYpQE01uLvfA2/Prm6egySHlZouKaina1RPuDwfLhCmsRCxwHj3Q==}
hasBin: true hasBin: true
'@hono/node-server@1.19.6': '@hono/node-server@1.18.2':
resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==} resolution: {integrity: sha512-icgNvC0vRYivzyuSSaUv9ttcwtN8fDyd1k3AOIBDJgYd84tXRZSS6na8X54CY/oYoFTNhEmZraW/Rb9XYwX4KA==}
engines: {node: '>=18.14.1'} engines: {node: '>=18.14.1'}
peerDependencies: peerDependencies:
hono: ^4 hono: ^4
@@ -1290,8 +1280,8 @@ packages:
'@types/body-parser@1.19.6': '@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
'@types/bun@1.3.3': '@types/bun@1.2.23':
resolution: {integrity: sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==} resolution: {integrity: sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A==}
'@types/chai@5.2.3': '@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -1347,8 +1337,8 @@ packages:
'@types/node@10.17.60': '@types/node@10.17.60':
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==} resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
'@types/node@24.10.4': '@types/node@22.19.3':
resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==} resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==}
'@types/node@25.0.3': '@types/node@25.0.3':
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
@@ -1365,6 +1355,9 @@ packages:
'@types/range-parser@1.2.7': '@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/retry@0.12.0': '@types/retry@0.12.0':
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
@@ -1618,8 +1611,10 @@ packages:
buffer@6.0.3: buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
bun-types@1.3.3: bun-types@1.2.23:
resolution: {integrity: sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==} resolution: {integrity: sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw==}
peerDependencies:
'@types/react': ^19
bytes@3.1.2: bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
@@ -1731,6 +1726,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
curve25519-js@0.0.4: curve25519-js@0.0.4:
resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==} resolution: {integrity: sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==}
@@ -1770,8 +1768,8 @@ packages:
resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==} resolution: {integrity: sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==}
engines: {node: '>=0.3.1'} engines: {node: '>=0.3.1'}
discord-api-types@0.38.36: discord-api-types@0.38.29:
resolution: {integrity: sha512-qrbUbjjwtyeBg5HsAlm1C859epfOyiLjPqAOzkdWlCNsZCWJrertnETF/NwM8H+waMFU58xGSc5eXUfXah+WTQ==} resolution: {integrity: sha512-+5BfrjLJN1hrrcK0MxDQli6NSv5lQH7Y3/qaOfk9+k7itex8RkA/UcevVMMLe8B4IKIawr4ITBTb2fBB2vDORg==}
discord-api-types@0.38.37: discord-api-types@0.38.37:
resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==} resolution: {integrity: sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w==}
@@ -2674,23 +2672,6 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
prism-media@1.3.5:
resolution: {integrity: sha512-IQdl0Q01m4LrkN1EGIE9lphov5Hy7WWlH6ulf5QdGePLlPas9p2mhgddTEHrlaXYjjFToM1/rWuwF37VF4taaA==}
peerDependencies:
'@discordjs/opus': '>=0.8.0 <1.0.0'
ffmpeg-static: ^5.0.2 || ^4.2.7 || ^3.0.0 || ^2.4.0
node-opus: ^0.3.3
opusscript: ^0.0.8
peerDependenciesMeta:
'@discordjs/opus':
optional: true
ffmpeg-static:
optional: true
node-opus:
optional: true
opusscript:
optional: true
process-nextick-args@2.0.1: process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
@@ -3068,6 +3049,9 @@ packages:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'} engines: {node: '>=18'}
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@7.16.0: undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@@ -3374,24 +3358,20 @@ snapshots:
'@borewit/text-codec@0.2.1': {} '@borewit/text-codec@0.2.1': {}
'@buape/carbon@0.0.0-beta-20260109165250(hono@4.11.3)': '@buape/carbon@0.13.0(@types/react@19.2.7)(hono@4.11.3)':
dependencies: dependencies:
'@types/node': 24.10.4 '@types/node': 22.19.3
discord-api-types: 0.38.36 discord-api-types: 0.38.29
optionalDependencies: optionalDependencies:
'@cloudflare/workers-types': 4.20251205.0 '@cloudflare/workers-types': 4.20250513.0
'@discordjs/voice': 0.19.0 '@hono/node-server': 1.18.2(hono@4.11.3)
'@hono/node-server': 1.19.6(hono@4.11.3) '@types/bun': 1.2.23(@types/react@19.2.7)
'@types/bun': 1.3.3
'@types/ws': 8.18.1 '@types/ws': 8.18.1
ws: 8.18.3 ws: 8.18.3
transitivePeerDependencies: transitivePeerDependencies:
- '@discordjs/opus' - '@types/react'
- bufferutil - bufferutil
- ffmpeg-static
- hono - hono
- node-opus
- opusscript
- utf-8-validate - utf-8-validate
'@cacheable/memory@2.0.7': '@cacheable/memory@2.0.7':
@@ -3423,23 +3403,7 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
sisteransi: 1.0.5 sisteransi: 1.0.5
'@cloudflare/workers-types@4.20251205.0': '@cloudflare/workers-types@4.20250513.0':
optional: true
'@discordjs/voice@0.19.0':
dependencies:
'@types/ws': 8.18.1
discord-api-types: 0.38.37
prism-media: 1.3.5
tslib: 2.8.1
ws: 8.19.0
transitivePeerDependencies:
- '@discordjs/opus'
- bufferutil
- ffmpeg-static
- node-opus
- opusscript
- utf-8-validate
optional: true optional: true
'@emnapi/core@1.8.1': '@emnapi/core@1.8.1':
@@ -3577,7 +3541,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@hono/node-server@1.19.6(hono@4.11.3)': '@hono/node-server@1.18.2(hono@4.11.3)':
dependencies: dependencies:
hono: 4.11.3 hono: 4.11.3
optional: true optional: true
@@ -3783,7 +3747,7 @@ snapshots:
'@mariozechner/pi-agent-core@0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)': '@mariozechner/pi-agent-core@0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
dependencies: dependencies:
'@mariozechner/pi-ai': 0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-ai': 0.41.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.41.0 '@mariozechner/pi-tui': 0.41.0
transitivePeerDependencies: transitivePeerDependencies:
- '@modelcontextprotocol/sdk' - '@modelcontextprotocol/sdk'
@@ -3793,7 +3757,7 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-ai@0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5)': '@mariozechner/pi-ai@0.41.0(ws@8.19.0)(zod@4.3.5)':
dependencies: dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.5) '@anthropic-ai/sdk': 0.71.2(zod@4.3.5)
'@google/genai': 1.34.0 '@google/genai': 1.34.0
@@ -3813,11 +3777,11 @@ snapshots:
- ws - ws
- zod - zod
'@mariozechner/pi-coding-agent@0.41.0(patch_hash=58af7c712ebe270527c2ad9d3351fac39d6cd4b81cc475a258d87840b446b90e)(ws@8.19.0)(zod@4.3.5)': '@mariozechner/pi-coding-agent@0.41.0(ws@8.19.0)(zod@4.3.5)':
dependencies: dependencies:
'@mariozechner/clipboard': 0.3.0 '@mariozechner/clipboard': 0.3.0
'@mariozechner/pi-agent-core': 0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-agent-core': 0.41.0(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.41.0(patch_hash=3f4c1f943c57dbe2980bf21b1768dc780355f9124eeffbc30b5d5e42d2ea4b7c)(ws@8.19.0)(zod@4.3.5) '@mariozechner/pi-ai': 0.41.0(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.41.0 '@mariozechner/pi-tui': 0.41.0
chalk: 5.6.2 chalk: 5.6.2
cli-highlight: 2.1.11 cli-highlight: 2.1.11
@@ -4215,9 +4179,11 @@ snapshots:
'@types/connect': 3.4.38 '@types/connect': 3.4.38
'@types/node': 25.0.3 '@types/node': 25.0.3
'@types/bun@1.3.3': '@types/bun@1.2.23(@types/react@19.2.7)':
dependencies: dependencies:
bun-types: 1.3.3 bun-types: 1.2.23(@types/react@19.2.7)
transitivePeerDependencies:
- '@types/react'
optional: true optional: true
'@types/chai@5.2.3': '@types/chai@5.2.3':
@@ -4286,9 +4252,9 @@ snapshots:
'@types/node@10.17.60': {} '@types/node@10.17.60': {}
'@types/node@24.10.4': '@types/node@22.19.3':
dependencies: dependencies:
undici-types: 7.16.0 undici-types: 6.21.0
'@types/node@25.0.3': '@types/node@25.0.3':
dependencies: dependencies:
@@ -4304,6 +4270,11 @@ snapshots:
'@types/range-parser@1.2.7': {} '@types/range-parser@1.2.7': {}
'@types/react@19.2.7':
dependencies:
csstype: 3.2.3
optional: true
'@types/retry@0.12.0': {} '@types/retry@0.12.0': {}
'@types/retry@0.12.5': {} '@types/retry@0.12.5': {}
@@ -4637,9 +4608,10 @@ snapshots:
base64-js: 1.5.1 base64-js: 1.5.1
ieee754: 1.2.1 ieee754: 1.2.1
bun-types@1.3.3: bun-types@1.2.23(@types/react@19.2.7):
dependencies: dependencies:
'@types/node': 25.0.3 '@types/node': 25.0.3
'@types/react': 19.2.7
optional: true optional: true
bytes@3.1.2: {} bytes@3.1.2: {}
@@ -4757,6 +4729,9 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
csstype@3.2.3:
optional: true
curve25519-js@0.0.4: {} curve25519-js@0.0.4: {}
data-uri-to-buffer@4.0.1: {} data-uri-to-buffer@4.0.1: {}
@@ -4778,7 +4753,7 @@ snapshots:
diff@8.0.2: {} diff@8.0.2: {}
discord-api-types@0.38.36: {} discord-api-types@0.38.29: {}
discord-api-types@0.38.37: {} discord-api-types@0.38.37: {}
@@ -5705,9 +5680,6 @@ snapshots:
react-is: 17.0.2 react-is: 17.0.2
optional: true optional: true
prism-media@1.3.5:
optional: true
process-nextick-args@2.0.1: {} process-nextick-args@2.0.1: {}
process-warning@5.0.0: {} process-warning@5.0.0: {}
@@ -6182,6 +6154,8 @@ snapshots:
uint8array-extras@1.5.0: {} uint8array-extras@1.5.0: {}
undici-types@6.21.0: {}
undici-types@7.16.0: {} undici-types@7.16.0: {}
undici@7.18.2: {} undici@7.18.2: {}
-134
View File
@@ -1,134 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
IMAGE_NAME="clawdbot-doctor-install-switch-e2e"
echo "Building Docker image..."
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
echo "Running doctor install switch E2E..."
docker run --rm -t "$IMAGE_NAME" bash -lc '
set -euo pipefail
# Stub systemd/loginctl so doctor + daemon flows work in Docker.
export PATH="/tmp/clawdbot-bin:$PATH"
mkdir -p /tmp/clawdbot-bin
cat > /tmp/clawdbot-bin/systemctl <<"SYSTEMCTL"
#!/usr/bin/env bash
set -euo pipefail
args=("$@")
if [[ "${args[0]:-}" == "--user" ]]; then
args=("${args[@]:1}")
fi
cmd="${args[0]:-}"
case "$cmd" in
status)
exit 0
;;
is-enabled)
unit="${args[1]:-}"
unit_path="$HOME/.config/systemd/user/${unit}"
if [ -f "$unit_path" ]; then
exit 0
fi
exit 1
;;
show)
echo "ActiveState=inactive"
echo "SubState=dead"
echo "MainPID=0"
echo "ExecMainStatus=0"
echo "ExecMainCode=0"
exit 0
;;
*)
exit 0
;;
esac
SYSTEMCTL
chmod +x /tmp/clawdbot-bin/systemctl
cat > /tmp/clawdbot-bin/loginctl <<"LOGINCTL"
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == *"show-user"* ]]; then
echo "Linger=yes"
exit 0
fi
if [[ "$*" == *"enable-linger"* ]]; then
exit 0
fi
exit 0
LOGINCTL
chmod +x /tmp/clawdbot-bin/loginctl
# Install the npm-global variant from the local /app source.
npm install -g --prefix /tmp/npm-prefix /app
npm_bin="/tmp/npm-prefix/bin/clawdbot"
npm_entry="/tmp/npm-prefix/lib/node_modules/clawdbot/dist/entry.js"
git_entry="/app/dist/entry.js"
assert_entrypoint() {
local unit_path="$1"
local expected="$2"
local exec_line=""
exec_line=$(grep -m1 "^ExecStart=" "$unit_path" || true)
if [ -z "$exec_line" ]; then
echo "Missing ExecStart in $unit_path"
exit 1
fi
exec_line="${exec_line#ExecStart=}"
entrypoint=$(echo "$exec_line" | awk "{print \$2}")
if [ "$entrypoint" != "$expected" ]; then
echo "Expected entrypoint $expected, got $entrypoint"
exit 1
fi
}
# Each flow: install service with one variant, run doctor from the other,
# and verify ExecStart entrypoint switches accordingly.
run_flow() {
local name="$1"
local install_cmd="$2"
local install_expected="$3"
local doctor_cmd="$4"
local doctor_expected="$5"
echo "== Flow: $name =="
home_dir=$(mktemp -d "/tmp/clawdbot-switch-${name}.XXXXXX")
export HOME="$home_dir"
export USER="testuser"
eval "$install_cmd"
unit_path="$HOME/.config/systemd/user/clawdbot-gateway.service"
if [ ! -f "$unit_path" ]; then
echo "Missing unit file: $unit_path"
exit 1
fi
assert_entrypoint "$unit_path" "$install_expected"
eval "$doctor_cmd"
assert_entrypoint "$unit_path" "$doctor_expected"
}
run_flow \
"npm-to-git" \
"$npm_bin daemon install --force" \
"$npm_entry" \
"node $git_entry doctor --repair" \
"$git_entry"
run_flow \
"git-to-npm" \
"node $git_entry daemon install --force" \
"$git_entry" \
"$npm_bin doctor --repair" \
"$npm_entry"
'
+5 -6
View File
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? ""; const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
const MINIMAX_BASE_URL = const MINIMAX_BASE_URL =
process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic"; process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/v1";
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1"; const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1";
const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1"; const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1";
@@ -11,16 +11,15 @@ const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip;
describeLive("minimax live", () => { describeLive("minimax live", () => {
it("returns assistant text", async () => { it("returns assistant text", async () => {
const model: Model<"anthropic-messages"> = { const model: Model<"openai-completions"> = {
id: MINIMAX_MODEL, id: MINIMAX_MODEL,
name: `MiniMax ${MINIMAX_MODEL}`, name: `MiniMax ${MINIMAX_MODEL}`,
api: "anthropic-messages", api: "openai-completions",
provider: "minimax", provider: "minimax",
baseUrl: MINIMAX_BASE_URL, baseUrl: MINIMAX_BASE_URL,
reasoning: MINIMAX_MODEL === "MiniMax-M2", reasoning: false,
input: ["text"], input: ["text"],
// Pricing: placeholder values (per 1M tokens, multiplied by 1000 for display) cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
contextWindow: 200000, contextWindow: 200000,
maxTokens: 8192, maxTokens: 8192,
}; };
-165
View File
@@ -2,10 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js"; import type { DiscordActionConfig } from "../../config/config.js";
import { import {
addRoleDiscord, addRoleDiscord,
createChannelDiscord,
createScheduledEventDiscord, createScheduledEventDiscord,
deleteChannelDiscord,
editChannelDiscord,
fetchChannelInfoDiscord, fetchChannelInfoDiscord,
fetchMemberInfoDiscord, fetchMemberInfoDiscord,
fetchRoleInfoDiscord, fetchRoleInfoDiscord,
@@ -13,28 +10,17 @@ import {
listGuildChannelsDiscord, listGuildChannelsDiscord,
listGuildEmojisDiscord, listGuildEmojisDiscord,
listScheduledEventsDiscord, listScheduledEventsDiscord,
moveChannelDiscord,
removeChannelPermissionDiscord,
removeRoleDiscord, removeRoleDiscord,
setChannelPermissionDiscord,
uploadEmojiDiscord, uploadEmojiDiscord,
uploadStickerDiscord, uploadStickerDiscord,
} from "../../discord/send.js"; } from "../../discord/send.js";
import { import {
type ActionGate, type ActionGate,
jsonResult, jsonResult,
readNumberParam,
readStringArrayParam, readStringArrayParam,
readStringParam, readStringParam,
} from "./common.js"; } from "./common.js";
function readParentIdParam(
params: Record<string, unknown>,
): string | null | undefined {
if (params.parentId === null) return null;
return readStringParam(params, "parentId");
}
export async function handleDiscordGuildAction( export async function handleDiscordGuildAction(
action: string, action: string,
params: Record<string, unknown>, params: Record<string, unknown>,
@@ -221,157 +207,6 @@ export async function handleDiscordGuildAction(
const event = await createScheduledEventDiscord(guildId, payload); const event = await createScheduledEventDiscord(guildId, payload);
return jsonResult({ ok: true, event }); return jsonResult({ ok: true, event });
} }
case "channelCreate": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "name", { required: true });
const type = readNumberParam(params, "type", { integer: true });
const parentId = readParentIdParam(params);
const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true });
const nsfw = params.nsfw as boolean | undefined;
const channel = await createChannelDiscord({
guildId,
name,
type: type ?? undefined,
parentId: parentId ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
nsfw,
});
return jsonResult({ ok: true, channel });
}
case "channelEdit": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const name = readStringParam(params, "name");
const topic = readStringParam(params, "topic");
const position = readNumberParam(params, "position", { integer: true });
const parentId = readParentIdParam(params);
const nsfw = params.nsfw as boolean | undefined;
const rateLimitPerUser = readNumberParam(params, "rateLimitPerUser", {
integer: true,
});
const channel = await editChannelDiscord({
channelId,
name: name ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
parentId: parentId === undefined ? undefined : parentId,
nsfw,
rateLimitPerUser: rateLimitPerUser ?? undefined,
});
return jsonResult({ ok: true, channel });
}
case "channelDelete": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const result = await deleteChannelDiscord(channelId);
return jsonResult(result);
}
case "channelMove": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
const channelId = readStringParam(params, "channelId", {
required: true,
});
const parentId = readParentIdParam(params);
const position = readNumberParam(params, "position", { integer: true });
await moveChannelDiscord({
guildId,
channelId,
parentId: parentId === undefined ? undefined : parentId,
position: position ?? undefined,
});
return jsonResult({ ok: true });
}
case "categoryCreate": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const guildId = readStringParam(params, "guildId", { required: true });
const name = readStringParam(params, "name", { required: true });
const position = readNumberParam(params, "position", { integer: true });
const channel = await createChannelDiscord({
guildId,
name,
type: 4,
position: position ?? undefined,
});
return jsonResult({ ok: true, category: channel });
}
case "categoryEdit": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const categoryId = readStringParam(params, "categoryId", {
required: true,
});
const name = readStringParam(params, "name");
const position = readNumberParam(params, "position", { integer: true });
const channel = await editChannelDiscord({
channelId: categoryId,
name: name ?? undefined,
position: position ?? undefined,
});
return jsonResult({ ok: true, category: channel });
}
case "categoryDelete": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const categoryId = readStringParam(params, "categoryId", {
required: true,
});
const result = await deleteChannelDiscord(categoryId);
return jsonResult(result);
}
case "channelPermissionSet": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const targetId = readStringParam(params, "targetId", { required: true });
const targetTypeRaw = readStringParam(params, "targetType", {
required: true,
});
const targetType = targetTypeRaw === "member" ? 1 : 0;
const allow = readStringParam(params, "allow");
const deny = readStringParam(params, "deny");
await setChannelPermissionDiscord({
channelId,
targetId,
targetType,
allow: allow ?? undefined,
deny: deny ?? undefined,
});
return jsonResult({ ok: true });
}
case "channelPermissionRemove": {
if (!isActionEnabled("channels", false)) {
throw new Error("Discord channel management is disabled.");
}
const channelId = readStringParam(params, "channelId", {
required: true,
});
const targetId = readStringParam(params, "targetId", { required: true });
await removeChannelPermissionDiscord(channelId, targetId);
return jsonResult({ ok: true });
}
default: default:
throw new Error(`Unknown action: ${action}`); throw new Error(`Unknown action: ${action}`);
} }
-233
View File
@@ -1,58 +1,38 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { DiscordActionConfig } from "../../config/config.js"; import type { DiscordActionConfig } from "../../config/config.js";
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
const createChannelDiscord = vi.fn(async () => ({
id: "new-channel",
name: "test",
type: 0,
}));
const createThreadDiscord = vi.fn(async () => ({})); const createThreadDiscord = vi.fn(async () => ({}));
const deleteChannelDiscord = vi.fn(async () => ({ ok: true, channelId: "C1" }));
const deleteMessageDiscord = vi.fn(async () => ({})); const deleteMessageDiscord = vi.fn(async () => ({}));
const editChannelDiscord = vi.fn(async () => ({
id: "C1",
name: "edited",
}));
const editMessageDiscord = vi.fn(async () => ({})); const editMessageDiscord = vi.fn(async () => ({}));
const fetchChannelPermissionsDiscord = vi.fn(async () => ({})); const fetchChannelPermissionsDiscord = vi.fn(async () => ({}));
const fetchReactionsDiscord = vi.fn(async () => ({})); const fetchReactionsDiscord = vi.fn(async () => ({}));
const listPinsDiscord = vi.fn(async () => ({})); const listPinsDiscord = vi.fn(async () => ({}));
const listThreadsDiscord = vi.fn(async () => ({})); const listThreadsDiscord = vi.fn(async () => ({}));
const moveChannelDiscord = vi.fn(async () => ({ ok: true }));
const pinMessageDiscord = vi.fn(async () => ({})); const pinMessageDiscord = vi.fn(async () => ({}));
const reactMessageDiscord = vi.fn(async () => ({})); const reactMessageDiscord = vi.fn(async () => ({}));
const readMessagesDiscord = vi.fn(async () => []); const readMessagesDiscord = vi.fn(async () => []);
const removeChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
const removeOwnReactionsDiscord = vi.fn(async () => ({ removed: ["👍"] })); const removeOwnReactionsDiscord = vi.fn(async () => ({ removed: ["👍"] }));
const removeReactionDiscord = vi.fn(async () => ({})); const removeReactionDiscord = vi.fn(async () => ({}));
const searchMessagesDiscord = vi.fn(async () => ({})); const searchMessagesDiscord = vi.fn(async () => ({}));
const sendMessageDiscord = vi.fn(async () => ({})); const sendMessageDiscord = vi.fn(async () => ({}));
const sendPollDiscord = vi.fn(async () => ({})); const sendPollDiscord = vi.fn(async () => ({}));
const sendStickerDiscord = vi.fn(async () => ({})); const sendStickerDiscord = vi.fn(async () => ({}));
const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true }));
const unpinMessageDiscord = vi.fn(async () => ({})); const unpinMessageDiscord = vi.fn(async () => ({}));
vi.mock("../../discord/send.js", () => ({ vi.mock("../../discord/send.js", () => ({
createChannelDiscord: (...args: unknown[]) => createChannelDiscord(...args),
createThreadDiscord: (...args: unknown[]) => createThreadDiscord(...args), createThreadDiscord: (...args: unknown[]) => createThreadDiscord(...args),
deleteChannelDiscord: (...args: unknown[]) => deleteChannelDiscord(...args),
deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args), deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args),
editChannelDiscord: (...args: unknown[]) => editChannelDiscord(...args),
editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args), editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args),
fetchChannelPermissionsDiscord: (...args: unknown[]) => fetchChannelPermissionsDiscord: (...args: unknown[]) =>
fetchChannelPermissionsDiscord(...args), fetchChannelPermissionsDiscord(...args),
fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args), fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args),
listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args), listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args),
listThreadsDiscord: (...args: unknown[]) => listThreadsDiscord(...args), listThreadsDiscord: (...args: unknown[]) => listThreadsDiscord(...args),
moveChannelDiscord: (...args: unknown[]) => moveChannelDiscord(...args),
pinMessageDiscord: (...args: unknown[]) => pinMessageDiscord(...args), pinMessageDiscord: (...args: unknown[]) => pinMessageDiscord(...args),
reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args), reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args),
readMessagesDiscord: (...args: unknown[]) => readMessagesDiscord(...args), readMessagesDiscord: (...args: unknown[]) => readMessagesDiscord(...args),
removeChannelPermissionDiscord: (...args: unknown[]) =>
removeChannelPermissionDiscord(...args),
removeOwnReactionsDiscord: (...args: unknown[]) => removeOwnReactionsDiscord: (...args: unknown[]) =>
removeOwnReactionsDiscord(...args), removeOwnReactionsDiscord(...args),
removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args), removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args),
@@ -60,8 +40,6 @@ vi.mock("../../discord/send.js", () => ({
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscord(...args), sendMessageDiscord: (...args: unknown[]) => sendMessageDiscord(...args),
sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args), sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args),
sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args), sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args),
setChannelPermissionDiscord: (...args: unknown[]) =>
setChannelPermissionDiscord(...args),
unpinMessageDiscord: (...args: unknown[]) => unpinMessageDiscord(...args), unpinMessageDiscord: (...args: unknown[]) => unpinMessageDiscord(...args),
})); }));
@@ -139,214 +117,3 @@ describe("handleDiscordMessagingAction", () => {
).rejects.toThrow(/Discord reactions are disabled/); ).rejects.toThrow(/Discord reactions are disabled/);
}); });
}); });
const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels";
const channelsDisabled = () => false;
describe("handleDiscordGuildAction - channel management", () => {
it("creates a channel", async () => {
const result = await handleDiscordGuildAction(
"channelCreate",
{
guildId: "G1",
name: "test-channel",
type: 0,
topic: "Test topic",
},
channelsEnabled,
);
expect(createChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
name: "test-channel",
type: 0,
parentId: undefined,
topic: "Test topic",
position: undefined,
nsfw: undefined,
});
expect(result.details).toMatchObject({ ok: true });
});
it("respects channel gating for channelCreate", async () => {
await expect(
handleDiscordGuildAction(
"channelCreate",
{ guildId: "G1", name: "test" },
channelsDisabled,
),
).rejects.toThrow(/Discord channel management is disabled/);
});
it("edits a channel", async () => {
await handleDiscordGuildAction(
"channelEdit",
{
channelId: "C1",
name: "new-name",
topic: "new topic",
},
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith({
channelId: "C1",
name: "new-name",
topic: "new topic",
position: undefined,
parentId: undefined,
nsfw: undefined,
rateLimitPerUser: undefined,
});
});
it("clears the channel parent when parentId is null", async () => {
await handleDiscordGuildAction(
"channelEdit",
{
channelId: "C1",
parentId: null,
},
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith({
channelId: "C1",
name: undefined,
topic: undefined,
position: undefined,
parentId: null,
nsfw: undefined,
rateLimitPerUser: undefined,
});
});
it("deletes a channel", async () => {
await handleDiscordGuildAction(
"channelDelete",
{ channelId: "C1" },
channelsEnabled,
);
expect(deleteChannelDiscord).toHaveBeenCalledWith("C1");
});
it("moves a channel", async () => {
await handleDiscordGuildAction(
"channelMove",
{
guildId: "G1",
channelId: "C1",
parentId: "P1",
position: 5,
},
channelsEnabled,
);
expect(moveChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
channelId: "C1",
parentId: "P1",
position: 5,
});
});
it("clears the channel parent on move when parentId is null", async () => {
await handleDiscordGuildAction(
"channelMove",
{
guildId: "G1",
channelId: "C1",
parentId: null,
},
channelsEnabled,
);
expect(moveChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
channelId: "C1",
parentId: null,
position: undefined,
});
});
it("creates a category with type=4", async () => {
await handleDiscordGuildAction(
"categoryCreate",
{ guildId: "G1", name: "My Category" },
channelsEnabled,
);
expect(createChannelDiscord).toHaveBeenCalledWith({
guildId: "G1",
name: "My Category",
type: 4,
position: undefined,
});
});
it("edits a category", async () => {
await handleDiscordGuildAction(
"categoryEdit",
{ categoryId: "CAT1", name: "Renamed Category" },
channelsEnabled,
);
expect(editChannelDiscord).toHaveBeenCalledWith({
channelId: "CAT1",
name: "Renamed Category",
position: undefined,
});
});
it("deletes a category", async () => {
await handleDiscordGuildAction(
"categoryDelete",
{ categoryId: "CAT1" },
channelsEnabled,
);
expect(deleteChannelDiscord).toHaveBeenCalledWith("CAT1");
});
it("sets channel permissions for role", async () => {
await handleDiscordGuildAction(
"channelPermissionSet",
{
channelId: "C1",
targetId: "R1",
targetType: "role",
allow: "1024",
deny: "2048",
},
channelsEnabled,
);
expect(setChannelPermissionDiscord).toHaveBeenCalledWith({
channelId: "C1",
targetId: "R1",
targetType: 0,
allow: "1024",
deny: "2048",
});
});
it("sets channel permissions for member", async () => {
await handleDiscordGuildAction(
"channelPermissionSet",
{
channelId: "C1",
targetId: "U1",
targetType: "member",
allow: "1024",
},
channelsEnabled,
);
expect(setChannelPermissionDiscord).toHaveBeenCalledWith({
channelId: "C1",
targetId: "U1",
targetType: 1,
allow: "1024",
deny: undefined,
});
});
it("removes channel permissions", async () => {
await handleDiscordGuildAction(
"channelPermissionRemove",
{ channelId: "C1", targetId: "R1" },
channelsEnabled,
);
expect(removeChannelPermissionDiscord).toHaveBeenCalledWith("C1", "R1");
});
});
-9
View File
@@ -37,15 +37,6 @@ const guildActions = new Set([
"voiceStatus", "voiceStatus",
"eventList", "eventList",
"eventCreate", "eventCreate",
"channelCreate",
"channelEdit",
"channelDelete",
"channelMove",
"categoryCreate",
"categoryEdit",
"categoryDelete",
"channelPermissionSet",
"channelPermissionRemove",
]); ]);
const moderationActions = new Set(["timeout", "kick", "ban"]); const moderationActions = new Set(["timeout", "kick", "ban"]);
-63
View File
@@ -202,67 +202,4 @@ export const DiscordToolSchema = Type.Union([
reason: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
deleteMessageDays: Type.Optional(Type.Number()), deleteMessageDays: Type.Optional(Type.Number()),
}), }),
// Channel management actions
Type.Object({
action: Type.Literal("channelCreate"),
guildId: Type.String(),
name: Type.String(),
type: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
nsfw: Type.Optional(Type.Boolean()),
}),
Type.Object({
action: Type.Literal("channelEdit"),
channelId: Type.String(),
name: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
nsfw: Type.Optional(Type.Boolean()),
rateLimitPerUser: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("channelDelete"),
channelId: Type.String(),
}),
Type.Object({
action: Type.Literal("channelMove"),
guildId: Type.String(),
channelId: Type.String(),
parentId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
position: Type.Optional(Type.Number()),
}),
// Category management actions (convenience aliases)
Type.Object({
action: Type.Literal("categoryCreate"),
guildId: Type.String(),
name: Type.String(),
position: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("categoryEdit"),
categoryId: Type.String(),
name: Type.Optional(Type.String()),
position: Type.Optional(Type.Number()),
}),
Type.Object({
action: Type.Literal("categoryDelete"),
categoryId: Type.String(),
}),
// Permission overwrite actions
Type.Object({
action: Type.Literal("channelPermissionSet"),
channelId: Type.String(),
targetId: Type.String(),
targetType: Type.Union([Type.Literal("role"), Type.Literal("member")]),
allow: Type.Optional(Type.String()),
deny: Type.Optional(Type.String()),
}),
Type.Object({
action: Type.Literal("channelPermissionRemove"),
channelId: Type.String(),
targetId: Type.String(),
}),
]); ]);
+4 -2
View File
@@ -43,11 +43,13 @@ describe("control command parsing", () => {
expect(hasControlCommand("/commands")).toBe(true); expect(hasControlCommand("/commands")).toBe(true);
expect(hasControlCommand("/commands:")).toBe(true); expect(hasControlCommand("/commands:")).toBe(true);
expect(hasControlCommand("commands")).toBe(false); expect(hasControlCommand("commands")).toBe(false);
expect(hasControlCommand("/status")).toBe(true);
expect(hasControlCommand("/status:")).toBe(true);
expect(hasControlCommand("status")).toBe(false);
expect(hasControlCommand("usage")).toBe(false);
expect(hasControlCommand("/compact")).toBe(true); expect(hasControlCommand("/compact")).toBe(true);
expect(hasControlCommand("/compact:")).toBe(true); expect(hasControlCommand("/compact:")).toBe(true);
expect(hasControlCommand("compact")).toBe(false); expect(hasControlCommand("compact")).toBe(false);
expect(hasControlCommand("status")).toBe(false);
expect(hasControlCommand("usage")).toBe(false);
for (const command of listChatCommands()) { for (const command of listChatCommands()) {
for (const alias of command.textAliases) { for (const alias of command.textAliases) {
+1 -1
View File
@@ -18,11 +18,11 @@ describe("commands registry", () => {
const specs = listNativeCommandSpecs(); const specs = listNativeCommandSpecs();
expect(specs.find((spec) => spec.name === "help")).toBeTruthy(); expect(specs.find((spec) => spec.name === "help")).toBeTruthy();
expect(specs.find((spec) => spec.name === "stop")).toBeTruthy(); expect(specs.find((spec) => spec.name === "stop")).toBeTruthy();
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
}); });
it("detects known text commands", () => { it("detects known text commands", () => {
const detection = getCommandDetection(); const detection = getCommandDetection();
expect(detection.exact.has("/help")).toBe(true);
expect(detection.exact.has("/commands")).toBe(true); expect(detection.exact.has("/commands")).toBe(true);
expect(detection.exact.has("/compact")).toBe(true); expect(detection.exact.has("/compact")).toBe(true);
for (const command of listChatCommands()) { for (const command of listChatCommands()) {
+12 -105
View File
@@ -17,11 +17,6 @@ export type NativeCommandSpec = {
acceptsArgs: boolean; acceptsArgs: boolean;
}; };
type TextAliasSpec = {
canonical: string;
acceptsArgs: boolean;
};
function defineChatCommand(command: { function defineChatCommand(command: {
key: string; key: string;
nativeName?: string; nativeName?: string;
@@ -31,11 +26,8 @@ function defineChatCommand(command: {
textAliases?: string[]; textAliases?: string[];
scope?: CommandScope; scope?: CommandScope;
}): ChatCommandDefinition { }): ChatCommandDefinition {
const aliases = ( const aliases =
command.textAliases ?? (command.textAlias ? [command.textAlias] : []) command.textAliases ?? (command.textAlias ? [command.textAlias] : []);
)
.map((alias) => alias.trim())
.filter(Boolean);
const scope = const scope =
command.scope ?? command.scope ??
(command.nativeName ? (aliases.length ? "both" : "native") : "text"); (command.nativeName ? (aliases.length ? "both" : "native") : "text");
@@ -58,64 +50,16 @@ function registerAlias(
if (!command) { if (!command) {
throw new Error(`registerAlias: unknown command key: ${key}`); throw new Error(`registerAlias: unknown command key: ${key}`);
} }
const existing = new Set( const existing = new Set(command.textAliases.map((alias) => alias.trim()));
command.textAliases.map((alias) => alias.trim().toLowerCase()),
);
for (const alias of aliases) { for (const alias of aliases) {
const trimmed = alias.trim(); const trimmed = alias.trim();
if (!trimmed) continue; if (!trimmed) continue;
const lowered = trimmed.toLowerCase(); if (existing.has(trimmed)) continue;
if (existing.has(lowered)) continue; existing.add(trimmed);
existing.add(lowered);
command.textAliases.push(trimmed); command.textAliases.push(trimmed);
} }
} }
function assertCommandRegistry(commands: ChatCommandDefinition[]): void {
const keys = new Set<string>();
const nativeNames = new Set<string>();
const textAliases = new Set<string>();
for (const command of commands) {
if (keys.has(command.key)) {
throw new Error(`Duplicate command key: ${command.key}`);
}
keys.add(command.key);
const nativeName = command.nativeName?.trim();
if (command.scope === "text") {
if (nativeName) {
throw new Error(`Text-only command has native name: ${command.key}`);
}
if (command.textAliases.length === 0) {
throw new Error(`Text-only command missing text alias: ${command.key}`);
}
} else if (!nativeName) {
throw new Error(`Native command missing native name: ${command.key}`);
} else {
const nativeKey = nativeName.toLowerCase();
if (nativeNames.has(nativeKey)) {
throw new Error(`Duplicate native command: ${nativeName}`);
}
nativeNames.add(nativeKey);
}
if (command.scope === "native" && command.textAliases.length > 0) {
throw new Error(`Native-only command has text aliases: ${command.key}`);
}
for (const alias of command.textAliases) {
if (!alias.startsWith("/")) {
throw new Error(`Command alias missing leading '/': ${alias}`);
}
const aliasKey = alias.toLowerCase();
if (textAliases.has(aliasKey)) {
throw new Error(`Duplicate command alias: ${alias}`);
}
textAliases.add(aliasKey);
}
}
}
export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => { export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
const commands: ChatCommandDefinition[] = [ const commands: ChatCommandDefinition[] = [
defineChatCommand({ defineChatCommand({
@@ -246,28 +190,11 @@ export const CHAT_COMMANDS: ChatCommandDefinition[] = (() => {
registerAlias(commands, "elevated", "/elev"); registerAlias(commands, "elevated", "/elev");
registerAlias(commands, "model", "/models"); registerAlias(commands, "model", "/models");
assertCommandRegistry(commands);
return commands; return commands;
})(); })();
const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]); const NATIVE_COMMAND_SURFACES = new Set(["discord", "slack", "telegram"]);
const TEXT_ALIAS_MAP: Map<string, TextAliasSpec> = (() => {
const map = new Map<string, TextAliasSpec>();
for (const command of CHAT_COMMANDS) {
const canonical = `/${command.key}`;
const acceptsArgs = Boolean(command.acceptsArgs);
for (const alias of command.textAliases) {
const normalized = alias.trim().toLowerCase();
if (!normalized) continue;
if (!map.has(normalized)) {
map.set(normalized, { canonical, acceptsArgs });
}
}
}
return map;
})();
let cachedDetection: let cachedDetection:
| { | {
exact: Set<string>; exact: Set<string>;
@@ -299,8 +226,8 @@ export function findCommandByNativeName(
const normalized = name.trim().toLowerCase(); const normalized = name.trim().toLowerCase();
return CHAT_COMMANDS.find( return CHAT_COMMANDS.find(
(command) => (command) =>
command.scope !== "text" && command.nativeName?.toLowerCase() === normalized &&
command.nativeName?.toLowerCase() === normalized, command.scope !== "text",
); );
} }
@@ -312,31 +239,11 @@ export function buildCommandText(commandName: string, args?: string): string {
export function normalizeCommandBody(raw: string): string { export function normalizeCommandBody(raw: string): string {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed.startsWith("/")) return trimmed; if (!trimmed.startsWith("/")) return trimmed;
const match = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/);
const colonMatch = trimmed.match(/^\/([^\s:]+)\s*:(.*)$/); if (!match) return trimmed;
const normalized = colonMatch const [, command, rest] = match;
? (() => { const normalizedRest = rest.trimStart();
const [, command, rest] = colonMatch; return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
const normalizedRest = rest.trimStart();
return normalizedRest ? `/${command} ${normalizedRest}` : `/${command}`;
})()
: trimmed;
const lowered = normalized.toLowerCase();
const exact = TEXT_ALIAS_MAP.get(lowered);
if (exact) return exact.canonical;
const tokenMatch = normalized.match(/^\/([^\s]+)(?:\s+([\s\S]+))?$/);
if (!tokenMatch) return normalized;
const [, token, rest] = tokenMatch;
const tokenKey = `/${token.toLowerCase()}`;
const tokenSpec = TEXT_ALIAS_MAP.get(tokenKey);
if (!tokenSpec) return normalized;
if (rest && !tokenSpec.acceptsArgs) return normalized;
const normalizedRest = rest?.trimStart();
return normalizedRest
? `${tokenSpec.canonical} ${normalizedRest}`
: tokenSpec.canonical;
} }
export function getCommandDetection(): { exact: Set<string>; regex: RegExp } { export function getCommandDetection(): { exact: Set<string>; regex: RegExp } {
+1 -22
View File
@@ -240,23 +240,7 @@ export function buildProgram() {
.option("--mode <mode>", "Wizard mode: local|remote") .option("--mode <mode>", "Wizard mode: local|remote")
.option( .option(
"--auth-choice <choice>", "--auth-choice <choice>",
"Auth: setup-token|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip", "Auth: oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax-cloud|minimax|skip",
)
.option(
"--token-provider <id>",
"Token provider id (non-interactive; used with --auth-choice token)",
)
.option(
"--token <token>",
"Token value (non-interactive; used with --auth-choice token)",
)
.option(
"--token-profile-id <id>",
"Auth profile id (non-interactive; default: <provider>:manual)",
)
.option(
"--token-expires-in <duration>",
"Optional token expiry duration (e.g. 365d, 12h)",
) )
.option("--anthropic-api-key <key>", "Anthropic API key") .option("--anthropic-api-key <key>", "Anthropic API key")
.option("--openai-api-key <key>", "OpenAI API key") .option("--openai-api-key <key>", "OpenAI API key")
@@ -286,7 +270,6 @@ export function buildProgram() {
mode: opts.mode as "local" | "remote" | undefined, mode: opts.mode as "local" | "remote" | undefined,
authChoice: opts.authChoice as authChoice: opts.authChoice as
| "oauth" | "oauth"
| "setup-token"
| "claude-cli" | "claude-cli"
| "token" | "token"
| "openai-codex" | "openai-codex"
@@ -299,10 +282,6 @@ export function buildProgram() {
| "minimax" | "minimax"
| "skip" | "skip"
| undefined, | undefined,
tokenProvider: opts.tokenProvider as string | undefined,
token: opts.token as string | undefined,
tokenProfileId: opts.tokenProfileId as string | undefined,
tokenExpiresIn: opts.tokenExpiresIn as string | undefined,
anthropicApiKey: opts.anthropicApiKey as string | undefined, anthropicApiKey: opts.anthropicApiKey as string | undefined,
openaiApiKey: opts.openaiApiKey as string | undefined, openaiApiKey: opts.openaiApiKey as string | undefined,
geminiApiKey: opts.geminiApiKey as string | undefined, geminiApiKey: opts.geminiApiKey as string | undefined,
+2 -12
View File
@@ -64,23 +64,17 @@ export function buildAuthChoiceOptions(params: {
if (claudeCli?.type === "oauth" || claudeCli?.type === "token") { if (claudeCli?.type === "oauth" || claudeCli?.type === "token") {
options.push({ options.push({
value: "claude-cli", value: "claude-cli",
label: "Anthropic token (Claude CLI)", label: "Anthropic OAuth (Claude CLI)",
hint: formatOAuthHint(claudeCli.expires), hint: formatOAuthHint(claudeCli.expires),
}); });
} else if (params.includeClaudeCliIfMissing && platform === "darwin") { } else if (params.includeClaudeCliIfMissing && platform === "darwin") {
options.push({ options.push({
value: "claude-cli", value: "claude-cli",
label: "Anthropic token (Claude CLI)", label: "Anthropic OAuth (Claude CLI)",
hint: "requires Keychain access", hint: "requires Keychain access",
}); });
} }
options.push({
value: "setup-token",
label: "Anthropic token (run setup-token)",
hint: "Runs `claude setup-token`",
});
options.push({ options.push({
value: "token", value: "token",
label: "Anthropic token (paste setup-token)", label: "Anthropic token (paste setup-token)",
@@ -101,10 +95,6 @@ export function buildAuthChoiceOptions(params: {
// Token flow is currently Anthropic-only; use CLI for advanced providers. // Token flow is currently Anthropic-only; use CLI for advanced providers.
options.push({ value: "minimax-cloud", label: "MiniMax M2.1 (minimax.io)" }); options.push({ value: "minimax-cloud", label: "MiniMax M2.1 (minimax.io)" });
options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" }); options.push({ value: "minimax", label: "Minimax M2.1 (LM Studio)" });
options.push({
value: "minimax-api",
label: "MiniMax API (platform.minimax.io)",
});
if (params.includeSkip) { if (params.includeSkip) {
options.push({ value: "skip", label: "Skip for now" }); options.push({ value: "skip", label: "Skip for now" });
} }
+1 -82
View File
@@ -36,8 +36,6 @@ import {
} from "./google-gemini-model-default.js"; } from "./google-gemini-model-default.js";
import { import {
applyAuthProfileConfig, applyAuthProfileConfig,
applyMinimaxApiConfig,
applyMinimaxApiProviderConfig,
applyMinimaxConfig, applyMinimaxConfig,
applyMinimaxHostedConfig, applyMinimaxHostedConfig,
applyMinimaxHostedProviderConfig, applyMinimaxHostedProviderConfig,
@@ -218,68 +216,7 @@ export async function applyAuthChoice(params: {
provider: "anthropic", provider: "anthropic",
mode: "token", mode: "token",
}); });
} else if ( } else if (params.authChoice === "token" || params.authChoice === "oauth") {
params.authChoice === "setup-token" ||
params.authChoice === "oauth"
) {
await params.prompter.note(
[
"This will run `claude setup-token` to create a long-lived Anthropic token.",
"Requires an interactive TTY and a Claude Pro/Max subscription.",
].join("\n"),
"Anthropic setup-token",
);
if (!process.stdin.isTTY) {
await params.prompter.note(
"`claude setup-token` requires an interactive TTY.",
"Anthropic setup-token",
);
return { config: nextConfig, agentModelOverride };
}
const proceed = await params.prompter.confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
});
if (!proceed) return { config: nextConfig, agentModelOverride };
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
await params.prompter.note(
`Failed to run claude: ${String(res.error)}`,
"Anthropic setup-token",
);
return { config: nextConfig, agentModelOverride };
}
if (typeof res.status === "number" && res.status !== 0) {
await params.prompter.note(
`claude setup-token failed (exit ${res.status})`,
"Anthropic setup-token",
);
return { config: nextConfig, agentModelOverride };
}
const store = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: true,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
await params.prompter.note(
`No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`,
"Anthropic setup-token",
);
return { config: nextConfig, agentModelOverride };
}
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "token",
});
} else if (params.authChoice === "token") {
const provider = (await params.prompter.select({ const provider = (await params.prompter.select({
message: "Token provider", message: "Token provider",
options: [{ value: "anthropic", label: "Anthropic (only supported)" }], options: [{ value: "anthropic", label: "Anthropic (only supported)" }],
@@ -631,24 +568,6 @@ export async function applyAuthChoice(params: {
agentModelOverride = "lmstudio/minimax-m2.1-gs32"; agentModelOverride = "lmstudio/minimax-m2.1-gs32";
await noteAgentModel("lmstudio/minimax-m2.1-gs32"); await noteAgentModel("lmstudio/minimax-m2.1-gs32");
} }
} else if (params.authChoice === "minimax-api") {
const key = await params.prompter.text({
message: "Enter MiniMax API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
await setMinimaxApiKey(String(key).trim(), params.agentDir);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "minimax:default",
provider: "minimax",
mode: "api_key",
});
if (params.setDefaultModel) {
nextConfig = applyMinimaxApiConfig(nextConfig);
} else {
nextConfig = applyMinimaxApiProviderConfig(nextConfig);
agentModelOverride = "minimax/MiniMax-M2.1";
await noteAgentModel("minimax/MiniMax-M2.1");
}
} }
return { config: nextConfig, agentModelOverride }; return { config: nextConfig, agentModelOverride };
+1 -64
View File
@@ -352,7 +352,6 @@ async function promptAuthConfig(
runtime, runtime,
) as ) as
| "oauth" | "oauth"
| "setup-token"
| "claude-cli" | "claude-cli"
| "token" | "token"
| "openai-codex" | "openai-codex"
@@ -404,68 +403,7 @@ async function promptAuthConfig(
provider: "anthropic", provider: "anthropic",
mode: "token", mode: "token",
}); });
} else if (authChoice === "setup-token" || authChoice === "oauth") { } else if (authChoice === "token" || authChoice === "oauth") {
note(
[
"This will run `claude setup-token` to create a long-lived Anthropic token.",
"Requires an interactive TTY and a Claude Pro/Max subscription.",
].join("\n"),
"Anthropic setup-token",
);
if (!process.stdin.isTTY) {
note(
"`claude setup-token` requires an interactive TTY.",
"Anthropic setup-token",
);
return next;
}
const runNow = guardCancel(
await confirm({
message: "Run `claude setup-token` now?",
initialValue: true,
}),
runtime,
);
if (!runNow) return next;
const res = await (async () => {
const { spawnSync } = await import("node:child_process");
return spawnSync("claude", ["setup-token"], { stdio: "inherit" });
})();
if (res.error) {
note(
`Failed to run claude: ${String(res.error)}`,
"Anthropic setup-token",
);
return next;
}
if (typeof res.status === "number" && res.status !== 0) {
note(
`claude setup-token failed (exit ${res.status})`,
"Anthropic setup-token",
);
return next;
}
const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: true,
});
if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) {
note(
`No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`,
"Anthropic setup-token",
);
return next;
}
next = applyAuthProfileConfig(next, {
profileId: CLAUDE_CLI_PROFILE_ID,
provider: "anthropic",
mode: "token",
});
} else if (authChoice === "token") {
const provider = guardCancel( const provider = guardCancel(
await select({ await select({
message: "Token provider", message: "Token provider",
@@ -788,7 +726,6 @@ async function promptAuthConfig(
: (next.agents?.defaults?.model?.primary ?? ""); : (next.agents?.defaults?.model?.primary ?? "");
const preferAnthropic = const preferAnthropic =
authChoice === "claude-cli" || authChoice === "claude-cli" ||
authChoice === "setup-token" ||
authChoice === "token" || authChoice === "token" ||
authChoice === "oauth" || authChoice === "oauth" ||
authChoice === "apiKey"; authChoice === "apiKey";
+24 -51
View File
@@ -22,7 +22,6 @@ import { resolveGatewayService } from "../daemon/service.js";
import { import {
auditGatewayServiceConfig, auditGatewayServiceConfig,
needsNodeRuntimeMigration, needsNodeRuntimeMigration,
SERVICE_AUDIT_CODES,
} from "../daemon/service-audit.js"; } from "../daemon/service-audit.js";
import { buildServiceEnvironment } from "../daemon/service-env.js"; import { buildServiceEnvironment } from "../daemon/service-env.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
@@ -49,17 +48,6 @@ function detectGatewayRuntime(
return DEFAULT_GATEWAY_DAEMON_RUNTIME; return DEFAULT_GATEWAY_DAEMON_RUNTIME;
} }
function findGatewayEntrypoint(programArguments?: string[]): string | null {
if (!programArguments || programArguments.length === 0) return null;
const gatewayIndex = programArguments.indexOf("gateway");
if (gatewayIndex <= 0) return null;
return programArguments[gatewayIndex - 1] ?? null;
}
function normalizeExecutablePath(value: string): string {
return path.resolve(value);
}
export async function maybeMigrateLegacyGatewayService( export async function maybeMigrateLegacyGatewayService(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
mode: "local" | "remote", mode: "local" | "remote",
@@ -183,45 +171,6 @@ export async function maybeRepairGatewayServiceConfig(
env: process.env, env: process.env,
command, command,
}); });
const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues);
const systemNodePath = needsNodeRuntime
? await resolveSystemNodePath(process.env)
: null;
if (needsNodeRuntime && !systemNodePath) {
note(
"System Node 22+ not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
"Gateway runtime",
);
}
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const runtimeChoice = detectGatewayRuntime(command.programArguments);
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined,
});
const expectedEntrypoint = findGatewayEntrypoint(programArguments);
const currentEntrypoint = findGatewayEntrypoint(command.programArguments);
if (
expectedEntrypoint &&
currentEntrypoint &&
normalizeExecutablePath(expectedEntrypoint) !==
normalizeExecutablePath(currentEntrypoint)
) {
audit.issues.push({
code: SERVICE_AUDIT_CODES.gatewayEntrypointMismatch,
message: "Gateway service entrypoint does not match the current install.",
detail: `${currentEntrypoint} -> ${expectedEntrypoint}`,
level: "recommended",
});
}
if (audit.issues.length === 0) return; if (audit.issues.length === 0) return;
note( note(
@@ -258,6 +207,30 @@ export async function maybeRepairGatewayServiceConfig(
initialValue: true, initialValue: true,
}); });
if (!repair) return; if (!repair) return;
const needsNodeRuntime = needsNodeRuntimeMigration(audit.issues);
const systemNodePath = needsNodeRuntime
? await resolveSystemNodePath(process.env)
: null;
if (needsNodeRuntime && !systemNodePath) {
note(
"System Node 22+ not found. Install via Homebrew/apt/choco and rerun doctor to migrate off Bun/version managers.",
"Gateway runtime",
);
}
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const port = resolveGatewayPort(cfg, process.env);
const runtimeChoice = detectGatewayRuntime(command.programArguments);
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({
port,
dev: devMode,
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined,
});
const environment = buildServiceEnvironment({ const environment = buildServiceEnvironment({
env: process.env, env: process.env,
port, port,
-145
View File
@@ -7,8 +7,6 @@ import { afterEach, describe, expect, it } from "vitest";
import { import {
applyAuthProfileConfig, applyAuthProfileConfig,
applyMinimaxApiConfig,
applyMinimaxApiProviderConfig,
writeOAuthCredentials, writeOAuthCredentials,
} from "./onboard-auth.js"; } from "./onboard-auth.js";
@@ -107,146 +105,3 @@ describe("applyAuthProfileConfig", () => {
]); ]);
}); });
}); });
describe("applyMinimaxApiConfig", () => {
it("adds minimax provider with correct settings", () => {
const cfg = applyMinimaxApiConfig({});
expect(cfg.models?.providers?.minimax).toMatchObject({
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
});
});
it("sets correct primary model", () => {
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.1-lightning");
expect(cfg.agents?.defaults?.model?.primary).toBe(
"minimax/MiniMax-M2.1-lightning",
);
});
it("sets reasoning flag for MiniMax-M2 model", () => {
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2");
expect(cfg.models?.providers?.minimax?.models[0]?.reasoning).toBe(true);
});
it("does not set reasoning for non-M2 models", () => {
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.1");
expect(cfg.models?.providers?.minimax?.models[0]?.reasoning).toBe(false);
});
it("preserves existing model fallbacks", () => {
const cfg = applyMinimaxApiConfig({
agents: {
defaults: {
model: { fallbacks: ["anthropic/claude-opus-4-5"] },
},
},
});
expect(cfg.agents?.defaults?.model?.fallbacks).toEqual([
"anthropic/claude-opus-4-5",
]);
});
it("adds model alias", () => {
const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.1");
expect(cfg.agents?.defaults?.models?.["minimax/MiniMax-M2.1"]?.alias).toBe(
"Minimax",
);
});
it("preserves existing model params when adding alias", () => {
const cfg = applyMinimaxApiConfig(
{
agents: {
defaults: {
models: {
"minimax/MiniMax-M2.1": {
alias: "MiniMax",
params: { custom: "value" },
},
},
},
},
},
"MiniMax-M2.1",
);
expect(
cfg.agents?.defaults?.models?.["minimax/MiniMax-M2.1"],
).toMatchObject({ alias: "Minimax", params: { custom: "value" } });
});
it("replaces existing minimax provider entirely", () => {
const cfg = applyMinimaxApiConfig({
models: {
providers: {
minimax: {
baseUrl: "https://old.example.com",
apiKey: "old-key",
api: "openai-completions",
models: [
{
id: "old-model",
name: "Old",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
},
],
},
},
},
});
expect(cfg.models?.providers?.minimax?.baseUrl).toBe(
"https://api.minimax.io/anthropic",
);
expect(cfg.models?.providers?.minimax?.api).toBe("anthropic-messages");
expect(cfg.models?.providers?.minimax?.models[0]?.id).toBe("MiniMax-M2.1");
});
it("preserves other providers when adding minimax", () => {
const cfg = applyMinimaxApiConfig({
models: {
providers: {
anthropic: {
baseUrl: "https://api.anthropic.com",
apiKey: "anthropic-key",
api: "anthropic-messages",
models: [
{
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
reasoning: false,
input: ["text"],
cost: { input: 15, output: 75, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
},
],
},
},
},
});
expect(cfg.models?.providers?.anthropic).toBeDefined();
expect(cfg.models?.providers?.minimax).toBeDefined();
});
it("preserves existing models mode", () => {
const cfg = applyMinimaxApiConfig({
models: { mode: "replace", providers: {} },
});
expect(cfg.models?.mode).toBe("replace");
});
});
describe("applyMinimaxApiProviderConfig", () => {
it("does not overwrite existing primary model", () => {
const cfg = applyMinimaxApiProviderConfig({
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
});
expect(cfg.agents?.defaults?.model?.primary).toBe(
"anthropic/claude-opus-4-5",
);
});
});
-70
View File
@@ -263,73 +263,3 @@ export function applyMinimaxHostedConfig(
}, },
}; };
} }
// MiniMax Anthropic-compatible API (platform.minimax.io/anthropic)
export function applyMinimaxApiProviderConfig(
cfg: ClawdbotConfig,
modelId: string = "MiniMax-M2.1",
): ClawdbotConfig {
const providers = { ...cfg.models?.providers };
providers.minimax = {
baseUrl: "https://api.minimax.io/anthropic",
apiKey: "", // Resolved via MINIMAX_API_KEY env var or auth profile
api: "anthropic-messages",
models: [
{
id: modelId,
name: `MiniMax ${modelId}`,
reasoning: modelId === "MiniMax-M2",
input: ["text"],
// Pricing: MiniMax doesn't publish public rates. Override in models.json for accurate costs.
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
contextWindow: 200000,
maxTokens: 8192,
},
],
};
const models = { ...cfg.agents?.defaults?.models };
models[`minimax/${modelId}`] = {
...models[`minimax/${modelId}`],
alias: "Minimax",
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
models,
},
},
models: { mode: cfg.models?.mode ?? "merge", providers },
};
}
export function applyMinimaxApiConfig(
cfg: ClawdbotConfig,
modelId: string = "MiniMax-M2.1",
): ClawdbotConfig {
const next = applyMinimaxApiProviderConfig(cfg, modelId);
return {
...next,
agents: {
...next.agents,
defaults: {
...next.agents?.defaults,
model: {
...(next.agents?.defaults?.model &&
"fallbacks" in (next.agents.defaults.model as Record<string, unknown>)
? {
fallbacks: (
next.agents.defaults.model as { fallbacks?: string[] }
).fallbacks,
}
: undefined),
primary: `minimax/${modelId}`,
},
},
},
};
}
-16
View File
@@ -29,7 +29,6 @@ import { applyGoogleGeminiModelDefault } from "./google-gemini-model-default.js"
import { healthCommand } from "./health.js"; import { healthCommand } from "./health.js";
import { import {
applyAuthProfileConfig, applyAuthProfileConfig,
applyMinimaxApiConfig,
applyMinimaxConfig, applyMinimaxConfig,
applyMinimaxHostedConfig, applyMinimaxHostedConfig,
setAnthropicApiKey, setAnthropicApiKey,
@@ -174,21 +173,6 @@ export async function runNonInteractiveOnboarding(
mode: "api_key", mode: "api_key",
}); });
nextConfig = applyMinimaxHostedConfig(nextConfig); nextConfig = applyMinimaxHostedConfig(nextConfig);
} else if (authChoice === "minimax-api") {
const key =
opts.minimaxApiKey?.trim() || resolveEnvApiKey("minimax")?.apiKey;
if (!key) {
runtime.error("Missing --minimax-api-key (or MINIMAX_API_KEY in env).");
runtime.exit(1);
return;
}
await setMinimaxApiKey(key);
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "minimax:default",
provider: "minimax",
mode: "api_key",
});
nextConfig = applyMinimaxApiConfig(nextConfig);
} else if (authChoice === "claude-cli") { } else if (authChoice === "claude-cli") {
const store = ensureAuthProfileStore(undefined, { const store = ensureAuthProfileStore(undefined, {
allowKeychainPrompt: false, allowKeychainPrompt: false,
-11
View File
@@ -3,9 +3,7 @@ import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
export type OnboardMode = "local" | "remote"; export type OnboardMode = "local" | "remote";
export type AuthChoice = export type AuthChoice =
// Legacy alias for `setup-token` (kept for backwards CLI compatibility).
| "oauth" | "oauth"
| "setup-token"
| "claude-cli" | "claude-cli"
| "token" | "token"
| "openai-codex" | "openai-codex"
@@ -16,7 +14,6 @@ export type AuthChoice =
| "gemini-api-key" | "gemini-api-key"
| "minimax-cloud" | "minimax-cloud"
| "minimax" | "minimax"
| "minimax-api"
| "skip"; | "skip";
export type GatewayAuthChoice = "off" | "token" | "password"; export type GatewayAuthChoice = "off" | "token" | "password";
export type ResetScope = "config" | "config+creds+sessions" | "full"; export type ResetScope = "config" | "config+creds+sessions" | "full";
@@ -30,14 +27,6 @@ export type OnboardOptions = {
workspace?: string; workspace?: string;
nonInteractive?: boolean; nonInteractive?: boolean;
authChoice?: AuthChoice; authChoice?: AuthChoice;
/** Used when `authChoice=token` in non-interactive mode. */
tokenProvider?: string;
/** Used when `authChoice=token` in non-interactive mode. */
token?: string;
/** Used when `authChoice=token` in non-interactive mode. */
tokenProfileId?: string;
/** Used when `authChoice=token` in non-interactive mode. */
tokenExpiresIn?: string;
anthropicApiKey?: string; anthropicApiKey?: string;
openaiApiKey?: string; openaiApiKey?: string;
geminiApiKey?: string; geminiApiKey?: string;
+3 -8
View File
@@ -10,11 +10,6 @@ export async function onboardCommand(
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
) { ) {
assertSupportedRuntime(runtime); assertSupportedRuntime(runtime);
const authChoice =
opts.authChoice === "oauth" ? ("setup-token" as const) : opts.authChoice;
const normalizedOpts =
authChoice === opts.authChoice ? opts : { ...opts, authChoice };
if (process.platform === "win32") { if (process.platform === "win32") {
runtime.log( runtime.log(
[ [
@@ -25,12 +20,12 @@ export async function onboardCommand(
); );
} }
if (normalizedOpts.nonInteractive) { if (opts.nonInteractive) {
await runNonInteractiveOnboarding(normalizedOpts, runtime); await runNonInteractiveOnboarding(opts, runtime);
return; return;
} }
await runInteractiveOnboarding(normalizedOpts, runtime); await runInteractiveOnboarding(opts, runtime);
} }
export type { OnboardOptions } from "./onboard-types.js"; export type { OnboardOptions } from "./onboard-types.js";
-1
View File
@@ -404,7 +404,6 @@ export type DiscordActionConfig = {
moderation?: boolean; moderation?: boolean;
emojiUploads?: boolean; emojiUploads?: boolean;
stickerUploads?: boolean; stickerUploads?: boolean;
channels?: boolean;
}; };
export type DiscordAccountConfig = { export type DiscordAccountConfig = {
-1
View File
@@ -30,7 +30,6 @@ export type ServiceConfigAudit = {
export const SERVICE_AUDIT_CODES = { export const SERVICE_AUDIT_CODES = {
gatewayCommandMissing: "gateway-command-missing", gatewayCommandMissing: "gateway-command-missing",
gatewayEntrypointMismatch: "gateway-entrypoint-mismatch",
gatewayPathMissing: "gateway-path-missing", gatewayPathMissing: "gateway-path-missing",
gatewayPathMissingDirs: "gateway-path-missing-dirs", gatewayPathMissingDirs: "gateway-path-missing-dirs",
gatewayPathNonMinimal: "gateway-path-nonminimal", gatewayPathNonMinimal: "gateway-path-nonminimal",
-267
View File
@@ -1,6 +1,5 @@
import type { Client } from "@buape/carbon"; import type { Client } from "@buape/carbon";
import { ChannelType, MessageType } from "@buape/carbon"; import { ChannelType, MessageType } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMock = vi.fn(); const sendMock = vi.fn();
@@ -120,168 +119,6 @@ describe("discord tool result dispatch", () => {
expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /); expect(sendMock.mock.calls[0]?.[1]).toMatch(/^PFX /);
}, 10000); }, 10000);
it("caches channel info lookups between messages", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/clawd",
},
},
session: { store: "/tmp/clawdbot-sessions.json" },
discord: { dm: { enabled: true, policy: "open" } },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
});
const fetchChannel = vi.fn().mockResolvedValue({
type: ChannelType.DM,
name: "dm",
});
const client = { fetchChannel } as unknown as Client;
const baseMessage = {
content: "hello",
channelId: "cache-channel-1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u-cache", bot: false, username: "Ada" },
};
await handler(
{
message: { ...baseMessage, id: "m-cache-1" },
author: baseMessage.author,
guild_id: null,
},
client,
);
await handler(
{
message: { ...baseMessage, id: "m-cache-2" },
author: baseMessage.author,
guild_id: null,
},
client,
);
expect(fetchChannel).toHaveBeenCalledTimes(1);
});
it("uses channel id allowlists for non-thread channels with categories", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
let capturedCtx: { SessionKey?: string } | undefined;
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
capturedCtx = ctx;
dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { final: 1 } };
});
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: "/tmp/clawd",
},
},
session: { store: "/tmp/clawdbot-sessions.json" },
discord: {
dm: { enabled: true, policy: "open" },
guilds: {
"*": {
requireMention: false,
channels: { c1: { allow: true } },
},
},
},
routing: { allowFrom: [] },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
guildEntries: {
"*": { requireMention: false, channels: { c1: { allow: true } } },
},
});
const client = {
fetchChannel: vi.fn().mockResolvedValue({
type: ChannelType.GuildText,
name: "general",
parentId: "category-1",
}),
rest: { get: vi.fn() },
} as unknown as Client;
await handler(
{
message: {
id: "m-category",
content: "hello",
channelId: "c1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
},
author: { id: "u1", bot: false, username: "Ada", tag: "Ada#1" },
member: { displayName: "Ada" },
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
},
client,
);
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:c1");
});
it("replies with pairing code and sender id when dmPolicy is pairing", async () => { it("replies with pairing code and sender id when dmPolicy is pairing", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js"); const { createDiscordMessageHandler } = await import("./monitor.js");
const cfg = { const cfg = {
@@ -544,110 +381,6 @@ describe("discord tool result dispatch", () => {
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general"); expect(capturedCtx?.ThreadLabel).toContain("Discord thread #general");
}); });
it("treats forum threads as distinct sessions without channel payloads", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js");
let capturedCtx:
| {
SessionKey?: string;
ParentSessionKey?: string;
ThreadStarterBody?: string;
ThreadLabel?: string;
}
| undefined;
dispatchMock.mockImplementationOnce(async ({ ctx, dispatcher }) => {
capturedCtx = ctx;
dispatcher.sendFinalReply({ text: "hi" });
return { queuedFinal: true, counts: { final: 1 } };
});
const cfg = {
agent: { model: "anthropic/claude-opus-4-5", workspace: "/tmp/clawd" },
session: { store: "/tmp/clawdbot-sessions.json" },
discord: {
dm: { enabled: true, policy: "open" },
guilds: { "*": { requireMention: false } },
},
routing: { allowFrom: [] },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
const handler = createDiscordMessageHandler({
cfg,
discordConfig: cfg.discord,
accountId: "default",
token: "token",
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
},
botUserId: "bot-id",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 10_000,
textLimit: 2000,
replyToMode: "off",
dmEnabled: true,
groupDmEnabled: false,
guildEntries: { "*": { requireMention: false } },
});
const fetchChannel = vi
.fn()
.mockResolvedValueOnce({
type: ChannelType.PublicThread,
name: "topic-1",
parentId: "forum-1",
})
.mockResolvedValueOnce({
type: ChannelType.GuildForum,
name: "support",
});
const restGet = vi.fn().mockResolvedValue({
content: "starter message",
author: { id: "u1", username: "Alice", discriminator: "0001" },
timestamp: new Date().toISOString(),
});
const client = {
fetchChannel,
rest: {
get: restGet,
},
} as unknown as Client;
await handler(
{
message: {
id: "m6",
content: "thread reply",
channelId: "t1",
timestamp: new Date().toISOString(),
type: MessageType.Default,
attachments: [],
embeds: [],
mentionedEveryone: false,
mentionedUsers: [],
mentionedRoles: [],
author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" },
},
author: { id: "u2", bot: false, username: "Bob", tag: "Bob#2" },
member: { displayName: "Bob" },
guild: { id: "g1", name: "Guild" },
guild_id: "g1",
},
client,
);
expect(capturedCtx?.SessionKey).toBe("agent:main:discord:channel:t1");
expect(capturedCtx?.ParentSessionKey).toBe(
"agent:main:discord:channel:forum-1",
);
expect(capturedCtx?.ThreadStarterBody).toContain("starter message");
expect(capturedCtx?.ThreadLabel).toContain("Discord thread #support");
expect(restGet).toHaveBeenCalledWith(Routes.channelMessage("t1", "t1"));
});
it("scopes thread sessions to the routed agent", async () => { it("scopes thread sessions to the routed agent", async () => {
const { createDiscordMessageHandler } = await import("./monitor.js"); const { createDiscordMessageHandler } = await import("./monitor.js");
+16 -132
View File
@@ -113,20 +113,7 @@ type DiscordThreadStarter = {
timestamp?: number; timestamp?: number;
}; };
type DiscordChannelInfo = {
type: ChannelType;
name?: string;
topic?: string;
parentId?: string;
};
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>(); const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
const DISCORD_CHANNEL_INFO_CACHE_TTL_MS = 5 * 60 * 1000;
const DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS = 30 * 1000;
const DISCORD_CHANNEL_INFO_CACHE = new Map<
string,
{ value: DiscordChannelInfo | null; expiresAt: number }
>();
const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 1000; const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 1000;
function logSlowDiscordListener(params: { function logSlowDiscordListener(params: {
@@ -152,22 +139,14 @@ async function resolveDiscordThreadStarter(params: {
channel: DiscordThreadChannel; channel: DiscordThreadChannel;
client: Client; client: Client;
parentId?: string; parentId?: string;
parentType?: ChannelType;
}): Promise<DiscordThreadStarter | null> { }): Promise<DiscordThreadStarter | null> {
const cacheKey = params.channel.id; const cacheKey = params.channel.id;
const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey); const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
if (cached) return cached; if (cached) return cached;
try { try {
const parentType = params.parentType; if (!params.parentId) return null;
const isForumParent =
parentType === ChannelType.GuildForum ||
parentType === ChannelType.GuildMedia;
const messageChannelId = isForumParent
? params.channel.id
: params.parentId;
if (!messageChannelId) return null;
const starter = (await params.client.rest.get( const starter = (await params.client.rest.get(
Routes.channelMessage(messageChannelId, params.channel.id), Routes.channelMessage(params.parentId, params.channel.id),
)) as { )) as {
content?: string | null; content?: string | null;
embeds?: Array<{ description?: string | null }>; embeds?: Array<{ description?: string | null }>;
@@ -247,66 +226,6 @@ export type DiscordMessageHandler = (
client: Client, client: Client,
) => Promise<void>; ) => Promise<void>;
function isDiscordThreadType(type: ChannelType | undefined): boolean {
return (
type === ChannelType.PublicThread ||
type === ChannelType.PrivateThread ||
type === ChannelType.AnnouncementThread
);
}
type DiscordThreadParentInfo = {
id?: string;
name?: string;
type?: ChannelType;
};
function resolveDiscordThreadChannel(params: {
isGuildMessage: boolean;
message: DiscordMessageEvent["message"];
channelInfo: DiscordChannelInfo | null;
}): DiscordThreadChannel | null {
if (!params.isGuildMessage) return null;
const { message, channelInfo } = params;
const channel =
"channel" in message
? (message as { channel?: unknown }).channel
: undefined;
const isThreadChannel =
channel &&
typeof channel === "object" &&
"isThread" in channel &&
typeof (channel as { isThread?: unknown }).isThread === "function" &&
(channel as { isThread: () => boolean }).isThread();
if (isThreadChannel) return channel as unknown as DiscordThreadChannel;
if (!isDiscordThreadType(channelInfo?.type)) return null;
return {
id: message.channelId,
name: channelInfo?.name ?? undefined,
parentId: channelInfo?.parentId ?? undefined,
parent: undefined,
};
}
async function resolveDiscordThreadParentInfo(params: {
client: Client;
threadChannel: DiscordThreadChannel;
channelInfo: DiscordChannelInfo | null;
}): Promise<DiscordThreadParentInfo> {
const { threadChannel, channelInfo, client } = params;
const parentId =
threadChannel.parentId ??
threadChannel.parent?.id ??
channelInfo?.parentId ??
undefined;
if (!parentId) return {};
let parentName = threadChannel.parent?.name;
const parentInfo = await resolveDiscordChannelInfo(client, parentId);
parentName = parentName ?? parentInfo?.name;
const parentType = parentInfo?.type;
return { id: parentId, name: parentName, type: parentType };
}
export function resolveDiscordReplyTarget(opts: { export function resolveDiscordReplyTarget(opts: {
replyToMode: ReplyToMode; replyToMode: ReplyToMode;
replyToId?: string; replyToId?: string;
@@ -742,24 +661,17 @@ export function createDiscordMessageHandler(params: {
"name" in message.channel "name" in message.channel
? message.channel.name ? message.channel.name
: undefined); : undefined);
const threadChannel = resolveDiscordThreadChannel({ const isThreadChannel =
isGuildMessage, isGuildMessage &&
message, message.channel &&
channelInfo, "isThread" in message.channel &&
}); message.channel.isThread();
let threadParentId: string | undefined; const threadChannel = isThreadChannel
let threadParentName: string | undefined; ? (message.channel as DiscordThreadChannel)
let threadParentType: ChannelType | undefined; : null;
if (threadChannel) { const threadParentId =
const parentInfo = await resolveDiscordThreadParentInfo({ threadChannel?.parentId ?? threadChannel?.parent?.id ?? undefined;
client, const threadParentName = threadChannel?.parent?.name;
threadChannel,
channelInfo,
});
threadParentId = parentInfo.id;
threadParentName = parentInfo.name;
threadParentType = parentInfo.type;
}
const threadName = threadChannel?.name; const threadName = threadChannel?.name;
const configChannelName = threadParentName ?? channelName; const configChannelName = threadParentName ?? channelName;
const configChannelSlug = configChannelName const configChannelSlug = configChannelName
@@ -1023,7 +935,6 @@ export function createDiscordMessageHandler(params: {
channel: threadChannel, channel: threadChannel,
client, client,
parentId: threadParentId, parentId: threadParentId,
parentType: threadParentType,
}); });
if (starter?.text) { if (starter?.text) {
const starterEnvelope = formatThreadStarterEnvelope({ const starterEnvelope = formatThreadStarterEnvelope({
@@ -1773,42 +1684,15 @@ async function deliverDiscordReply(params: {
async function resolveDiscordChannelInfo( async function resolveDiscordChannelInfo(
client: Client, client: Client,
channelId: string, channelId: string,
): Promise<DiscordChannelInfo | null> { ): Promise<{ type: ChannelType; name?: string; topic?: string } | null> {
const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId);
if (cached) {
if (cached.expiresAt > Date.now()) return cached.value;
DISCORD_CHANNEL_INFO_CACHE.delete(channelId);
}
try { try {
const channel = await client.fetchChannel(channelId); const channel = await client.fetchChannel(channelId);
if (!channel) { if (!channel) return null;
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
return null;
}
const name = "name" in channel ? (channel.name ?? undefined) : undefined; const name = "name" in channel ? (channel.name ?? undefined) : undefined;
const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined; const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined;
const parentId = return { type: channel.type, name, topic };
"parentId" in channel ? (channel.parentId ?? undefined) : undefined;
const payload: DiscordChannelInfo = {
type: channel.type,
name,
topic,
parentId,
};
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: payload,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS,
});
return payload;
} catch (err) { } catch (err) {
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`); logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`);
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
return null; return null;
} }
} }
-125
View File
@@ -183,41 +183,6 @@ export type DiscordStickerUpload = {
mediaUrl: string; mediaUrl: string;
}; };
export type DiscordChannelCreate = {
guildId: string;
name: string;
type?: number;
parentId?: string;
topic?: string;
position?: number;
nsfw?: boolean;
};
export type DiscordChannelEdit = {
channelId: string;
name?: string;
topic?: string;
position?: number;
parentId?: string | null;
nsfw?: boolean;
rateLimitPerUser?: number;
};
export type DiscordChannelMove = {
guildId: string;
channelId: string;
parentId?: string | null;
position?: number;
};
export type DiscordChannelPermissionSet = {
channelId: string;
targetId: string;
targetType: 0 | 1;
allow?: string;
deny?: string;
};
function resolveToken(params: { function resolveToken(params: {
explicit?: string; explicit?: string;
accountId: string; accountId: string;
@@ -1226,93 +1191,3 @@ export async function banMemberDiscord(
}); });
return { ok: true }; return { ok: true };
} }
// Channel management functions
export async function createChannelDiscord(
payload: DiscordChannelCreate,
opts: DiscordReactOpts = {},
): Promise<APIChannel> {
const rest = resolveDiscordRest(opts);
const body: Record<string, unknown> = {
name: payload.name,
};
if (payload.type !== undefined) body.type = payload.type;
if (payload.parentId) body.parent_id = payload.parentId;
if (payload.topic) body.topic = payload.topic;
if (payload.position !== undefined) body.position = payload.position;
if (payload.nsfw !== undefined) body.nsfw = payload.nsfw;
return (await rest.post(Routes.guildChannels(payload.guildId), {
body,
})) as APIChannel;
}
export async function editChannelDiscord(
payload: DiscordChannelEdit,
opts: DiscordReactOpts = {},
): Promise<APIChannel> {
const rest = resolveDiscordRest(opts);
const body: Record<string, unknown> = {};
if (payload.name !== undefined) body.name = payload.name;
if (payload.topic !== undefined) body.topic = payload.topic;
if (payload.position !== undefined) body.position = payload.position;
if (payload.parentId !== undefined) body.parent_id = payload.parentId;
if (payload.nsfw !== undefined) body.nsfw = payload.nsfw;
if (payload.rateLimitPerUser !== undefined)
body.rate_limit_per_user = payload.rateLimitPerUser;
return (await rest.patch(Routes.channel(payload.channelId), {
body,
})) as APIChannel;
}
export async function deleteChannelDiscord(
channelId: string,
opts: DiscordReactOpts = {},
) {
const rest = resolveDiscordRest(opts);
await rest.delete(Routes.channel(channelId));
return { ok: true, channelId };
}
export async function moveChannelDiscord(
payload: DiscordChannelMove,
opts: DiscordReactOpts = {},
) {
const rest = resolveDiscordRest(opts);
const body: Array<Record<string, unknown>> = [
{
id: payload.channelId,
...(payload.parentId !== undefined && { parent_id: payload.parentId }),
...(payload.position !== undefined && { position: payload.position }),
},
];
await rest.patch(Routes.guildChannels(payload.guildId), { body });
return { ok: true };
}
export async function setChannelPermissionDiscord(
payload: DiscordChannelPermissionSet,
opts: DiscordReactOpts = {},
) {
const rest = resolveDiscordRest(opts);
const body: Record<string, unknown> = {
type: payload.targetType,
};
if (payload.allow !== undefined) body.allow = payload.allow;
if (payload.deny !== undefined) body.deny = payload.deny;
await rest.put(
`/channels/${payload.channelId}/permissions/${payload.targetId}`,
{ body },
);
return { ok: true };
}
export async function removeChannelPermissionDiscord(
channelId: string,
targetId: string,
opts: DiscordReactOpts = {},
) {
const rest = resolveDiscordRest(opts);
await rest.delete(`/channels/${channelId}/permissions/${targetId}`);
return { ok: true };
}
+26 -4
View File
@@ -6,7 +6,6 @@ import {
type HookMappingConfig, type HookMappingConfig,
type HooksConfig, type HooksConfig,
} from "../config/config.js"; } from "../config/config.js";
import type { HookMessageProvider } from "./hooks.js";
export type HookMappingResolved = { export type HookMappingResolved = {
id: string; id: string;
@@ -19,7 +18,15 @@ export type HookMappingResolved = {
messageTemplate?: string; messageTemplate?: string;
textTemplate?: string; textTemplate?: string;
deliver?: boolean; deliver?: boolean;
provider?: HookMessageProvider; provider?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string; to?: string;
model?: string; model?: string;
thinking?: string; thinking?: string;
@@ -52,7 +59,15 @@ export type HookAction =
wakeMode: "now" | "next-heartbeat"; wakeMode: "now" | "next-heartbeat";
sessionKey?: string; sessionKey?: string;
deliver?: boolean; deliver?: boolean;
provider?: HookMessageProvider; provider?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string; to?: string;
model?: string; model?: string;
thinking?: string; thinking?: string;
@@ -90,7 +105,14 @@ type HookTransformResult = Partial<{
name: string; name: string;
sessionKey: string; sessionKey: string;
deliver: boolean; deliver: boolean;
provider: HookMessageProvider; provider:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage";
to: string; to: string;
model: string; model: string;
thinking: string; thinking: string;
-19
View File
@@ -66,16 +66,6 @@ describe("gateway hooks helpers", () => {
expect(ok.value.sessionKey).toBe("hook:fixed"); expect(ok.value.sessionKey).toBe("hook:fixed");
expect(ok.value.provider).toBe("last"); expect(ok.value.provider).toBe("last");
expect(ok.value.name).toBe("Hook"); expect(ok.value.name).toBe("Hook");
expect(ok.value.deliver).toBe(true);
}
const explicitNoDeliver = normalizeAgentPayload(
{ message: "hello", deliver: false },
{ idFactory: () => "fixed" },
);
expect(explicitNoDeliver.ok).toBe(true);
if (explicitNoDeliver.ok) {
expect(explicitNoDeliver.value.deliver).toBe(false);
} }
const imsg = normalizeAgentPayload( const imsg = normalizeAgentPayload(
@@ -87,15 +77,6 @@ describe("gateway hooks helpers", () => {
expect(imsg.value.provider).toBe("imessage"); expect(imsg.value.provider).toBe("imessage");
} }
const teams = normalizeAgentPayload(
{ message: "yo", provider: "teams" },
{ idFactory: () => "x" },
);
expect(teams.ok).toBe(true);
if (teams.ok) {
expect(teams.value.provider).toBe("msteams");
}
const bad = normalizeAgentPayload({ message: "yo", provider: "sms" }); const bad = normalizeAgentPayload({ message: "yo", provider: "sms" });
expect(bad.ok).toBe(false); expect(bad.ok).toBe(false);
}); });
+33 -32
View File
@@ -138,41 +138,20 @@ export type HookAgentPayload = {
wakeMode: "now" | "next-heartbeat"; wakeMode: "now" | "next-heartbeat";
sessionKey: string; sessionKey: string;
deliver: boolean; deliver: boolean;
provider: HookMessageProvider; provider:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage";
to?: string; to?: string;
model?: string; model?: string;
thinking?: string; thinking?: string;
timeoutSeconds?: number; timeoutSeconds?: number;
}; };
const HOOK_PROVIDER_VALUES = [
"last",
"whatsapp",
"telegram",
"discord",
"slack",
"signal",
"imessage",
"msteams",
] as const;
export type HookMessageProvider = (typeof HOOK_PROVIDER_VALUES)[number];
const hookProviderSet = new Set<string>(HOOK_PROVIDER_VALUES);
export const HOOK_PROVIDER_ERROR = `provider must be ${HOOK_PROVIDER_VALUES.join("|")}`;
export function resolveHookProvider(raw: unknown): HookMessageProvider | null {
if (raw === undefined) return "last";
if (typeof raw !== "string") return null;
const normalized = normalizeMessageProvider(raw);
if (!normalized || !hookProviderSet.has(normalized)) return null;
return normalized as HookMessageProvider;
}
export function resolveHookDeliver(raw: unknown): boolean {
return raw !== false;
}
export function normalizeAgentPayload( export function normalizeAgentPayload(
payload: Record<string, unknown>, payload: Record<string, unknown>,
opts?: { idFactory?: () => string }, opts?: { idFactory?: () => string },
@@ -196,8 +175,30 @@ export function normalizeAgentPayload(
typeof sessionKeyRaw === "string" && sessionKeyRaw.trim() typeof sessionKeyRaw === "string" && sessionKeyRaw.trim()
? sessionKeyRaw.trim() ? sessionKeyRaw.trim()
: `hook:${idFactory()}`; : `hook:${idFactory()}`;
const provider = resolveHookProvider(payload.provider); const providerRaw = payload.provider;
if (!provider) return { ok: false, error: HOOK_PROVIDER_ERROR }; const providerNormalized =
typeof providerRaw === "string"
? normalizeMessageProvider(providerRaw)
: undefined;
const provider =
providerNormalized === "whatsapp" ||
providerNormalized === "telegram" ||
providerNormalized === "discord" ||
providerNormalized === "slack" ||
providerNormalized === "signal" ||
providerNormalized === "imessage" ||
providerNormalized === "last"
? providerNormalized
: providerRaw === undefined
? "last"
: null;
if (provider === null) {
return {
ok: false,
error:
"provider must be last|whatsapp|telegram|discord|slack|signal|imessage",
};
}
const toRaw = payload.to; const toRaw = payload.to;
const to = const to =
typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined; typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
@@ -209,7 +210,7 @@ export function normalizeAgentPayload(
if (modelRaw !== undefined && !model) { if (modelRaw !== undefined && !model) {
return { ok: false, error: "model required" }; return { ok: false, error: "model required" };
} }
const deliver = resolveHookDeliver(payload.deliver); const deliver = payload.deliver === true;
const thinkingRaw = payload.thinking; const thinkingRaw = payload.thinking;
const thinking = const thinking =
typeof thinkingRaw === "string" && thinkingRaw.trim() typeof thinkingRaw === "string" && thinkingRaw.trim()
+11 -12
View File
@@ -11,15 +11,11 @@ import type { createSubsystemLogger } from "../logging.js";
import { handleControlUiHttpRequest } from "./control-ui.js"; import { handleControlUiHttpRequest } from "./control-ui.js";
import { import {
extractHookToken, extractHookToken,
HOOK_PROVIDER_ERROR,
type HookMessageProvider,
type HooksConfigResolved, type HooksConfigResolved,
normalizeAgentPayload, normalizeAgentPayload,
normalizeHookHeaders, normalizeHookHeaders,
normalizeWakePayload, normalizeWakePayload,
readJsonBody, readJsonBody,
resolveHookDeliver,
resolveHookProvider,
} from "./hooks.js"; } from "./hooks.js";
import { applyHookMappings } from "./hooks-mapping.js"; import { applyHookMappings } from "./hooks-mapping.js";
@@ -36,7 +32,15 @@ type HookDispatchers = {
wakeMode: "now" | "next-heartbeat"; wakeMode: "now" | "next-heartbeat";
sessionKey: string; sessionKey: string;
deliver: boolean; deliver: boolean;
provider: HookMessageProvider; provider:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string; to?: string;
model?: string; model?: string;
thinking?: string; thinking?: string;
@@ -167,18 +171,13 @@ export function createHooksRequestHandler(
sendJson(res, 200, { ok: true, mode: mapped.action.mode }); sendJson(res, 200, { ok: true, mode: mapped.action.mode });
return true; return true;
} }
const provider = resolveHookProvider(mapped.action.provider);
if (!provider) {
sendJson(res, 400, { ok: false, error: HOOK_PROVIDER_ERROR });
return true;
}
const runId = dispatchAgentHook({ const runId = dispatchAgentHook({
message: mapped.action.message, message: mapped.action.message,
name: mapped.action.name ?? "Hook", name: mapped.action.name ?? "Hook",
wakeMode: mapped.action.wakeMode, wakeMode: mapped.action.wakeMode,
sessionKey: mapped.action.sessionKey ?? "", sessionKey: mapped.action.sessionKey ?? "",
deliver: resolveHookDeliver(mapped.action.deliver), deliver: mapped.action.deliver === true,
provider, provider: mapped.action.provider ?? "last",
to: mapped.action.to, to: mapped.action.to,
model: mapped.action.model, model: mapped.action.model,
thinking: mapped.action.thinking, thinking: mapped.action.thinking,
+10 -2
View File
@@ -111,7 +111,7 @@ import {
startGatewayConfigReloader, startGatewayConfigReloader,
} from "./config-reload.js"; } from "./config-reload.js";
import { normalizeControlUiBasePath } from "./control-ui.js"; import { normalizeControlUiBasePath } from "./control-ui.js";
import { type HookMessageProvider, resolveHooksConfig } from "./hooks.js"; import { resolveHooksConfig } from "./hooks.js";
import { import {
isLoopbackAddress, isLoopbackAddress,
isLoopbackHost, isLoopbackHost,
@@ -496,7 +496,15 @@ export async function startGatewayServer(
wakeMode: "now" | "next-heartbeat"; wakeMode: "now" | "next-heartbeat";
sessionKey: string; sessionKey: string;
deliver: boolean; deliver: boolean;
provider: HookMessageProvider; provider:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
| "signal"
| "imessage"
| "msteams";
to?: string; to?: string;
model?: string; model?: string;
thinking?: string; thinking?: string;
+1 -12
View File
@@ -6,10 +6,6 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
const realOs = await vi.importActual<typeof import("node:os")>("node:os"); const realOs = await vi.importActual<typeof import("node:os")>("node:os");
const HOME = path.join(realOs.tmpdir(), "clawdbot-home-test"); const HOME = path.join(realOs.tmpdir(), "clawdbot-home-test");
const PREV_CLAWDBOT_STATE_DIR = process.env.CLAWDBOT_STATE_DIR;
const PREV_CLAWDIS_STATE_DIR = process.env.CLAWDIS_STATE_DIR;
delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDIS_STATE_DIR;
vi.mock("node:os", () => ({ vi.mock("node:os", () => ({
default: { homedir: () => HOME, tmpdir: () => realOs.tmpdir() }, default: { homedir: () => HOME, tmpdir: () => realOs.tmpdir() },
@@ -26,18 +22,11 @@ describe("media store", () => {
afterAll(async () => { afterAll(async () => {
await fs.rm(HOME, { recursive: true, force: true }); await fs.rm(HOME, { recursive: true, force: true });
if (PREV_CLAWDBOT_STATE_DIR !== undefined)
process.env.CLAWDBOT_STATE_DIR = PREV_CLAWDBOT_STATE_DIR;
else delete process.env.CLAWDBOT_STATE_DIR;
if (PREV_CLAWDIS_STATE_DIR !== undefined)
process.env.CLAWDIS_STATE_DIR = PREV_CLAWDIS_STATE_DIR;
else delete process.env.CLAWDIS_STATE_DIR;
}); });
it("creates and returns media directory", async () => { it("creates and returns media directory", async () => {
const dir = await store.ensureMediaDir(); const dir = await store.ensureMediaDir();
expect(path.basename(dir)).toBe("media"); expect(dir).toContain("clawdbot-home-test");
const stat = await fs.stat(dir); const stat = await fs.stat(dir);
expect(stat.isDirectory()).toBe(true); expect(stat.isDirectory()).toBe(true);
}); });
+1 -17
View File
@@ -14,16 +14,9 @@ const runtime = {
describe("web logout", () => { describe("web logout", () => {
const origHomedir = os.homedir; const origHomedir = os.homedir;
let tmpDir: string; let tmpDir: string;
let prevStateDir: string | undefined;
let prevClawdisStateDir: string | undefined;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
prevStateDir = process.env.CLAWDBOT_STATE_DIR;
prevClawdisStateDir = process.env.CLAWDIS_STATE_DIR;
delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDIS_STATE_DIR;
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-logout-")); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-logout-"));
vi.spyOn(os, "homedir").mockReturnValue(tmpDir); vi.spyOn(os, "homedir").mockReturnValue(tmpDir);
vi.resetModules(); vi.resetModules();
@@ -43,14 +36,6 @@ describe("web logout", () => {
await fsPromises await fsPromises
.rm(tmpDir, { recursive: true, force: true }) .rm(tmpDir, { recursive: true, force: true })
.catch(() => {}); .catch(() => {});
if (prevStateDir !== undefined) process.env.CLAWDBOT_STATE_DIR = prevStateDir;
else delete process.env.CLAWDBOT_STATE_DIR;
if (prevClawdisStateDir !== undefined)
process.env.CLAWDIS_STATE_DIR = prevClawdisStateDir;
else delete process.env.CLAWDIS_STATE_DIR;
// restore for safety // restore for safety
// eslint-disable-next-line @typescript-eslint/unbound-method // eslint-disable-next-line @typescript-eslint/unbound-method
(os.homedir as unknown as typeof origHomedir) = origHomedir; (os.homedir as unknown as typeof origHomedir) = origHomedir;
@@ -62,8 +47,7 @@ describe("web logout", () => {
async () => { async () => {
const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js"); const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js");
const rel = path.relative(tmpDir, WA_WEB_AUTH_DIR); expect(WA_WEB_AUTH_DIR.startsWith(tmpDir)).toBe(true);
expect(rel && !rel.startsWith("..") && !path.isAbsolute(rel)).toBe(true);
fs.mkdirSync(WA_WEB_AUTH_DIR, { recursive: true }); fs.mkdirSync(WA_WEB_AUTH_DIR, { recursive: true });
fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}"); fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}");
const result = await logoutWeb({ runtime: runtime as never }); const result = await logoutWeb({ runtime: runtime as never });