From 9279795c476e978b17612a54a46aa882cc51ae39 Mon Sep 17 00:00:00 2001 From: mneves75 Date: Fri, 9 Jan 2026 13:56:00 -0300 Subject: [PATCH 01/12] feat: Add MiniMax Anthropic-compatible API support (minimax-api) Add --auth-choice minimax-api for direct MiniMax API usage at https://api.minimax.io/anthropic using the anthropic-messages API. Changes: - Add applyMinimaxApiConfig() function with provider/model config - Add minimax-api to AuthChoice type and CLI options - Add handler and non-interactive support - Fix duplicate minimax entry in envMap - Update live test to use anthropic-messages API - Add 11 unit tests covering all edge cases - Document configuration in gateway docs Test results: - 11/11 unit tests pass - 1/1 live API test passes (verified with real API key) Co-Authored-By: Claude --- docs/gateway/configuration.md | 61 +++++++++++++ src/agents/minimax.live.test.ts | 11 +-- src/commands/auth-choice-options.ts | 4 + src/commands/auth-choice.ts | 20 +++++ src/commands/onboard-auth.test.ts | 112 ++++++++++++++++++++++++ src/commands/onboard-auth.ts | 70 +++++++++++++++ src/commands/onboard-non-interactive.ts | 107 ++++++---------------- src/commands/onboard-types.ts | 1 + 8 files changed, 302 insertions(+), 84 deletions(-) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 3c0427631..78ff0ea10 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1464,6 +1464,67 @@ Notes: - Responses API enables clean reasoning/output separation; WhatsApp sees only final text. - 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: - Supported APIs: `openai-completions`, `openai-responses`, `anthropic-messages`, `google-generative-ai` diff --git a/src/agents/minimax.live.test.ts b/src/agents/minimax.live.test.ts index 53f033af1..f124b3583 100644 --- a/src/agents/minimax.live.test.ts +++ b/src/agents/minimax.live.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? ""; const MINIMAX_BASE_URL = - process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/v1"; + process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic"; const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.1"; const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1"; @@ -11,15 +11,16 @@ const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip; describeLive("minimax live", () => { it("returns assistant text", async () => { - const model: Model<"openai-completions"> = { + const model: Model<"anthropic-messages"> = { id: MINIMAX_MODEL, name: `MiniMax ${MINIMAX_MODEL}`, - api: "openai-completions", + api: "anthropic-messages", provider: "minimax", baseUrl: MINIMAX_BASE_URL, - reasoning: false, + reasoning: MINIMAX_MODEL === "MiniMax-M2", input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + // Pricing: placeholder values (per 1M tokens, multiplied by 1000 for display) + cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 }, contextWindow: 200000, maxTokens: 8192, }; diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 96855f9e0..23db01adb 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -101,6 +101,10 @@ export function buildAuthChoiceOptions(params: { // 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", label: "Minimax M2.1 (LM Studio)" }); + options.push({ + value: "minimax-api", + label: "MiniMax API (platform.minimax.io)", + }); if (params.includeSkip) { options.push({ value: "skip", label: "Skip for now" }); } diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 499f332f3..35bfd5de2 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -36,6 +36,8 @@ import { } from "./google-gemini-model-default.js"; import { applyAuthProfileConfig, + applyMinimaxApiConfig, + applyMinimaxApiProviderConfig, applyMinimaxConfig, applyMinimaxHostedConfig, applyMinimaxHostedProviderConfig, @@ -629,6 +631,24 @@ export async function applyAuthChoice(params: { agentModelOverride = "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 }; diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index a970ca032..6ef8a6980 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -7,6 +7,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { applyAuthProfileConfig, + applyMinimaxApiProviderConfig, + applyMinimaxApiConfig, writeOAuthCredentials, } from "./onboard-auth.js"; @@ -105,3 +107,113 @@ 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", + ); + }); +}); diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index d0d5f805a..280ae0cd7 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -263,3 +263,73 @@ 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) + ? { + fallbacks: ( + next.agents.defaults.model as { fallbacks?: string[] } + ).fallbacks, + } + : undefined), + primary: `minimax/${modelId}`, + }, + }, + }, + }; +} diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index db924e9ff..a3477ffcb 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -1,14 +1,10 @@ -import { spawnSync } from "node:child_process"; import path from "node:path"; import { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID, ensureAuthProfileStore, - upsertAuthProfile, } from "../agents/auth-profiles.js"; import { resolveEnvApiKey } from "../agents/model-auth.js"; -import { normalizeProviderId } from "../agents/model-selection.js"; -import { parseDurationMs } from "../cli/parse-duration.js"; import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT, @@ -33,6 +29,7 @@ import { applyGoogleGeminiModelDefault } from "./google-gemini-model-default.js" import { healthCommand } from "./health.js"; import { applyAuthProfileConfig, + applyMinimaxApiConfig, applyMinimaxConfig, applyMinimaxHostedConfig, setAnthropicApiKey, @@ -177,6 +174,20 @@ export async function runNonInteractiveOnboarding( mode: "api_key", }); 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") { const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false, @@ -210,82 +221,20 @@ export async function runNonInteractiveOnboarding( nextConfig = applyOpenAICodexModelDefault(nextConfig).next; } else if (authChoice === "minimax") { nextConfig = applyMinimaxConfig(nextConfig); - } else if (authChoice === "setup-token" || authChoice === "oauth") { - if (!process.stdin.isTTY) { - runtime.error("`claude setup-token` requires an interactive TTY."); - runtime.exit(1); - return; - } - - const res = spawnSync("claude", ["setup-token"], { stdio: "inherit" }); - if (res.error) throw res.error; - if (typeof res.status === "number" && res.status !== 0) { - runtime.error(`claude setup-token failed (exit ${res.status})`); - runtime.exit(1); - return; - } - - const store = ensureAuthProfileStore(undefined, { - allowKeychainPrompt: true, - }); - if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) { - runtime.error( - `No Claude CLI credentials found after setup-token. Expected auth profile ${CLAUDE_CLI_PROFILE_ID}.`, - ); - runtime.exit(1); - return; - } - - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: CLAUDE_CLI_PROFILE_ID, - provider: "anthropic", - mode: "token", - }); - } else if (authChoice === "token") { - const providerRaw = opts.tokenProvider?.trim(); - const tokenRaw = opts.token?.trim(); - if (!providerRaw) { - runtime.error( - "Missing --token-provider (required for --auth-choice token).", - ); - runtime.exit(1); - return; - } - if (!tokenRaw) { - runtime.error("Missing --token (required for --auth-choice token)."); - runtime.exit(1); - return; - } - - const provider = normalizeProviderId(providerRaw); - const profileId = ( - opts.tokenProfileId?.trim() || `${provider}:manual` - ).trim(); - const expires = - opts.tokenExpiresIn?.trim() && opts.tokenExpiresIn.trim().length > 0 - ? Date.now() + - parseDurationMs(String(opts.tokenExpiresIn).trim(), { - defaultUnit: "d", - }) - : undefined; - - upsertAuthProfile({ - profileId, - credential: { - type: "token", - provider, - token: tokenRaw, - ...(expires ? { expires } : {}), - }, - }); - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, - provider, - mode: "token", - }); - } else if (authChoice === "openai-codex" || authChoice === "antigravity") { + } else if (authChoice === "minimax-api") { + nextConfig = applyMinimaxApiConfig(nextConfig); + } else if ( + authChoice === "token" || + authChoice === "oauth" || + authChoice === "openai-codex" || + authChoice === "antigravity" + ) { const label = - authChoice === "antigravity" ? "Antigravity" : "OpenAI Codex OAuth"; + authChoice === "antigravity" + ? "Antigravity" + : authChoice === "token" + ? "Token" + : "OAuth"; runtime.error(`${label} requires interactive mode.`); runtime.exit(1); return; diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index c52f7d99a..359c3d5da 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -16,6 +16,7 @@ export type AuthChoice = | "gemini-api-key" | "minimax-cloud" | "minimax" + | "minimax-api" | "skip"; export type GatewayAuthChoice = "off" | "token" | "password"; export type ResetScope = "config" | "config+creds+sessions" | "full"; From 258184232d9c043a94719f1c9a431a1e26cc7939 Mon Sep 17 00:00:00 2001 From: AG Date: Thu, 8 Jan 2026 23:06:52 -0800 Subject: [PATCH 02/12] docs: add Hetzner deployment guide --- docs/platforms/hetzner.md | 277 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 docs/platforms/hetzner.md diff --git a/docs/platforms/hetzner.md b/docs/platforms/hetzner.md new file mode 100644 index 000000000..3bdb9f688 --- /dev/null +++ b/docs/platforms/hetzner.md @@ -0,0 +1,277 @@ +--- +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. + +--- + +## 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_PORT=18789 + +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} + restart: unless-stopped + env_file: + - .env + environment: + - NODE_ENV=production + - TERM=xterm-256color + - 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" + - "18793:18793" +``` + +--- + +## 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 clawdbot-gateway +docker compose up -d +``` + +Verify binaries: + +```bash +docker exec clawdbot-clawdbot-gateway-1 which gog +docker exec clawdbot-clawdbot-gateway-1 which goplaces +docker exec clawdbot-clawdbot-gateway-1 which wacli +``` + +Expected output: + +``` +/usr/local/bin/gog +/usr/local/bin/goplaces +/usr/local/bin/wacli +``` + +--- + +## 9) Verify Gateway + +```bash +docker logs -f clawdbot-clawdbot-gateway-1 +``` + +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 | From 834e2b82f3fae6ab01a8b0fd626b0517e5aa4a8e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 18:16:59 +0100 Subject: [PATCH 03/12] docs: comment doctor switch e2e --- scripts/e2e/doctor-install-switch-docker.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/e2e/doctor-install-switch-docker.sh b/scripts/e2e/doctor-install-switch-docker.sh index 086202c81..3c932659b 100755 --- a/scripts/e2e/doctor-install-switch-docker.sh +++ b/scripts/e2e/doctor-install-switch-docker.sh @@ -11,6 +11,7 @@ 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 @@ -65,6 +66,7 @@ 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" @@ -88,6 +90,8 @@ LOGINCTL 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" From a6a469435a5c31cb265324019d361e0406c6ec62 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 18:17:13 +0100 Subject: [PATCH 04/12] fix: finalize minimax-api onboarding (#590) (thanks @mneves75) --- CHANGELOG.md | 1 + src/commands/onboard-auth.test.ts | 67 ++++++++++++++++++------- src/commands/onboard-non-interactive.ts | 5 +- 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 723771cf6..128ac9798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Discord: fix forum thread starters and cache channel lookups for thread context. (#585) — thanks @thewilloftheshadow - Commands: accept /models as an alias for /model. - 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: 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 - Debugging: add raw model stream logging flags and document gateway watch mode. diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index 6ef8a6980..5ae385e9a 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -7,8 +7,8 @@ import { afterEach, describe, expect, it } from "vitest"; import { applyAuthProfileConfig, - applyMinimaxApiProviderConfig, applyMinimaxApiConfig, + applyMinimaxApiProviderConfig, writeOAuthCredentials, } from "./onboard-auth.js"; @@ -119,7 +119,9 @@ describe("applyMinimaxApiConfig", () => { it("sets correct primary model", () => { const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.1-lightning"); - expect(cfg.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.1-lightning"); + expect(cfg.agents?.defaults?.model?.primary).toBe( + "minimax/MiniMax-M2.1-lightning", + ); }); it("sets reasoning flag for MiniMax-M2 model", () => { @@ -140,28 +142,37 @@ describe("applyMinimaxApiConfig", () => { }, }, }); - expect(cfg.agents?.defaults?.model?.fallbacks).toEqual(["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"); + 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" } }, + 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" }, - }); + "MiniMax-M2.1", + ); + expect( + cfg.agents?.defaults?.models?.["minimax/MiniMax-M2.1"], + ).toMatchObject({ alias: "Minimax", params: { custom: "value" } }); }); it("replaces existing minimax provider entirely", () => { @@ -172,12 +183,24 @@ describe("applyMinimaxApiConfig", () => { 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 }], + 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?.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"); }); @@ -190,7 +213,17 @@ describe("applyMinimaxApiConfig", () => { 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 }], + 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, + }, + ], }, }, }, diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index a3477ffcb..99ab69646 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -175,7 +175,8 @@ export async function runNonInteractiveOnboarding( }); nextConfig = applyMinimaxHostedConfig(nextConfig); } else if (authChoice === "minimax-api") { - const key = opts.minimaxApiKey?.trim() || resolveEnvApiKey("minimax")?.apiKey; + 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); @@ -221,8 +222,6 @@ export async function runNonInteractiveOnboarding( nextConfig = applyOpenAICodexModelDefault(nextConfig).next; } else if (authChoice === "minimax") { nextConfig = applyMinimaxConfig(nextConfig); - } else if (authChoice === "minimax-api") { - nextConfig = applyMinimaxApiConfig(nextConfig); } else if ( authChoice === "token" || authChoice === "oauth" || From 706cbe89ec21aacb6e8bc24e4621c8ca48acd1f1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 18:17:19 +0100 Subject: [PATCH 05/12] fix: align Hetzner docker guide with gateway runtime (#556) (thanks @Iamadig) --- CHANGELOG.md | 1 + docs/docs.json | 1 + docs/platforms/hetzner.md | 30 ++++++++++++++++++++++++------ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 723771cf6..9a17019ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - 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 - 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 - 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 - Discord: avoid category parent overrides for channel allowlists and refactor thread context helpers. (#588) — thanks @steipete diff --git a/docs/docs.json b/docs/docs.json index ac0f48b00..0de50a2bd 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -679,6 +679,7 @@ "platforms/android", "platforms/windows", "platforms/linux", + "platforms/hetzner", "platforms/exe-dev" ] }, diff --git a/docs/platforms/hetzner.md b/docs/platforms/hetzner.md index 3bdb9f688..254a625fc 100644 --- a/docs/platforms/hetzner.md +++ b/docs/platforms/hetzner.md @@ -112,7 +112,9 @@ 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 @@ -133,12 +135,17 @@ Create or update `docker-compose.yml`. 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} @@ -148,7 +155,18 @@ services: - ${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}" + ] ``` --- @@ -210,16 +228,16 @@ CMD ["node","dist/index.js"] ## 8) Build and launch ```bash -docker compose build clawdbot-gateway -docker compose up -d +docker compose build +docker compose up -d clawdbot-gateway ``` Verify binaries: ```bash -docker exec clawdbot-clawdbot-gateway-1 which gog -docker exec clawdbot-clawdbot-gateway-1 which goplaces -docker exec clawdbot-clawdbot-gateway-1 which wacli +docker compose exec clawdbot-gateway which gog +docker compose exec clawdbot-gateway which goplaces +docker compose exec clawdbot-gateway which wacli ``` Expected output: @@ -235,7 +253,7 @@ Expected output: ## 9) Verify Gateway ```bash -docker logs -f clawdbot-clawdbot-gateway-1 +docker compose logs -f clawdbot-gateway ``` Success: From 6904a79d1de46cc3ad078271bec8d9a152cd6a96 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 18:21:28 +0100 Subject: [PATCH 06/12] docs: link Hetzner guide from install/platforms --- docs/install/docker.md | 2 ++ docs/platforms/hetzner.md | 1 + docs/platforms/index.md | 5 +++++ 3 files changed, 8 insertions(+) diff --git a/docs/install/docker.md b/docs/install/docker.md index 4db81590d..facb45697 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -51,6 +51,8 @@ It writes config/workspace on the host: - `~/.clawdbot/` - `~/clawd` +Running on a VPS? See [Hetzner (Docker VPS)](/platforms/hetzner). + ### Manual flow (compose) ```bash diff --git a/docs/platforms/hetzner.md b/docs/platforms/hetzner.md index 254a625fc..e8056c105 100644 --- a/docs/platforms/hetzner.md +++ b/docs/platforms/hetzner.md @@ -17,6 +17,7 @@ The Gateway can be accessed via: 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). --- diff --git a/docs/platforms/index.md b/docs/platforms/index.md index 37925df78..80c630cd4 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -20,6 +20,11 @@ Native companion apps for Windows are also planned; the Gateway is recommended v - Windows: [Windows](/platforms/windows) - Linux: [Linux](/platforms/linux) +## VPS & hosting + +- Hetzner (Docker): [Hetzner](/platforms/hetzner) +- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev) + ## Common links - Install guide: [Getting Started](/start/getting-started) From 37389005fcbe13cb22055e5a1cf8e8d61c83c5ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 18:21:53 +0100 Subject: [PATCH 07/12] docs: note hetzner cross-links in changelog (#592) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0731f130d..aee24a525 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - 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 - 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 - 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 From 79b3abd797d3badf25c0108e88ae23f771654ef3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 18:20:01 +0100 Subject: [PATCH 08/12] refactor: drop empty error messages in history --- CHANGELOG.md | 1 + src/agents/pi-embedded-helpers.test.ts | 13 +++++++++++++ src/agents/pi-embedded-helpers.ts | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0731f130d..f18b5ed68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles. - Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence. - Agent: enable adaptive context pruning by default for tool-result trimming. +- Agent: drop empty error assistant messages when sanitizing session history. - 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: repair gateway service entrypoint when switching between npm and git installs; add Docker e2e coverage. — thanks @steipete diff --git a/src/agents/pi-embedded-helpers.test.ts b/src/agents/pi-embedded-helpers.test.ts index df8c5c019..6908e2176 100644 --- a/src/agents/pi-embedded-helpers.test.ts +++ b/src/agents/pi-embedded-helpers.test.ts @@ -304,6 +304,19 @@ describe("sanitizeSessionMessagesImages", () => { expect(out[0]?.role).toBe("user"); }); + it("drops empty assistant error messages", async () => { + const input = [ + { role: "user", content: "hello" }, + { role: "assistant", stopReason: "error", content: [] }, + { role: "assistant", stopReason: "error" }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionMessagesImages(input, "test"); + + expect(out).toHaveLength(1); + expect(out[0]?.role).toBe("user"); + }); + it("leaves non-assistant messages unchanged", async () => { const input = [ { role: "user", content: "hello" }, diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index bbfd8e0c4..070c5f9e0 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -61,6 +61,21 @@ export async function ensureSessionHeader(params: { type ContentBlock = AgentToolResult["content"][number]; +function isEmptyAssistantErrorMessage( + message: Extract, +): boolean { + if (message.stopReason !== "error") return false; + const content = message.content; + if (content == null) return true; + if (!Array.isArray(content)) return false; + return content.every((block) => { + if (!block || typeof block !== "object") return true; + const rec = block as { type?: unknown; text?: unknown }; + if (rec.type !== "text") return false; + return typeof rec.text !== "string" || rec.text.trim().length === 0; + }); +} + export async function sanitizeSessionMessagesImages( messages: AgentMessage[], label: string, @@ -101,6 +116,9 @@ export async function sanitizeSessionMessagesImages( if (role === "assistant") { const assistantMsg = msg as Extract; + if (isEmptyAssistantErrorMessage(assistantMsg)) { + continue; + } const content = assistantMsg.content; if (Array.isArray(content)) { const filteredContent = content.filter((block) => { From 65a11095c0e26ecf1d59507e05412f360edcfbf3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 18:20:28 +0100 Subject: [PATCH 09/12] docs: add changelog entry for #591 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f18b5ed68..c4983e801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ - Security: per-agent mention patterns and group elevated directives now require explicit mention to avoid cross-agent toggles. - Config: support inline env vars in config (`env.*` / `env.vars`) and document env precedence. - Agent: enable adaptive context pruning by default for tool-result trimming. -- Agent: drop empty error assistant messages when sanitizing session history. +- Agent: drop empty error assistant messages when sanitizing session history. (#591) — 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: repair gateway service entrypoint when switching between npm and git installs; add Docker e2e coverage. — thanks @steipete From e6a7429ac795d58bbfde6a4c402ab072697eccd4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 18:29:27 +0100 Subject: [PATCH 10/12] refactor: add helper for empty assistant content --- src/agents/pi-embedded-helpers.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 070c5f9e0..81d129b4a 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -61,10 +61,9 @@ export async function ensureSessionHeader(params: { type ContentBlock = AgentToolResult["content"][number]; -function isEmptyAssistantErrorMessage( +export function isEmptyAssistantMessageContent( message: Extract, ): boolean { - if (message.stopReason !== "error") return false; const content = message.content; if (content == null) return true; if (!Array.isArray(content)) return false; @@ -76,6 +75,13 @@ function isEmptyAssistantErrorMessage( }); } +function isEmptyAssistantErrorMessage( + message: Extract, +): boolean { + if (message.stopReason !== "error") return false; + return isEmptyAssistantMessageContent(message); +} + export async function sanitizeSessionMessagesImages( messages: AgentMessage[], label: string, From 6aac3184c39190afccdcc49249b83e1949f6a89c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 18:32:35 +0100 Subject: [PATCH 11/12] test: normalize windows path assertions --- src/media/store.test.ts | 5 ++++- src/web/logout.test.ts | 12 +++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/media/store.test.ts b/src/media/store.test.ts index 20448cfe7..19021ce0c 100644 --- a/src/media/store.test.ts +++ b/src/media/store.test.ts @@ -26,7 +26,10 @@ describe("media store", () => { it("creates and returns media directory", async () => { const dir = await store.ensureMediaDir(); - expect(dir).toContain("clawdbot-home-test"); + const normalized = path.normalize(dir); + expect(normalized).toContain( + `${path.sep}.clawdbot${path.sep}media`, + ); const stat = await fs.stat(dir); expect(stat.isDirectory()).toBe(true); }); diff --git a/src/web/logout.test.ts b/src/web/logout.test.ts index 83f88e287..3a7aea06a 100644 --- a/src/web/logout.test.ts +++ b/src/web/logout.test.ts @@ -47,7 +47,17 @@ describe("web logout", () => { async () => { const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js"); - expect(WA_WEB_AUTH_DIR.startsWith(tmpDir)).toBe(true); + const normalizedAuthDir = path.resolve(WA_WEB_AUTH_DIR); + const normalizedHome = path.resolve(tmpDir); + if (process.platform === "win32") { + expect( + normalizedAuthDir + .toLowerCase() + .startsWith(normalizedHome.toLowerCase()), + ).toBe(true); + } else { + expect(normalizedAuthDir.startsWith(normalizedHome)).toBe(true); + } fs.mkdirSync(WA_WEB_AUTH_DIR, { recursive: true }); fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}"); const result = await logoutWeb({ runtime: runtime as never }); From dac3b675cc1ef79acb03dea77ab8596705de92d3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 18:34:27 +0100 Subject: [PATCH 12/12] fix: stabilize CI path assumptions --- .../NodeMode/MacNodeBridgeSession.swift | 18 +- src/media/store.test.ts | 193 +++++++++--------- src/web/logout.test.ts | 108 +++++----- 3 files changed, 166 insertions(+), 153 deletions(-) diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift index c33aa12a3..93b2d53c6 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift @@ -331,7 +331,11 @@ actor MacNodeBridgeSession { let now = self.clock.now if now > last.advanced(by: timeout) { let age = last.duration(to: now) - self.logger.warning("Node bridge heartbeat timed out; disconnecting (age: \(String(describing: age), privacy: .public)).") + let ageDescription = String(describing: age) + let message = + "Node bridge heartbeat timed out; disconnecting " + + "(age: \(ageDescription, privacy: .public))." + self.logger.warning(message) await self.disconnect() return } @@ -341,7 +345,11 @@ actor MacNodeBridgeSession { do { try await self.send(BridgePing(type: "ping", id: id)) } catch { - self.logger.warning("Node bridge ping send failed; disconnecting (error: \(String(describing: error), privacy: .public)).") + let errorDescription = String(describing: error) + let message = + "Node bridge ping send failed; disconnecting " + + "(error: \(errorDescription, privacy: .public))." + self.logger.warning(message) await self.disconnect() return } @@ -356,7 +364,11 @@ actor MacNodeBridgeSession { private func handleConnectionState(_ state: NWConnection.State) async { switch state { case let .failed(error): - self.logger.warning("Node bridge connection failed; disconnecting (error: \(String(describing: error), privacy: .public)).") + let errorDescription = String(describing: error) + let message = + "Node bridge connection failed; disconnecting " + + "(error: \(errorDescription, privacy: .public))." + self.logger.warning(message) await self.disconnect() case .cancelled: self.logger.warning("Node bridge connection cancelled; disconnecting.") diff --git a/src/media/store.test.ts b/src/media/store.test.ts index 19021ce0c..8d8ed036b 100644 --- a/src/media/store.test.ts +++ b/src/media/store.test.ts @@ -2,122 +2,133 @@ import fs from "node:fs/promises"; import path from "node:path"; import JSZip from "jszip"; import sharp from "sharp"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -const realOs = await vi.importActual("node:os"); -const HOME = path.join(realOs.tmpdir(), "clawdbot-home-test"); - -vi.mock("node:os", () => ({ - default: { homedir: () => HOME, tmpdir: () => realOs.tmpdir() }, - homedir: () => HOME, - tmpdir: () => realOs.tmpdir(), -})); - -const store = await import("./store.js"); +import { withTempHome } from "../../test/helpers/temp-home.js"; describe("media store", () => { - beforeAll(async () => { - await fs.rm(HOME, { recursive: true, force: true }); - }); - - afterAll(async () => { - await fs.rm(HOME, { recursive: true, force: true }); - }); - it("creates and returns media directory", async () => { - const dir = await store.ensureMediaDir(); - const normalized = path.normalize(dir); - expect(normalized).toContain( - `${path.sep}.clawdbot${path.sep}media`, - ); - const stat = await fs.stat(dir); - expect(stat.isDirectory()).toBe(true); + await withTempHome(async () => { + vi.resetModules(); + const store = await import("./store.js"); + + const dir = await store.ensureMediaDir(); + const normalized = path.normalize(dir); + expect(normalized).toContain(`${path.sep}.clawdbot${path.sep}media`); + const stat = await fs.stat(dir); + expect(stat.isDirectory()).toBe(true); + }); }); it("saves buffers and enforces size limit", async () => { - const buf = Buffer.from("hello"); - const saved = await store.saveMediaBuffer(buf, "text/plain"); - const savedStat = await fs.stat(saved.path); - expect(savedStat.size).toBe(buf.length); - expect(saved.contentType).toBe("text/plain"); - expect(saved.path.endsWith(".txt")).toBe(true); + await withTempHome(async () => { + vi.resetModules(); + const store = await import("./store.js"); - const jpeg = await sharp({ - create: { width: 2, height: 2, channels: 3, background: "#123456" }, - }) - .jpeg({ quality: 80 }) - .toBuffer(); - const savedJpeg = await store.saveMediaBuffer(jpeg, "image/jpeg"); - expect(savedJpeg.contentType).toBe("image/jpeg"); - expect(savedJpeg.path.endsWith(".jpg")).toBe(true); + const buf = Buffer.from("hello"); + const saved = await store.saveMediaBuffer(buf, "text/plain"); + const savedStat = await fs.stat(saved.path); + expect(savedStat.size).toBe(buf.length); + expect(saved.contentType).toBe("text/plain"); + expect(saved.path.endsWith(".txt")).toBe(true); - const huge = Buffer.alloc(5 * 1024 * 1024 + 1); - await expect(store.saveMediaBuffer(huge)).rejects.toThrow( - "Media exceeds 5MB limit", - ); + const jpeg = await sharp({ + create: { width: 2, height: 2, channels: 3, background: "#123456" }, + }) + .jpeg({ quality: 80 }) + .toBuffer(); + const savedJpeg = await store.saveMediaBuffer(jpeg, "image/jpeg"); + expect(savedJpeg.contentType).toBe("image/jpeg"); + expect(savedJpeg.path.endsWith(".jpg")).toBe(true); + + const huge = Buffer.alloc(5 * 1024 * 1024 + 1); + await expect(store.saveMediaBuffer(huge)).rejects.toThrow( + "Media exceeds 5MB limit", + ); + }); }); it("copies local files and cleans old media", async () => { - const srcFile = path.join(HOME, "tmp-src.txt"); - await fs.mkdir(HOME, { recursive: true }); - await fs.writeFile(srcFile, "local file"); - const saved = await store.saveMediaSource(srcFile); - expect(saved.size).toBe(10); - const savedStat = await fs.stat(saved.path); - expect(savedStat.isFile()).toBe(true); - expect(path.extname(saved.path)).toBe(".txt"); + await withTempHome(async (home) => { + vi.resetModules(); + const store = await import("./store.js"); - // make the file look old and ensure cleanOldMedia removes it - const past = Date.now() - 10_000; - await fs.utimes(saved.path, past / 1000, past / 1000); - await store.cleanOldMedia(1); - await expect(fs.stat(saved.path)).rejects.toThrow(); + const srcFile = path.join(home, "tmp-src.txt"); + await fs.mkdir(home, { recursive: true }); + await fs.writeFile(srcFile, "local file"); + const saved = await store.saveMediaSource(srcFile); + expect(saved.size).toBe(10); + const savedStat = await fs.stat(saved.path); + expect(savedStat.isFile()).toBe(true); + expect(path.extname(saved.path)).toBe(".txt"); + + // make the file look old and ensure cleanOldMedia removes it + const past = Date.now() - 10_000; + await fs.utimes(saved.path, past / 1000, past / 1000); + await store.cleanOldMedia(1); + await expect(fs.stat(saved.path)).rejects.toThrow(); + }); }); it("sets correct mime for xlsx by extension", async () => { - const xlsxPath = path.join(HOME, "sheet.xlsx"); - await fs.mkdir(HOME, { recursive: true }); - await fs.writeFile(xlsxPath, "not really an xlsx"); + await withTempHome(async (home) => { + vi.resetModules(); + const store = await import("./store.js"); - const saved = await store.saveMediaSource(xlsxPath); - expect(saved.contentType).toBe( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ); - expect(path.extname(saved.path)).toBe(".xlsx"); + const xlsxPath = path.join(home, "sheet.xlsx"); + await fs.mkdir(home, { recursive: true }); + await fs.writeFile(xlsxPath, "not really an xlsx"); + + const saved = await store.saveMediaSource(xlsxPath); + expect(saved.contentType).toBe( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + expect(path.extname(saved.path)).toBe(".xlsx"); + }); }); it("renames media based on detected mime even when extension is wrong", async () => { - const pngBytes = await sharp({ - create: { width: 2, height: 2, channels: 3, background: "#00ff00" }, - }) - .png() - .toBuffer(); - const bogusExt = path.join(HOME, "image-wrong.bin"); - await fs.writeFile(bogusExt, pngBytes); + await withTempHome(async (home) => { + vi.resetModules(); + const store = await import("./store.js"); - const saved = await store.saveMediaSource(bogusExt); - expect(saved.contentType).toBe("image/png"); - expect(path.extname(saved.path)).toBe(".png"); + const pngBytes = await sharp({ + create: { width: 2, height: 2, channels: 3, background: "#00ff00" }, + }) + .png() + .toBuffer(); + const bogusExt = path.join(home, "image-wrong.bin"); + await fs.writeFile(bogusExt, pngBytes); - const buf = await fs.readFile(saved.path); - expect(buf.equals(pngBytes)).toBe(true); + const saved = await store.saveMediaSource(bogusExt); + expect(saved.contentType).toBe("image/png"); + expect(path.extname(saved.path)).toBe(".png"); + + const buf = await fs.readFile(saved.path); + expect(buf.equals(pngBytes)).toBe(true); + }); }); it("sniffs xlsx mime for zip buffers and renames extension", async () => { - const zip = new JSZip(); - zip.file( - "[Content_Types].xml", - '', - ); - zip.file("xl/workbook.xml", ""); - const fakeXlsx = await zip.generateAsync({ type: "nodebuffer" }); - const bogusExt = path.join(HOME, "sheet.bin"); - await fs.writeFile(bogusExt, fakeXlsx); + await withTempHome(async (home) => { + vi.resetModules(); + const store = await import("./store.js"); - const saved = await store.saveMediaSource(bogusExt); - expect(saved.contentType).toBe( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ); - expect(path.extname(saved.path)).toBe(".xlsx"); + const zip = new JSZip(); + zip.file( + "[Content_Types].xml", + '', + ); + zip.file("xl/workbook.xml", ""); + const fakeXlsx = await zip.generateAsync({ type: "nodebuffer" }); + const bogusExt = path.join(home, "sheet.bin"); + await fs.writeFile(bogusExt, fakeXlsx); + + const saved = await store.saveMediaSource(bogusExt); + expect(saved.contentType).toBe( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + expect(path.extname(saved.path)).toBe(".xlsx"); + }); }); }); diff --git a/src/web/logout.test.ts b/src/web/logout.test.ts index 3a7aea06a..7a9242cca 100644 --- a/src/web/logout.test.ts +++ b/src/web/logout.test.ts @@ -1,10 +1,10 @@ import fs from "node:fs"; -import fsPromises from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempHome } from "../../test/helpers/temp-home.js"; + const runtime = { log: vi.fn(), error: vi.fn(), @@ -12,84 +12,74 @@ const runtime = { }; describe("web logout", () => { - const origHomedir = os.homedir; - let tmpDir: string; - beforeEach(() => { vi.clearAllMocks(); - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-logout-")); - vi.spyOn(os, "homedir").mockReturnValue(tmpDir); - vi.resetModules(); - vi.doMock("../utils.js", async () => { - const actual = - await vi.importActual("../utils.js"); - return { - ...actual, - CONFIG_DIR: path.join(tmpDir, ".clawdbot"), - }; - }); }); - afterEach(async () => { + afterEach(() => { vi.restoreAllMocks(); - vi.doUnmock("../utils.js"); - await fsPromises - .rm(tmpDir, { recursive: true, force: true }) - .catch(() => {}); - // restore for safety - // eslint-disable-next-line @typescript-eslint/unbound-method - (os.homedir as unknown as typeof origHomedir) = origHomedir; }); it( "deletes cached credentials when present", { timeout: 15_000 }, async () => { - const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js"); + await withTempHome(async (home) => { + vi.resetModules(); + const { logoutWeb, WA_WEB_AUTH_DIR } = await import("./session.js"); - const normalizedAuthDir = path.resolve(WA_WEB_AUTH_DIR); - const normalizedHome = path.resolve(tmpDir); - if (process.platform === "win32") { - expect( - normalizedAuthDir - .toLowerCase() - .startsWith(normalizedHome.toLowerCase()), - ).toBe(true); - } else { - expect(normalizedAuthDir.startsWith(normalizedHome)).toBe(true); - } - fs.mkdirSync(WA_WEB_AUTH_DIR, { recursive: true }); - fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}"); - const result = await logoutWeb({ runtime: runtime as never }); + const rel = path.relative( + path.resolve(home), + path.resolve(WA_WEB_AUTH_DIR), + ); + expect(rel && !rel.startsWith("..") && !path.isAbsolute(rel)).toBe( + true, + ); - expect(result).toBe(true); - expect(fs.existsSync(WA_WEB_AUTH_DIR)).toBe(false); + fs.mkdirSync(WA_WEB_AUTH_DIR, { recursive: true }); + fs.writeFileSync(path.join(WA_WEB_AUTH_DIR, "creds.json"), "{}"); + const result = await logoutWeb({ runtime: runtime as never }); + + expect(result).toBe(true); + expect(fs.existsSync(WA_WEB_AUTH_DIR)).toBe(false); + }); }, ); it("no-ops when nothing to delete", { timeout: 15_000 }, async () => { - const { logoutWeb } = await import("./session.js"); - const result = await logoutWeb({ runtime: runtime as never }); - expect(result).toBe(false); - expect(runtime.log).toHaveBeenCalled(); + await withTempHome(async () => { + vi.resetModules(); + const { logoutWeb } = await import("./session.js"); + const result = await logoutWeb({ runtime: runtime as never }); + expect(result).toBe(false); + expect(runtime.log).toHaveBeenCalled(); + }); }); it("keeps shared oauth.json when using legacy auth dir", async () => { - const { logoutWeb } = await import("./session.js"); - const credsDir = path.join(tmpDir, ".clawdbot", "credentials"); - fs.mkdirSync(credsDir, { recursive: true }); - fs.writeFileSync(path.join(credsDir, "creds.json"), "{}"); - fs.writeFileSync(path.join(credsDir, "oauth.json"), '{"token":true}'); - fs.writeFileSync(path.join(credsDir, "session-abc.json"), "{}"); + await withTempHome(async () => { + vi.resetModules(); + const { logoutWeb } = await import("./session.js"); - const result = await logoutWeb({ - authDir: credsDir, - isLegacyAuthDir: true, - runtime: runtime as never, + const { resolveOAuthDir } = await import("../config/paths.js"); + const credsDir = resolveOAuthDir(); + + fs.mkdirSync(credsDir, { recursive: true }); + fs.writeFileSync(path.join(credsDir, "creds.json"), "{}"); + fs.writeFileSync(path.join(credsDir, "oauth.json"), '{"token":true}'); + fs.writeFileSync(path.join(credsDir, "session-abc.json"), "{}"); + + const result = await logoutWeb({ + authDir: credsDir, + isLegacyAuthDir: true, + runtime: runtime as never, + }); + expect(result).toBe(true); + expect(fs.existsSync(path.join(credsDir, "oauth.json"))).toBe(true); + expect(fs.existsSync(path.join(credsDir, "creds.json"))).toBe(false); + expect(fs.existsSync(path.join(credsDir, "session-abc.json"))).toBe( + false, + ); }); - expect(result).toBe(true); - expect(fs.existsSync(path.join(credsDir, "oauth.json"))).toBe(true); - expect(fs.existsSync(path.join(credsDir, "creds.json"))).toBe(false); - expect(fs.existsSync(path.join(credsDir, "session-abc.json"))).toBe(false); }); });