From 8b4696c087aed2256c74d106b58367406fb96a53 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Sun, 25 Jan 2026 15:24:02 +0800 Subject: [PATCH 01/40] fix(voice-call): validate provider credentials from env vars The `validateProviderConfig()` function now checks both config values AND environment variables when validating provider credentials. This aligns the validation behavior with `resolveProvider()` which already falls back to env vars. Previously, users who set credentials via environment variables would get validation errors even though the credentials would be found at runtime. The error messages correctly suggested env vars as an alternative, but the validation didn't actually check them. Affects all three supported providers: Twilio, Telnyx, and Plivo. Fixes #1709 Co-Authored-By: Claude --- extensions/voice-call/src/config.test.ts | 196 +++++++++++++++++++++++ extensions/voice-call/src/config.ts | 12 +- 2 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 extensions/voice-call/src/config.test.ts diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts new file mode 100644 index 000000000..3a4311c8a --- /dev/null +++ b/extensions/voice-call/src/config.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { validateProviderConfig, type VoiceCallConfig } from "./config.js"; + +function createBaseConfig( + provider: "telnyx" | "twilio" | "plivo" | "mock", +): VoiceCallConfig { + return { + enabled: true, + provider, + fromNumber: "+15550001234", + inboundPolicy: "disabled", + allowFrom: [], + outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 }, + maxDurationSeconds: 300, + silenceTimeoutMs: 800, + transcriptTimeoutMs: 180000, + ringTimeoutMs: 30000, + maxConcurrentCalls: 1, + serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" }, + tailscale: { mode: "off", path: "/voice/webhook" }, + tunnel: { provider: "none", allowNgrokFreeTier: true }, + streaming: { + enabled: false, + sttProvider: "openai-realtime", + sttModel: "gpt-4o-transcribe", + silenceDurationMs: 800, + vadThreshold: 0.5, + streamPath: "/voice/stream", + }, + skipSignatureVerification: false, + stt: { provider: "openai", model: "whisper-1" }, + tts: { provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" }, + responseModel: "openai/gpt-4o-mini", + responseTimeoutMs: 30000, + }; +} + +describe("validateProviderConfig", () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + // Clear all relevant env vars before each test + delete process.env.TWILIO_ACCOUNT_SID; + delete process.env.TWILIO_AUTH_TOKEN; + delete process.env.TELNYX_API_KEY; + delete process.env.TELNYX_CONNECTION_ID; + delete process.env.PLIVO_AUTH_ID; + delete process.env.PLIVO_AUTH_TOKEN; + }); + + afterEach(() => { + // Restore original env + process.env = { ...originalEnv }; + }); + + describe("twilio provider", () => { + it("passes validation when credentials are in config", () => { + const config = createBaseConfig("twilio"); + config.twilio = { accountSid: "AC123", authToken: "secret" }; + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("passes validation when credentials are in environment variables", () => { + process.env.TWILIO_ACCOUNT_SID = "AC123"; + process.env.TWILIO_AUTH_TOKEN = "secret"; + const config = createBaseConfig("twilio"); + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("passes validation with mixed config and env vars", () => { + process.env.TWILIO_AUTH_TOKEN = "secret"; + const config = createBaseConfig("twilio"); + config.twilio = { accountSid: "AC123" }; + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("fails validation when accountSid is missing everywhere", () => { + process.env.TWILIO_AUTH_TOKEN = "secret"; + const config = createBaseConfig("twilio"); + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(false); + expect(result.errors).toContain( + "plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)", + ); + }); + + it("fails validation when authToken is missing everywhere", () => { + process.env.TWILIO_ACCOUNT_SID = "AC123"; + const config = createBaseConfig("twilio"); + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(false); + expect(result.errors).toContain( + "plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)", + ); + }); + }); + + describe("telnyx provider", () => { + it("passes validation when credentials are in config", () => { + const config = createBaseConfig("telnyx"); + config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" }; + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("passes validation when credentials are in environment variables", () => { + process.env.TELNYX_API_KEY = "KEY123"; + process.env.TELNYX_CONNECTION_ID = "CONN456"; + const config = createBaseConfig("telnyx"); + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("fails validation when apiKey is missing everywhere", () => { + process.env.TELNYX_CONNECTION_ID = "CONN456"; + const config = createBaseConfig("telnyx"); + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(false); + expect(result.errors).toContain( + "plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)", + ); + }); + }); + + describe("plivo provider", () => { + it("passes validation when credentials are in config", () => { + const config = createBaseConfig("plivo"); + config.plivo = { authId: "MA123", authToken: "secret" }; + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("passes validation when credentials are in environment variables", () => { + process.env.PLIVO_AUTH_ID = "MA123"; + process.env.PLIVO_AUTH_TOKEN = "secret"; + const config = createBaseConfig("plivo"); + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("fails validation when authId is missing everywhere", () => { + process.env.PLIVO_AUTH_TOKEN = "secret"; + const config = createBaseConfig("plivo"); + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(false); + expect(result.errors).toContain( + "plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)", + ); + }); + }); + + describe("disabled config", () => { + it("skips validation when enabled is false", () => { + const config = createBaseConfig("twilio"); + config.enabled = false; + + const result = validateProviderConfig(config); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + }); +}); diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index 832e692ca..403a2eb89 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -352,12 +352,12 @@ export function validateProviderConfig(config: VoiceCallConfig): { } if (config.provider === "telnyx") { - if (!config.telnyx?.apiKey) { + if (!config.telnyx?.apiKey && !process.env.TELNYX_API_KEY) { errors.push( "plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)", ); } - if (!config.telnyx?.connectionId) { + if (!config.telnyx?.connectionId && !process.env.TELNYX_CONNECTION_ID) { errors.push( "plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)", ); @@ -365,12 +365,12 @@ export function validateProviderConfig(config: VoiceCallConfig): { } if (config.provider === "twilio") { - if (!config.twilio?.accountSid) { + if (!config.twilio?.accountSid && !process.env.TWILIO_ACCOUNT_SID) { errors.push( "plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)", ); } - if (!config.twilio?.authToken) { + if (!config.twilio?.authToken && !process.env.TWILIO_AUTH_TOKEN) { errors.push( "plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)", ); @@ -378,12 +378,12 @@ export function validateProviderConfig(config: VoiceCallConfig): { } if (config.provider === "plivo") { - if (!config.plivo?.authId) { + if (!config.plivo?.authId && !process.env.PLIVO_AUTH_ID) { errors.push( "plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)", ); } - if (!config.plivo?.authToken) { + if (!config.plivo?.authToken && !process.env.PLIVO_AUTH_TOKEN) { errors.push( "plugins.entries.voice-call.config.plivo.authToken is required (or set PLIVO_AUTH_TOKEN env)", ); From 592930f10f90a99926b9ba50ab74734c4e11e257 Mon Sep 17 00:00:00 2001 From: rhuanssauro Date: Sun, 25 Jan 2026 20:41:20 -0300 Subject: [PATCH 02/40] security: apply Agents Council recommendations - Add USER node directive to Dockerfile for non-root container execution - Update SECURITY.md with Node.js version requirements (CVE-2025-59466, CVE-2026-21636) - Add Docker security best practices documentation - Document detect-secrets usage for local security scanning Reviewed-by: Agents Council (5/5 approval) Security-Score: 8.8/10 Watchdog-Verdict: SAFE WITH CONDITIONS Co-Authored-By: Claude Sonnet 4.5 --- Dockerfile | 5 +++++ SECURITY.md | 45 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a33f0077d..642cfd612 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,4 +32,9 @@ RUN pnpm ui:build ENV NODE_ENV=production +# Security hardening: Run as non-root user +# The node:22-bookworm image includes a 'node' user (uid 1000) +# This reduces the attack surface by preventing container escape via root privileges +USER node + CMD ["node", "dist/index.js"] diff --git a/SECURITY.md b/SECURITY.md index 43d493996..11aa0b781 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy -If you believe you’ve found a security issue in Clawdbot, please report it privately. +If you believe you've found a security issue in Clawdbot, please report it privately. ## Reporting @@ -12,3 +12,46 @@ If you believe you’ve found a security issue in Clawdbot, please report it pri For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see: - `https://docs.clawd.bot/gateway/security` + +## Runtime Requirements + +### Node.js Version + +Clawdbot requires **Node.js 22.12.0 or later** (LTS). This version includes important security patches: + +- CVE-2025-59466: async_hooks DoS vulnerability +- CVE-2026-21636: Permission model bypass vulnerability + +Verify your Node.js version: + +```bash +node --version # Should be v22.12.0 or later +``` + +### Docker Security + +When running Clawdbot in Docker: + +1. The official image runs as a non-root user (`node`) for reduced attack surface +2. Use `--read-only` flag when possible for additional filesystem protection +3. Limit container capabilities with `--cap-drop=ALL` + +Example secure Docker run: + +```bash +docker run --read-only --cap-drop=ALL \ + -v clawdbot-data:/app/data \ + clawdbot/clawdbot:latest +``` + +## Security Scanning + +This project uses `detect-secrets` for automated secret detection in CI/CD. +See `.detect-secrets.cfg` for configuration and `.secrets.baseline` for the baseline. + +Run locally: + +```bash +pip install detect-secrets==1.5.0 +detect-secrets scan --baseline .secrets.baseline +``` From a187cd47f7177333fc2f28ceb7f42a0869d79d84 Mon Sep 17 00:00:00 2001 From: rhuanssauro Date: Sun, 25 Jan 2026 21:10:01 -0300 Subject: [PATCH 03/40] fix: downgrade @typescript/native-preview to published version - Update @typescript/native-preview from 7.0.0-dev.20260125.1 to 7.0.0-dev.20260124.1 (20260125.1 is not yet published to npm) - Update memory-core peerDependency to >=2026.1.24 to match latest published version - Fixes CI lockfile validation failures This resolves the pnpm frozen-lockfile errors in GitHub Actions. --- extensions/memory-core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index c70da1395..e9a682855 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -9,6 +9,6 @@ ] }, "peerDependencies": { - "clawdbot": ">=2026.1.25" + "clawdbot": ">=2026.1.24" } } From 4e9756a3e14f42d50371ed7aec49be76eb7bb085 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 13:52:22 +0000 Subject: [PATCH 04/40] fix: sync memory-core peer dep with lockfile --- CHANGELOG.md | 1 + extensions/memory-core/package.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3190914c..0c7e77e45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ Status: unreleased. ### Fixes - Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. +- Build: align memory-core peer dependency with lockfile. - Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie. - Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93. - Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0. diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index e9a682855..c70da1395 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -9,6 +9,6 @@ ] }, "peerDependencies": { - "clawdbot": ">=2026.1.24" + "clawdbot": ">=2026.1.25" } } From d37df28319da750ecfeb5416c0297b8a25e58ed2 Mon Sep 17 00:00:00 2001 From: Shakker Nerd Date: Mon, 26 Jan 2026 14:01:08 +0000 Subject: [PATCH 05/40] feat: Resolve voice call configuration by merging environment variables into settings. --- extensions/voice-call/index.ts | 16 ++++---- extensions/voice-call/src/config.ts | 58 +++++++++++++++++++++++++--- extensions/voice-call/src/runtime.ts | 27 +++++++------ 3 files changed, 74 insertions(+), 27 deletions(-) diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index 760726faa..60076bbe2 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -1,8 +1,8 @@ import { Type } from "@sinclair/typebox"; - import type { CoreConfig } from "./src/core-bridge.js"; import { VoiceCallConfigSchema, + resolveVoiceCallConfig, validateProviderConfig, type VoiceCallConfig, } from "./src/config.js"; @@ -145,8 +145,10 @@ const voiceCallPlugin = { description: "Voice-call plugin with Telnyx/Twilio/Plivo providers", configSchema: voiceCallConfigSchema, register(api) { - const cfg = voiceCallConfigSchema.parse(api.pluginConfig); - const validation = validateProviderConfig(cfg); + const config = resolveVoiceCallConfig( + voiceCallConfigSchema.parse(api.pluginConfig), + ); + const validation = validateProviderConfig(config); if (api.pluginConfig && typeof api.pluginConfig === "object") { const raw = api.pluginConfig as Record; @@ -167,7 +169,7 @@ const voiceCallPlugin = { let runtime: VoiceCallRuntime | null = null; const ensureRuntime = async () => { - if (!cfg.enabled) { + if (!config.enabled) { throw new Error("Voice call disabled in plugin config"); } if (!validation.valid) { @@ -176,7 +178,7 @@ const voiceCallPlugin = { if (runtime) return runtime; if (!runtimePromise) { runtimePromise = createVoiceCallRuntime({ - config: cfg, + config, coreConfig: api.config as CoreConfig, ttsRuntime: api.runtime.tts, logger: api.logger, @@ -457,7 +459,7 @@ const voiceCallPlugin = { ({ program }) => registerVoiceCallCli({ program, - config: cfg, + config, ensureRuntime, logger: api.logger, }), @@ -467,7 +469,7 @@ const voiceCallPlugin = { api.registerService({ id: "voicecall", start: async () => { - if (!cfg.enabled) return; + if (!config.enabled) return; try { await ensureRuntime(); } catch (err) { diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index 1a3a9bbbd..6d6036792 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -381,6 +381,52 @@ export type VoiceCallConfig = z.infer; // Configuration Helpers // ----------------------------------------------------------------------------- +/** + * Resolves the configuration by merging environment variables into missing fields. + * Returns a new configuration object with environment variables applied. + */ +export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig { + const resolved = JSON.parse(JSON.stringify(config)) as VoiceCallConfig; + + // Telnyx + if (resolved.provider === "telnyx") { + resolved.telnyx = resolved.telnyx ?? {}; + resolved.telnyx.apiKey = + resolved.telnyx.apiKey ?? process.env.TELNYX_API_KEY; + resolved.telnyx.connectionId = + resolved.telnyx.connectionId ?? process.env.TELNYX_CONNECTION_ID; + resolved.telnyx.publicKey = + resolved.telnyx.publicKey ?? process.env.TELNYX_PUBLIC_KEY; + } + + // Twilio + if (resolved.provider === "twilio") { + resolved.twilio = resolved.twilio ?? {}; + resolved.twilio.accountSid = + resolved.twilio.accountSid ?? process.env.TWILIO_ACCOUNT_SID; + resolved.twilio.authToken = + resolved.twilio.authToken ?? process.env.TWILIO_AUTH_TOKEN; + } + + // Plivo + if (resolved.provider === "plivo") { + resolved.plivo = resolved.plivo ?? {}; + resolved.plivo.authId = + resolved.plivo.authId ?? process.env.PLIVO_AUTH_ID; + resolved.plivo.authToken = + resolved.plivo.authToken ?? process.env.PLIVO_AUTH_TOKEN; + } + + // Tunnel Config + resolved.tunnel = resolved.tunnel ?? { provider: "none", allowNgrokFreeTier: true }; + resolved.tunnel.ngrokAuthToken = + resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN; + resolved.tunnel.ngrokDomain = + resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN; + + return resolved; +} + /** * Validate that the configuration has all required fields for the selected provider. */ @@ -403,12 +449,12 @@ export function validateProviderConfig(config: VoiceCallConfig): { } if (config.provider === "telnyx") { - if (!config.telnyx?.apiKey && !process.env.TELNYX_API_KEY) { + if (!config.telnyx?.apiKey) { errors.push( "plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)", ); } - if (!config.telnyx?.connectionId && !process.env.TELNYX_CONNECTION_ID) { + if (!config.telnyx?.connectionId) { errors.push( "plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)", ); @@ -416,12 +462,12 @@ export function validateProviderConfig(config: VoiceCallConfig): { } if (config.provider === "twilio") { - if (!config.twilio?.accountSid && !process.env.TWILIO_ACCOUNT_SID) { + if (!config.twilio?.accountSid) { errors.push( "plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)", ); } - if (!config.twilio?.authToken && !process.env.TWILIO_AUTH_TOKEN) { + if (!config.twilio?.authToken) { errors.push( "plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)", ); @@ -429,12 +475,12 @@ export function validateProviderConfig(config: VoiceCallConfig): { } if (config.provider === "plivo") { - if (!config.plivo?.authId && !process.env.PLIVO_AUTH_ID) { + if (!config.plivo?.authId) { errors.push( "plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)", ); } - if (!config.plivo?.authToken && !process.env.PLIVO_AUTH_TOKEN) { + if (!config.plivo?.authToken) { errors.push( "plugins.entries.voice-call.config.plivo.authToken is required (or set PLIVO_AUTH_TOKEN env)", ); diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index 0770333cd..a2eb15315 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -1,6 +1,6 @@ import type { CoreConfig } from "./core-bridge.js"; import type { VoiceCallConfig } from "./config.js"; -import { validateProviderConfig } from "./config.js"; +import { resolveVoiceCallConfig, validateProviderConfig } from "./config.js"; import { CallManager } from "./manager.js"; import type { VoiceCallProvider } from "./providers/base.js"; import { MockProvider } from "./providers/mock.js"; @@ -37,17 +37,15 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider { switch (config.provider) { case "telnyx": return new TelnyxProvider({ - apiKey: config.telnyx?.apiKey ?? process.env.TELNYX_API_KEY, - connectionId: - config.telnyx?.connectionId ?? process.env.TELNYX_CONNECTION_ID, - publicKey: config.telnyx?.publicKey ?? process.env.TELNYX_PUBLIC_KEY, + apiKey: config.telnyx?.apiKey, + connectionId: config.telnyx?.connectionId, + publicKey: config.telnyx?.publicKey, }); case "twilio": return new TwilioProvider( { - accountSid: - config.twilio?.accountSid ?? process.env.TWILIO_ACCOUNT_SID, - authToken: config.twilio?.authToken ?? process.env.TWILIO_AUTH_TOKEN, + accountSid: config.twilio?.accountSid, + authToken: config.twilio?.authToken, }, { allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? true, @@ -61,8 +59,8 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider { case "plivo": return new PlivoProvider( { - authId: config.plivo?.authId ?? process.env.PLIVO_AUTH_ID, - authToken: config.plivo?.authToken ?? process.env.PLIVO_AUTH_TOKEN, + authId: config.plivo?.authId, + authToken: config.plivo?.authToken, }, { publicUrl: config.publicUrl, @@ -85,7 +83,7 @@ export async function createVoiceCallRuntime(params: { ttsRuntime?: TelephonyTtsRuntime; logger?: Logger; }): Promise { - const { config, coreConfig, ttsRuntime, logger } = params; + const { config: rawConfig, coreConfig, ttsRuntime, logger } = params; const log = logger ?? { info: console.log, warn: console.warn, @@ -93,6 +91,8 @@ export async function createVoiceCallRuntime(params: { debug: console.debug, }; + const config = resolveVoiceCallConfig(rawConfig); + if (!config.enabled) { throw new Error( "Voice call disabled. Enable the plugin entry in config.", @@ -125,9 +125,8 @@ export async function createVoiceCallRuntime(params: { provider: config.tunnel.provider, port: config.serve.port, path: config.serve.path, - ngrokAuthToken: - config.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN, - ngrokDomain: config.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN, + ngrokAuthToken: config.tunnel.ngrokAuthToken, + ngrokDomain: config.tunnel.ngrokDomain, }); publicUrl = tunnelResult?.publicUrl ?? null; } catch (err) { From 6918fbc0bdce20b0f1ccfcf4a98b08b2856a5034 Mon Sep 17 00:00:00 2001 From: Shakker Nerd Date: Mon, 26 Jan 2026 14:11:45 +0000 Subject: [PATCH 06/40] test: incorporate `resolveVoiceCallConfig` into config validation tests. --- extensions/voice-call/src/config.test.ts | 26 ++++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts index 3a4311c8a..7334498e2 100644 --- a/extensions/voice-call/src/config.test.ts +++ b/extensions/voice-call/src/config.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { validateProviderConfig, type VoiceCallConfig } from "./config.js"; +import { validateProviderConfig, resolveVoiceCallConfig, type VoiceCallConfig } from "./config.js"; function createBaseConfig( provider: "telnyx" | "twilio" | "plivo" | "mock", @@ -68,7 +68,8 @@ describe("validateProviderConfig", () => { it("passes validation when credentials are in environment variables", () => { process.env.TWILIO_ACCOUNT_SID = "AC123"; process.env.TWILIO_AUTH_TOKEN = "secret"; - const config = createBaseConfig("twilio"); + let config = createBaseConfig("twilio"); + config = resolveVoiceCallConfig(config); const result = validateProviderConfig(config); @@ -78,8 +79,9 @@ describe("validateProviderConfig", () => { it("passes validation with mixed config and env vars", () => { process.env.TWILIO_AUTH_TOKEN = "secret"; - const config = createBaseConfig("twilio"); + let config = createBaseConfig("twilio"); config.twilio = { accountSid: "AC123" }; + config = resolveVoiceCallConfig(config); const result = validateProviderConfig(config); @@ -89,7 +91,8 @@ describe("validateProviderConfig", () => { it("fails validation when accountSid is missing everywhere", () => { process.env.TWILIO_AUTH_TOKEN = "secret"; - const config = createBaseConfig("twilio"); + let config = createBaseConfig("twilio"); + config = resolveVoiceCallConfig(config); const result = validateProviderConfig(config); @@ -101,7 +104,8 @@ describe("validateProviderConfig", () => { it("fails validation when authToken is missing everywhere", () => { process.env.TWILIO_ACCOUNT_SID = "AC123"; - const config = createBaseConfig("twilio"); + let config = createBaseConfig("twilio"); + config = resolveVoiceCallConfig(config); const result = validateProviderConfig(config); @@ -126,7 +130,8 @@ describe("validateProviderConfig", () => { it("passes validation when credentials are in environment variables", () => { process.env.TELNYX_API_KEY = "KEY123"; process.env.TELNYX_CONNECTION_ID = "CONN456"; - const config = createBaseConfig("telnyx"); + let config = createBaseConfig("telnyx"); + config = resolveVoiceCallConfig(config); const result = validateProviderConfig(config); @@ -136,7 +141,8 @@ describe("validateProviderConfig", () => { it("fails validation when apiKey is missing everywhere", () => { process.env.TELNYX_CONNECTION_ID = "CONN456"; - const config = createBaseConfig("telnyx"); + let config = createBaseConfig("telnyx"); + config = resolveVoiceCallConfig(config); const result = validateProviderConfig(config); @@ -161,7 +167,8 @@ describe("validateProviderConfig", () => { it("passes validation when credentials are in environment variables", () => { process.env.PLIVO_AUTH_ID = "MA123"; process.env.PLIVO_AUTH_TOKEN = "secret"; - const config = createBaseConfig("plivo"); + let config = createBaseConfig("plivo"); + config = resolveVoiceCallConfig(config); const result = validateProviderConfig(config); @@ -171,7 +178,8 @@ describe("validateProviderConfig", () => { it("fails validation when authId is missing everywhere", () => { process.env.PLIVO_AUTH_TOKEN = "secret"; - const config = createBaseConfig("plivo"); + let config = createBaseConfig("plivo"); + config = resolveVoiceCallConfig(config); const result = validateProviderConfig(config); From f3e3c4573bdec6c063245e0867200cf6ef500654 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 26 Jan 2026 08:50:01 -0600 Subject: [PATCH 07/40] Docs: add LINE channel guide --- .github/labeler.yml | 1 + CHANGELOG.md | 1 + docs/channels/index.md | 1 + docs/channels/line.md | 183 +++++++++++++++++++++++++++++++++++++++++ docs/docs.json | 17 ++++ 5 files changed, 203 insertions(+) create mode 100644 docs/channels/line.md diff --git a/.github/labeler.yml b/.github/labeler.yml index 5d2837a6c..6e4f74306 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -24,6 +24,7 @@ - changed-files: - any-glob-to-any-file: - "extensions/line/**" + - "docs/channels/line.md" "channel: matrix": - changed-files: - any-glob-to-any-file: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c7e77e45..a1057175a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Status: unreleased. - Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto. - Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto. - Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev. +- Docs: add LINE channel guide. - Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD. - Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub. - Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a. diff --git a/docs/channels/index.md b/docs/channels/index.md index 52e963b87..a67c5ac1e 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -21,6 +21,7 @@ Text is supported everywhere; media and reactions vary by channel. - [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe). - [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups). - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). +- [LINE](/channels/line) — LINE Messaging API bot (plugin, installed separately). - [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). - [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately). diff --git a/docs/channels/line.md b/docs/channels/line.md new file mode 100644 index 000000000..40ed2f9f6 --- /dev/null +++ b/docs/channels/line.md @@ -0,0 +1,183 @@ +--- +summary: "LINE Messaging API plugin setup, config, and usage" +read_when: + - You want to connect Clawdbot to LINE + - You need LINE webhook + credential setup + - You want LINE-specific message options +--- + +# LINE (plugin) + +LINE connects to Clawdbot via the LINE Messaging API. The plugin runs as a webhook +receiver on the gateway and uses your channel access token + channel secret for +authentication. + +Status: supported via plugin. Direct messages, group chats, media, locations, Flex +messages, template messages, and quick replies are supported. Reactions and threads +are not supported. + +## Plugin required + +Install the LINE plugin: + +```bash +clawdbot plugins install @clawdbot/line +``` + +Local checkout (when running from a git repo): + +```bash +clawdbot plugins install ./extensions/line +``` + +## Setup + +1) Create a LINE Developers account and open the Console: + https://developers.line.biz/console/ +2) Create (or pick) a Provider and add a **Messaging API** channel. +3) Copy the **Channel access token** and **Channel secret** from the channel settings. +4) Enable **Use webhook** in the Messaging API settings. +5) Set the webhook URL to your gateway endpoint (HTTPS required): + +``` +https://gateway-host/line/webhook +``` + +The gateway responds to LINE’s webhook verification (GET) and inbound events (POST). +If you need a custom path, set `channels.line.webhookPath` or +`channels.line.accounts..webhookPath` and update the URL accordingly. + +## Configure + +Minimal config: + +```json5 +{ + channels: { + line: { + enabled: true, + channelAccessToken: "LINE_CHANNEL_ACCESS_TOKEN", + channelSecret: "LINE_CHANNEL_SECRET", + dmPolicy: "pairing" + } + } +} +``` + +Env vars (default account only): + +- `LINE_CHANNEL_ACCESS_TOKEN` +- `LINE_CHANNEL_SECRET` + +Token/secret files: + +```json5 +{ + channels: { + line: { + tokenFile: "/path/to/line-token.txt", + secretFile: "/path/to/line-secret.txt" + } + } +} +``` + +Multiple accounts: + +```json5 +{ + channels: { + line: { + accounts: { + marketing: { + channelAccessToken: "...", + channelSecret: "...", + webhookPath: "/line/marketing" + } + } + } + } +} +``` + +## Access control + +Direct messages default to pairing. Unknown senders get a pairing code and their +messages are ignored until approved. + +```bash +clawdbot pairing list line +clawdbot pairing approve line +``` + +Allowlists and policies: + +- `channels.line.dmPolicy`: `pairing | allowlist | open | disabled` +- `channels.line.allowFrom`: allowlisted LINE user IDs for DMs +- `channels.line.groupPolicy`: `allowlist | open | disabled` +- `channels.line.groupAllowFrom`: allowlisted LINE user IDs for groups +- Per-group overrides: `channels.line.groups..allowFrom` + +LINE IDs are case-sensitive. Valid IDs look like: + +- User: `U` + 32 hex chars +- Group: `C` + 32 hex chars +- Room: `R` + 32 hex chars + +## Message behavior + +- Text is chunked at 5000 characters. +- Markdown formatting is stripped; code blocks and tables are converted into Flex + cards when possible. +- Streaming responses are buffered; LINE receives full chunks with a loading + animation while the agent works. +- Media downloads are capped by `channels.line.mediaMaxMb` (default 10). + +## Channel data (rich messages) + +Use `channelData.line` to send quick replies, locations, Flex cards, or template +messages. + +```json5 +{ + text: "Here you go", + channelData: { + line: { + quickReplies: ["Status", "Help"], + location: { + title: "Office", + address: "123 Main St", + latitude: 35.681236, + longitude: 139.767125 + }, + flexMessage: { + altText: "Status card", + contents: { /* Flex payload */ } + }, + templateMessage: { + type: "confirm", + text: "Proceed?", + confirmLabel: "Yes", + confirmData: "yes", + cancelLabel: "No", + cancelData: "no" + } + } + } +} +``` + +The LINE plugin also ships a `/card` command for Flex message presets: + +``` +/card info "Welcome" "Thanks for joining!" +``` + +## Troubleshooting + +- **Webhook verification fails:** ensure the webhook URL is HTTPS and the + `channelSecret` matches the LINE console. +- **No inbound events:** confirm the webhook path matches `channels.line.webhookPath` + and that the gateway is reachable from LINE. +- **Media download errors:** raise `channels.line.mediaMaxMb` if media exceeds the + default limit. diff --git a/docs/docs.json b/docs/docs.json index b0f0ee802..2cc5ae78b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -117,6 +117,14 @@ "source": "/mattermost/", "destination": "/channels/mattermost" }, + { + "source": "/line", + "destination": "/channels/line" + }, + { + "source": "/line/", + "destination": "/channels/line" + }, { "source": "/glm", "destination": "/providers/glm" @@ -197,6 +205,14 @@ "source": "/providers/msteams/", "destination": "/channels/msteams" }, + { + "source": "/providers/line", + "destination": "/channels/line" + }, + { + "source": "/providers/line/", + "destination": "/channels/line" + }, { "source": "/providers/signal", "destination": "/channels/signal" @@ -974,6 +990,7 @@ "channels/signal", "channels/imessage", "channels/msteams", + "channels/line", "channels/matrix", "channels/zalo", "channels/zalouser", From 961b4adc1cae08a8ff1c1ad7aa649ea21755bc33 Mon Sep 17 00:00:00 2001 From: Yuri Chukhlib Date: Mon, 26 Jan 2026 15:51:25 +0100 Subject: [PATCH 08/40] feat(gateway): deprecate query param hook token auth for security (#2200) * feat(gateway): deprecate query param hook token auth for security Query parameter tokens appear in: - Server access logs - Browser history - Referrer headers - Network monitoring tools This change adds a deprecation warning when tokens are provided via query parameter, encouraging migration to header-based authentication (Authorization: Bearer or X-Clawdbot-Token header). Changes: - Modified extractHookToken to return { token, fromQuery } object - Added deprecation warning in server-http.ts when fromQuery is true - Updated tests to verify the new return type and fromQuery flag Fixes #2148 Co-Authored-By: Claude * fix: deprecate hook query token auth (#2200) (thanks @YuriNachos) --------- Co-authored-by: Claude Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + docs/automation/webhook.md | 8 ++++---- src/gateway/hooks.test.ts | 12 +++++++++--- src/gateway/hooks.ts | 15 ++++++++++----- src/gateway/server-http.ts | 9 ++++++++- 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1057175a..629f46908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot Status: unreleased. ### Changes +- Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos. - Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. - Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng. - Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr. diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md index 4fbf6bf50..12fc6b92a 100644 --- a/docs/automation/webhook.md +++ b/docs/automation/webhook.md @@ -27,10 +27,10 @@ Notes: ## Auth -Every request must include the hook token: -- `Authorization: Bearer ` -- or `x-clawdbot-token: ` -- or `?token=` +Every request must include the hook token. Prefer headers: +- `Authorization: Bearer ` (recommended) +- `x-clawdbot-token: ` +- `?token=` (deprecated; logs a warning and will be removed in a future major release) ## Endpoints diff --git a/src/gateway/hooks.test.ts b/src/gateway/hooks.test.ts index 5a3c5e79e..447e91bdb 100644 --- a/src/gateway/hooks.test.ts +++ b/src/gateway/hooks.test.ts @@ -47,15 +47,21 @@ describe("gateway hooks helpers", () => { }, } as unknown as IncomingMessage; const url = new URL("http://localhost/hooks/wake?token=query"); - expect(extractHookToken(req, url)).toBe("top"); + const result1 = extractHookToken(req, url); + expect(result1.token).toBe("top"); + expect(result1.fromQuery).toBe(false); const req2 = { headers: { "x-clawdbot-token": "header" }, } as unknown as IncomingMessage; - expect(extractHookToken(req2, url)).toBe("header"); + const result2 = extractHookToken(req2, url); + expect(result2.token).toBe("header"); + expect(result2.fromQuery).toBe(false); const req3 = { headers: {} } as unknown as IncomingMessage; - expect(extractHookToken(req3, url)).toBe("query"); + const result3 = extractHookToken(req3, url); + expect(result3.token).toBe("query"); + expect(result3.fromQuery).toBe(true); }); test("normalizeWakePayload trims + validates", () => { diff --git a/src/gateway/hooks.ts b/src/gateway/hooks.ts index 6065d121d..31265c341 100644 --- a/src/gateway/hooks.ts +++ b/src/gateway/hooks.ts @@ -41,21 +41,26 @@ export function resolveHooksConfig(cfg: ClawdbotConfig): HooksConfigResolved | n }; } -export function extractHookToken(req: IncomingMessage, url: URL): string | undefined { +export type HookTokenResult = { + token: string | undefined; + fromQuery: boolean; +}; + +export function extractHookToken(req: IncomingMessage, url: URL): HookTokenResult { const auth = typeof req.headers.authorization === "string" ? req.headers.authorization.trim() : ""; if (auth.toLowerCase().startsWith("bearer ")) { const token = auth.slice(7).trim(); - if (token) return token; + if (token) return { token, fromQuery: false }; } const headerToken = typeof req.headers["x-clawdbot-token"] === "string" ? req.headers["x-clawdbot-token"].trim() : ""; - if (headerToken) return headerToken; + if (headerToken) return { token: headerToken, fromQuery: false }; const queryToken = url.searchParams.get("token"); - if (queryToken) return queryToken.trim(); - return undefined; + if (queryToken) return { token: queryToken.trim(), fromQuery: true }; + return { token: undefined, fromQuery: false }; } export async function readJsonBody( diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 136ec6229..08415f346 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -76,13 +76,20 @@ export function createHooksRequestHandler( return false; } - const token = extractHookToken(req, url); + const { token, fromQuery } = extractHookToken(req, url); if (!token || token !== hooksConfig.token) { res.statusCode = 401; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Unauthorized"); return true; } + if (fromQuery) { + logHooks.warn( + "Hook token provided via query parameter is deprecated for security reasons. " + + "Tokens in URLs appear in logs, browser history, and referrer headers. " + + "Use Authorization: Bearer or X-Clawdbot-Token header instead.", + ); + } if (req.method !== "POST") { res.statusCode = 405; From 300cda5d7dee86de62a64fef25ed4d123a94e217 Mon Sep 17 00:00:00 2001 From: Yuri Chukhlib Date: Mon, 26 Jan 2026 16:05:06 +0100 Subject: [PATCH 09/40] fix: wrap telegram reasoning italics per line (#2181) Landed PR #2181. Thanks @YuriNachos! Co-authored-by: YuriNachos --- CHANGELOG.md | 1 + src/agents/pi-embedded-utils.test.ts | 40 +++++++++++++++++++++++++++- src/agents/pi-embedded-utils.ts | 8 +++++- src/infra/tailscale.test.ts | 2 +- 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 629f46908..2f0d77860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ Status: unreleased. - macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal. ### Fixes +- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. - Build: align memory-core peer dependency with lockfile. - Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie. diff --git a/src/agents/pi-embedded-utils.test.ts b/src/agents/pi-embedded-utils.test.ts index c765a4d3a..cca7f8cb4 100644 --- a/src/agents/pi-embedded-utils.test.ts +++ b/src/agents/pi-embedded-utils.test.ts @@ -1,6 +1,6 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; -import { extractAssistantText } from "./pi-embedded-utils.js"; +import { extractAssistantText, formatReasoningMessage } from "./pi-embedded-utils.js"; describe("extractAssistantText", () => { it("strips Minimax tool invocation XML from text", () => { @@ -508,3 +508,41 @@ File contents here`, expect(result).toBe("StartMiddleEnd"); }); }); + +describe("formatReasoningMessage", () => { + it("returns empty string for empty input", () => { + expect(formatReasoningMessage("")).toBe(""); + }); + + it("returns empty string for whitespace-only input", () => { + expect(formatReasoningMessage(" \n \t ")).toBe(""); + }); + + it("wraps single line in italics", () => { + expect(formatReasoningMessage("Single line of reasoning")).toBe( + "Reasoning:\n_Single line of reasoning_", + ); + }); + + it("wraps each line separately for multiline text (Telegram fix)", () => { + expect(formatReasoningMessage("Line one\nLine two\nLine three")).toBe( + "Reasoning:\n_Line one_\n_Line two_\n_Line three_", + ); + }); + + it("preserves empty lines between reasoning text", () => { + expect(formatReasoningMessage("First block\n\nSecond block")).toBe( + "Reasoning:\n_First block_\n\n_Second block_", + ); + }); + + it("handles mixed empty and non-empty lines", () => { + expect(formatReasoningMessage("A\n\nB\nC")).toBe("Reasoning:\n_A_\n\n_B_\n_C_"); + }); + + it("trims leading/trailing whitespace", () => { + expect(formatReasoningMessage(" \n Reasoning here \n ")).toBe( + "Reasoning:\n_Reasoning here_", + ); + }); +}); diff --git a/src/agents/pi-embedded-utils.ts b/src/agents/pi-embedded-utils.ts index 89a9df805..969b0a316 100644 --- a/src/agents/pi-embedded-utils.ts +++ b/src/agents/pi-embedded-utils.ts @@ -211,7 +211,13 @@ export function formatReasoningMessage(text: string): string { if (!trimmed) return ""; // Show reasoning in italics (cursive) for markdown-friendly surfaces (Discord, etc.). // Keep the plain "Reasoning:" prefix so existing parsing/detection keeps working. - return `Reasoning:\n_${trimmed}_`; + // Note: Underscore markdown cannot span multiple lines on Telegram, so we wrap + // each non-empty line separately. + const italicLines = trimmed + .split("\n") + .map((line) => (line ? `_${line}_` : line)) + .join("\n"); + return `Reasoning:\n${italicLines}`; } type ThinkTaggedSplitBlock = diff --git a/src/infra/tailscale.test.ts b/src/infra/tailscale.test.ts index 44429b8aa..cc31c3ca9 100644 --- a/src/infra/tailscale.test.ts +++ b/src/infra/tailscale.test.ts @@ -10,7 +10,7 @@ const { disableTailscaleServe, ensureFunnel, } = tailscale; -const tailscaleBin = expect.stringMatching(/tailscale$/); +const tailscaleBin = expect.stringMatching(/tailscale$/i); describe("tailscale helpers", () => { afterEach(() => { From ded366d9aba1c2c9871a81945b029c727645f4da Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 14:54:54 +0000 Subject: [PATCH 10/40] docs: expand security guidance for prompt injection and browser control --- docs/gateway/security.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/gateway/security.md b/docs/gateway/security.md index ce542951d..f5526ca73 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -193,10 +193,17 @@ Prompt injection is when an attacker crafts a message that manipulates the model Even with strong system prompts, **prompt injection is not solved**. What helps in practice: - Keep inbound DMs locked down (pairing/allowlists). - Prefer mention gating in groups; avoid “always-on” bots in public rooms. -- Treat links and pasted instructions as hostile by default. +- Treat links, attachments, and pasted instructions as hostile by default. - Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem. +- Limit high-risk tools (`exec`, `browser`, `web_fetch`, `web_search`) to trusted agents or explicit allowlists. - **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)). +Red flags to treat as untrusted: +- “Read this file/URL and do exactly what it says.” +- “Ignore your system prompt or safety rules.” +- “Reveal your hidden instructions or tool outputs.” +- “Paste the full contents of ~/.clawdbot or your logs.” + ### Prompt injection does not require public DMs Even if **only you** can message the bot, prompt injection can still happen via @@ -210,6 +217,7 @@ tool calls. Reduce the blast radius by: then pass the summary to your main agent. - Keeping `web_search` / `web_fetch` / `browser` off for tool-enabled agents unless needed. - Enabling sandboxing and strict tool allowlists for any agent that touches untrusted input. +- Keeping secrets out of prompts; pass them via env/config on the gateway host instead. ### Model strength (security note) @@ -226,8 +234,12 @@ Recommendations: `/reasoning` and `/verbose` can expose internal reasoning or tool output that was not meant for a public channel. In group settings, treat them as **debug -only** and keep them off unless you explicitly need them. If you enable them, -do so only in trusted DMs or tightly controlled rooms. +only** and keep them off unless you explicitly need them. + +Guidance: +- Keep `/reasoning` and `/verbose` disabled in public rooms. +- If you enable them, do so only in trusted DMs or tightly controlled rooms. +- Remember: verbose output can include tool args, URLs, and data the model saw. ## Incident Response (if you suspect compromise) @@ -544,6 +556,7 @@ access those accounts and data. Treat browser profiles as **sensitive state**: - For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach. - Treat `browser.controlUrl` endpoints as an admin API: tailnet-only + token auth. Prefer Tailscale Serve over LAN binds. - Keep `browser.controlToken` separate from `gateway.auth.token` (you can reuse it, but that increases blast radius). +- Prefer env vars for the token (`CLAWDBOT_BROWSER_CONTROL_TOKEN`) instead of storing it in config on disk. - Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach. ## Per-agent access profiles (multi-agent) From 403c397ff5ce7a58d4a481319430420fcd155d14 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 26 Jan 2026 09:36:46 -0600 Subject: [PATCH 11/40] Docs: add cli/security labels --- .github/labeler.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/labeler.yml b/.github/labeler.yml index 6e4f74306..f22868736 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -133,6 +133,17 @@ - "docs/**" - "docs.acp.md" +"cli": + - changed-files: + - any-glob-to-any-file: + - "src/cli/**" + +"security": + - changed-files: + - any-glob-to-any-file: + - "docs/cli/security.md" + - "docs/gateway/security.md" + "extensions: copilot-proxy": - changed-files: - any-glob-to-any-file: From 8b68cdd9bc5d6122010d87e5c878f90971b331c7 Mon Sep 17 00:00:00 2001 From: Alex Alaniz Date: Mon, 26 Jan 2026 10:44:17 -0500 Subject: [PATCH 12/40] fix: harden doctor gateway exposure warnings (#2016) (thanks @Alex-Alaniz) (#2016) Co-authored-by: Peter Steinberger --- src/commands/doctor-security.test.ts | 71 ++++++++++++++++++++++++++ src/commands/doctor-security.ts | 75 +++++++++++++++------------- 2 files changed, 112 insertions(+), 34 deletions(-) create mode 100644 src/commands/doctor-security.test.ts diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts new file mode 100644 index 000000000..460b2b1fe --- /dev/null +++ b/src/commands/doctor-security.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../config/config.js"; + +const note = vi.hoisted(() => vi.fn()); + +vi.mock("../terminal/note.js", () => ({ + note, +})); + +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins: () => [], +})); + +import { noteSecurityWarnings } from "./doctor-security.js"; + +describe("noteSecurityWarnings gateway exposure", () => { + let prevToken: string | undefined; + let prevPassword: string | undefined; + + beforeEach(() => { + note.mockClear(); + prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; + prevPassword = process.env.CLAWDBOT_GATEWAY_PASSWORD; + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + delete process.env.CLAWDBOT_GATEWAY_PASSWORD; + }); + + afterEach(() => { + if (prevToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN; + else process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; + if (prevPassword === undefined) delete process.env.CLAWDBOT_GATEWAY_PASSWORD; + else process.env.CLAWDBOT_GATEWAY_PASSWORD = prevPassword; + }); + + const lastMessage = () => String(note.mock.calls.at(-1)?.[0] ?? ""); + + it("warns when exposed without auth", async () => { + const cfg = { gateway: { bind: "lan" } } as ClawdbotConfig; + await noteSecurityWarnings(cfg); + const message = lastMessage(); + expect(message).toContain("CRITICAL"); + expect(message).toContain("without authentication"); + }); + + it("uses env token to avoid critical warning", async () => { + process.env.CLAWDBOT_GATEWAY_TOKEN = "token-123"; + const cfg = { gateway: { bind: "lan" } } as ClawdbotConfig; + await noteSecurityWarnings(cfg); + const message = lastMessage(); + expect(message).toContain("WARNING"); + expect(message).not.toContain("CRITICAL"); + }); + + it("treats whitespace token as missing", async () => { + const cfg = { + gateway: { bind: "lan", auth: { mode: "token", token: " " } }, + } as ClawdbotConfig; + await noteSecurityWarnings(cfg); + const message = lastMessage(); + expect(message).toContain("CRITICAL"); + }); + + it("skips warning for loopback bind", async () => { + const cfg = { gateway: { bind: "loopback" } } as ClawdbotConfig; + await noteSecurityWarnings(cfg); + const message = lastMessage(); + expect(message).toContain("No channel security warnings detected"); + expect(message).not.toContain("Gateway bound"); + }); +}); diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 483917faa..620a7fd7d 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -1,10 +1,12 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; -import type { ClawdbotConfig } from "../config/config.js"; +import type { ClawdbotConfig, GatewayBindMode } from "../config/config.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { note } from "../terminal/note.js"; import { formatCliCommand } from "../cli/command-format.js"; +import { resolveGatewayAuth } from "../gateway/auth.js"; +import { isLoopbackHost, resolveGatewayBindHost } from "../gateway/net.js"; export async function noteSecurityWarnings(cfg: ClawdbotConfig) { const warnings: string[] = []; @@ -16,50 +18,55 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) { // Check for dangerous gateway binding configurations // that expose the gateway to network without proper auth - const gatewayBind = cfg.gateway?.bind ?? "loopback"; + const gatewayBind = (cfg.gateway?.bind ?? "loopback") as string; const customBindHost = cfg.gateway?.customBindHost?.trim(); - const authMode = cfg.gateway?.auth?.mode ?? "off"; - const authToken = cfg.gateway?.auth?.token; - const authPassword = cfg.gateway?.auth?.password; + const bindModes: GatewayBindMode[] = ["auto", "lan", "loopback", "custom", "tailnet"]; + const bindMode = bindModes.includes(gatewayBind as GatewayBindMode) + ? (gatewayBind as GatewayBindMode) + : undefined; + const resolvedBindHost = bindMode + ? await resolveGatewayBindHost(bindMode, customBindHost) + : "0.0.0.0"; + const isExposed = !isLoopbackHost(resolvedBindHost); - const isLoopbackBindHost = (host: string) => { - const normalized = host.trim().toLowerCase(); - return ( - normalized === "localhost" || - normalized === "::1" || - normalized === "[::1]" || - normalized.startsWith("127.") - ); - }; - - // Bindings that expose gateway beyond localhost - const exposedBindings = ["all", "lan", "0.0.0.0"]; - const isExposed = - exposedBindings.includes(gatewayBind) || - (gatewayBind === "custom" && (!customBindHost || !isLoopbackBindHost(customBindHost))); + const resolvedAuth = resolveGatewayAuth({ + authConfig: cfg.gateway?.auth, + env: process.env, + tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off", + }); + const authToken = resolvedAuth.token?.trim() ?? ""; + const authPassword = resolvedAuth.password?.trim() ?? ""; + const hasToken = authToken.length > 0; + const hasPassword = authPassword.length > 0; + const hasSharedSecret = + (resolvedAuth.mode === "token" && hasToken) || + (resolvedAuth.mode === "password" && hasPassword); + const bindDescriptor = `"${gatewayBind}" (${resolvedBindHost})`; if (isExposed) { - if (authMode === "off") { + if (!hasSharedSecret) { + const authFixLines = + resolvedAuth.mode === "password" + ? [ + ` Fix: ${formatCliCommand("clawdbot configure")} to set a password`, + ` Or switch to token: ${formatCliCommand("clawdbot config set gateway.auth.mode token")}`, + ] + : [ + ` Fix: ${formatCliCommand("clawdbot doctor --fix")} to generate a token`, + ` Or set token directly: ${formatCliCommand( + "clawdbot config set gateway.auth.mode token", + )}`, + ]; warnings.push( - `- CRITICAL: Gateway bound to "${gatewayBind}" with NO authentication.`, + `- CRITICAL: Gateway bound to ${bindDescriptor} without authentication.`, ` Anyone on your network (or internet if port-forwarded) can fully control your agent.`, ` Fix: ${formatCliCommand("clawdbot config set gateway.bind loopback")}`, - ` Or enable auth: ${formatCliCommand("clawdbot config set gateway.auth.mode token")}`, - ); - } else if (authMode === "token" && !authToken) { - warnings.push( - `- CRITICAL: Gateway bound to "${gatewayBind}" with empty auth token.`, - ` Fix: ${formatCliCommand("clawdbot doctor --fix")} to generate a token`, - ); - } else if (authMode === "password" && !authPassword) { - warnings.push( - `- CRITICAL: Gateway bound to "${gatewayBind}" with empty password.`, - ` Fix: ${formatCliCommand("clawdbot configure")} to set a password`, + ...authFixLines, ); } else { // Auth is configured, but still warn about network exposure warnings.push( - `- WARNING: Gateway bound to "${gatewayBind}" (network-accessible).`, + `- WARNING: Gateway bound to ${bindDescriptor} (network-accessible).`, ` Ensure your auth credentials are strong and not exposed.`, ); } From b623557a2ec7e271bda003eb3ac33fbb2e218505 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 16:05:20 +0000 Subject: [PATCH 13/40] fix: harden url fetch dns pinning --- CHANGELOG.md | 1 + src/agents/tools/web-fetch.ts | 251 ++++++++++++++++------------- src/infra/net/ssrf.pinning.test.ts | 63 ++++++++ src/infra/net/ssrf.ts | 115 ++++++++++++- src/media/input-files.ts | 86 +++++----- src/media/store.redirect.test.ts | 3 + src/media/store.ts | 108 +++++++------ 7 files changed, 429 insertions(+), 198 deletions(-) create mode 100644 src/infra/net/ssrf.pinning.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f0d77860..b8740dd85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Status: unreleased. - Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. - Build: align memory-core peer dependency with lockfile. - Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie. +- Security: harden URL fetches with DNS pinning to reduce rebinding risk. Thanks Chris Zheng. - Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93. - Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0. - Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed). diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index c8bcaa609..9f1e565dd 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -1,7 +1,13 @@ import { Type } from "@sinclair/typebox"; import type { ClawdbotConfig } from "../../config/config.js"; -import { assertPublicHostname, SsrFBlockedError } from "../../infra/net/ssrf.js"; +import { + closeDispatcher, + createPinnedDispatcher, + resolvePinnedHostname, + SsrFBlockedError, +} from "../../infra/net/ssrf.js"; +import type { Dispatcher } from "undici"; import { stringEnum } from "../schema/typebox.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; @@ -167,7 +173,7 @@ async function fetchWithRedirects(params: { maxRedirects: number; timeoutSeconds: number; userAgent: string; -}): Promise<{ response: Response; finalUrl: string }> { +}): Promise<{ response: Response; finalUrl: string; dispatcher: Dispatcher }> { const signal = withTimeout(undefined, params.timeoutSeconds * 1000); const visited = new Set(); let currentUrl = params.url; @@ -184,39 +190,50 @@ async function fetchWithRedirects(params: { throw new Error("Invalid URL: must be http or https"); } - await assertPublicHostname(parsedUrl.hostname); - - const res = await fetch(parsedUrl.toString(), { - method: "GET", - headers: { - Accept: "*/*", - "User-Agent": params.userAgent, - "Accept-Language": "en-US,en;q=0.9", - }, - signal, - redirect: "manual", - }); + const pinned = await resolvePinnedHostname(parsedUrl.hostname); + const dispatcher = createPinnedDispatcher(pinned); + let res: Response; + try { + res = await fetch(parsedUrl.toString(), { + method: "GET", + headers: { + Accept: "*/*", + "User-Agent": params.userAgent, + "Accept-Language": "en-US,en;q=0.9", + }, + signal, + redirect: "manual", + dispatcher, + } as RequestInit); + } catch (err) { + await closeDispatcher(dispatcher); + throw err; + } if (isRedirectStatus(res.status)) { const location = res.headers.get("location"); if (!location) { + await closeDispatcher(dispatcher); throw new Error(`Redirect missing location header (${res.status})`); } redirectCount += 1; if (redirectCount > params.maxRedirects) { + await closeDispatcher(dispatcher); throw new Error(`Too many redirects (limit: ${params.maxRedirects})`); } const nextUrl = new URL(location, parsedUrl).toString(); if (visited.has(nextUrl)) { + await closeDispatcher(dispatcher); throw new Error("Redirect loop detected"); } visited.add(nextUrl); void res.body?.cancel(); + await closeDispatcher(dispatcher); currentUrl = nextUrl; continue; } - return { response: res, finalUrl: currentUrl }; + return { response: res, finalUrl: currentUrl, dispatcher }; } } @@ -348,6 +365,7 @@ async function runWebFetch(params: { const start = Date.now(); let res: Response; + let dispatcher: Dispatcher | null = null; let finalUrl = params.url; try { const result = await fetchWithRedirects({ @@ -358,6 +376,7 @@ async function runWebFetch(params: { }); res = result.response; finalUrl = result.finalUrl; + dispatcher = result.dispatcher; } catch (error) { if (error instanceof SsrFBlockedError) { throw error; @@ -396,108 +415,112 @@ async function runWebFetch(params: { throw error; } - if (!res.ok) { - if (params.firecrawlEnabled && params.firecrawlApiKey) { - const firecrawl = await fetchFirecrawlContent({ - url: params.url, - extractMode: params.extractMode, - apiKey: params.firecrawlApiKey, - baseUrl: params.firecrawlBaseUrl, - onlyMainContent: params.firecrawlOnlyMainContent, - maxAgeMs: params.firecrawlMaxAgeMs, - proxy: params.firecrawlProxy, - storeInCache: params.firecrawlStoreInCache, - timeoutSeconds: params.firecrawlTimeoutSeconds, - }); - const truncated = truncateText(firecrawl.text, params.maxChars); - const payload = { - url: params.url, - finalUrl: firecrawl.finalUrl || finalUrl, - status: firecrawl.status ?? res.status, - contentType: "text/markdown", - title: firecrawl.title, - extractMode: params.extractMode, - extractor: "firecrawl", - truncated: truncated.truncated, - length: truncated.text.length, - fetchedAt: new Date().toISOString(), - tookMs: Date.now() - start, - text: truncated.text, - warning: firecrawl.warning, - }; - writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - } - const rawDetail = await readResponseText(res); - const detail = formatWebFetchErrorDetail({ - detail: rawDetail, - contentType: res.headers.get("content-type"), - maxChars: DEFAULT_ERROR_MAX_CHARS, - }); - throw new Error(`Web fetch failed (${res.status}): ${detail || res.statusText}`); - } - - const contentType = res.headers.get("content-type") ?? "application/octet-stream"; - const body = await readResponseText(res); - - let title: string | undefined; - let extractor = "raw"; - let text = body; - if (contentType.includes("text/html")) { - if (params.readabilityEnabled) { - const readable = await extractReadableContent({ - html: body, - url: finalUrl, - extractMode: params.extractMode, - }); - if (readable?.text) { - text = readable.text; - title = readable.title; - extractor = "readability"; - } else { - const firecrawl = await tryFirecrawlFallback({ ...params, url: finalUrl }); - if (firecrawl) { - text = firecrawl.text; - title = firecrawl.title; - extractor = "firecrawl"; - } else { - throw new Error( - "Web fetch extraction failed: Readability and Firecrawl returned no content.", - ); - } + try { + if (!res.ok) { + if (params.firecrawlEnabled && params.firecrawlApiKey) { + const firecrawl = await fetchFirecrawlContent({ + url: params.url, + extractMode: params.extractMode, + apiKey: params.firecrawlApiKey, + baseUrl: params.firecrawlBaseUrl, + onlyMainContent: params.firecrawlOnlyMainContent, + maxAgeMs: params.firecrawlMaxAgeMs, + proxy: params.firecrawlProxy, + storeInCache: params.firecrawlStoreInCache, + timeoutSeconds: params.firecrawlTimeoutSeconds, + }); + const truncated = truncateText(firecrawl.text, params.maxChars); + const payload = { + url: params.url, + finalUrl: firecrawl.finalUrl || finalUrl, + status: firecrawl.status ?? res.status, + contentType: "text/markdown", + title: firecrawl.title, + extractMode: params.extractMode, + extractor: "firecrawl", + truncated: truncated.truncated, + length: truncated.text.length, + fetchedAt: new Date().toISOString(), + tookMs: Date.now() - start, + text: truncated.text, + warning: firecrawl.warning, + }; + writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; } - } else { - throw new Error( - "Web fetch extraction failed: Readability disabled and Firecrawl unavailable.", - ); + const rawDetail = await readResponseText(res); + const detail = formatWebFetchErrorDetail({ + detail: rawDetail, + contentType: res.headers.get("content-type"), + maxChars: DEFAULT_ERROR_MAX_CHARS, + }); + throw new Error(`Web fetch failed (${res.status}): ${detail || res.statusText}`); } - } else if (contentType.includes("application/json")) { - try { - text = JSON.stringify(JSON.parse(body), null, 2); - extractor = "json"; - } catch { - text = body; - extractor = "raw"; - } - } - const truncated = truncateText(text, params.maxChars); - const payload = { - url: params.url, - finalUrl, - status: res.status, - contentType, - title, - extractMode: params.extractMode, - extractor, - truncated: truncated.truncated, - length: truncated.text.length, - fetchedAt: new Date().toISOString(), - tookMs: Date.now() - start, - text: truncated.text, - }; - writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; + const contentType = res.headers.get("content-type") ?? "application/octet-stream"; + const body = await readResponseText(res); + + let title: string | undefined; + let extractor = "raw"; + let text = body; + if (contentType.includes("text/html")) { + if (params.readabilityEnabled) { + const readable = await extractReadableContent({ + html: body, + url: finalUrl, + extractMode: params.extractMode, + }); + if (readable?.text) { + text = readable.text; + title = readable.title; + extractor = "readability"; + } else { + const firecrawl = await tryFirecrawlFallback({ ...params, url: finalUrl }); + if (firecrawl) { + text = firecrawl.text; + title = firecrawl.title; + extractor = "firecrawl"; + } else { + throw new Error( + "Web fetch extraction failed: Readability and Firecrawl returned no content.", + ); + } + } + } else { + throw new Error( + "Web fetch extraction failed: Readability disabled and Firecrawl unavailable.", + ); + } + } else if (contentType.includes("application/json")) { + try { + text = JSON.stringify(JSON.parse(body), null, 2); + extractor = "json"; + } catch { + text = body; + extractor = "raw"; + } + } + + const truncated = truncateText(text, params.maxChars); + const payload = { + url: params.url, + finalUrl, + status: res.status, + contentType, + title, + extractMode: params.extractMode, + extractor, + truncated: truncated.truncated, + length: truncated.text.length, + fetchedAt: new Date().toISOString(), + tookMs: Date.now() - start, + text: truncated.text, + }; + writeCache(FETCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + } finally { + await closeDispatcher(dispatcher); + } } async function tryFirecrawlFallback(params: { diff --git a/src/infra/net/ssrf.pinning.test.ts b/src/infra/net/ssrf.pinning.test.ts new file mode 100644 index 000000000..42bc54b66 --- /dev/null +++ b/src/infra/net/ssrf.pinning.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createPinnedLookup, resolvePinnedHostname } from "./ssrf.js"; + +describe("ssrf pinning", () => { + it("pins resolved addresses for the target hostname", async () => { + const lookup = vi.fn(async () => [ + { address: "93.184.216.34", family: 4 }, + { address: "93.184.216.35", family: 4 }, + ]); + + const pinned = await resolvePinnedHostname("Example.com.", lookup); + expect(pinned.hostname).toBe("example.com"); + expect(pinned.addresses).toEqual(["93.184.216.34", "93.184.216.35"]); + + const first = await new Promise<{ address: string; family?: number }>((resolve, reject) => { + pinned.lookup("example.com", (err, address, family) => { + if (err) reject(err); + else resolve({ address: address as string, family }); + }); + }); + expect(first.address).toBe("93.184.216.34"); + expect(first.family).toBe(4); + + const all = await new Promise((resolve, reject) => { + pinned.lookup("example.com", { all: true }, (err, addresses) => { + if (err) reject(err); + else resolve(addresses); + }); + }); + expect(Array.isArray(all)).toBe(true); + expect((all as Array<{ address: string }>).map((entry) => entry.address)).toEqual( + pinned.addresses, + ); + }); + + it("rejects private DNS results", async () => { + const lookup = vi.fn(async () => [{ address: "10.0.0.8", family: 4 }]); + await expect(resolvePinnedHostname("example.com", lookup)).rejects.toThrow(/private|internal/i); + }); + + it("falls back for non-matching hostnames", async () => { + const fallback = vi.fn((host: string, options?: unknown, callback?: unknown) => { + const cb = typeof options === "function" ? options : (callback as () => void); + (cb as (err: null, address: string, family: number) => void)(null, "1.2.3.4", 4); + }); + const lookup = createPinnedLookup({ + hostname: "example.com", + addresses: ["93.184.216.34"], + fallback, + }); + + const result = await new Promise<{ address: string }>((resolve, reject) => { + lookup("other.test", (err, address) => { + if (err) reject(err); + else resolve({ address: address as string }); + }); + }); + + expect(fallback).toHaveBeenCalledTimes(1); + expect(result.address).toBe("1.2.3.4"); + }); +}); diff --git a/src/infra/net/ssrf.ts b/src/infra/net/ssrf.ts index 9b09cc4b1..297df0f03 100644 --- a/src/infra/net/ssrf.ts +++ b/src/infra/net/ssrf.ts @@ -1,4 +1,12 @@ import { lookup as dnsLookup } from "node:dns/promises"; +import { lookup as dnsLookupCb, type LookupAddress } from "node:dns"; +import { Agent, type Dispatcher } from "undici"; + +type LookupCallback = ( + err: NodeJS.ErrnoException | null, + address: string | LookupAddress[], + family?: number, +) => void; export class SsrFBlockedError extends Error { constructor(message: string) { @@ -101,10 +109,71 @@ export function isBlockedHostname(hostname: string): boolean { ); } -export async function assertPublicHostname( +export function createPinnedLookup(params: { + hostname: string; + addresses: string[]; + fallback?: typeof dnsLookupCb; +}): typeof dnsLookupCb { + const normalizedHost = normalizeHostname(params.hostname); + const fallback = params.fallback ?? dnsLookupCb; + const fallbackLookup = fallback as unknown as ( + hostname: string, + callback: LookupCallback, + ) => void; + const fallbackWithOptions = fallback as unknown as ( + hostname: string, + options: unknown, + callback: LookupCallback, + ) => void; + const records = params.addresses.map((address) => ({ + address, + family: address.includes(":") ? 6 : 4, + })); + let index = 0; + + return ((host: string, options?: unknown, callback?: unknown) => { + const cb: LookupCallback = + typeof options === "function" ? (options as LookupCallback) : (callback as LookupCallback); + if (!cb) return; + const normalized = normalizeHostname(host); + if (!normalized || normalized !== normalizedHost) { + if (typeof options === "function" || options === undefined) { + return fallbackLookup(host, cb); + } + return fallbackWithOptions(host, options, cb); + } + + const opts = + typeof options === "object" && options !== null + ? (options as { all?: boolean; family?: number }) + : {}; + const requestedFamily = + typeof options === "number" ? options : typeof opts.family === "number" ? opts.family : 0; + const candidates = + requestedFamily === 4 || requestedFamily === 6 + ? records.filter((entry) => entry.family === requestedFamily) + : records; + const usable = candidates.length > 0 ? candidates : records; + if (opts.all) { + cb(null, usable as LookupAddress[]); + return; + } + const chosen = usable[index % usable.length]; + index += 1; + cb(null, chosen.address, chosen.family); + }) as typeof dnsLookupCb; +} + +export type PinnedHostname = { + hostname: string; + addresses: string[]; + lookup: typeof dnsLookupCb; +}; + +export async function resolvePinnedHostname( hostname: string, lookupFn: LookupFn = dnsLookup, -): Promise { +): Promise { const normalized = normalizeHostname(hostname); if (!normalized) { throw new Error("Invalid hostname"); @@ -128,4 +197,46 @@ export async function assertPublicHostname( throw new SsrFBlockedError("Blocked: resolves to private/internal IP address"); } } + + const addresses = Array.from(new Set(results.map((entry) => entry.address))); + if (addresses.length === 0) { + throw new Error(`Unable to resolve hostname: ${hostname}`); + } + + return { + hostname: normalized, + addresses, + lookup: createPinnedLookup({ hostname: normalized, addresses }), + }; +} + +export function createPinnedDispatcher(pinned: PinnedHostname): Dispatcher { + return new Agent({ + connect: { + lookup: pinned.lookup, + }, + }); +} + +export async function closeDispatcher(dispatcher?: Dispatcher | null): Promise { + if (!dispatcher) return; + const candidate = dispatcher as { close?: () => Promise | void; destroy?: () => void }; + try { + if (typeof candidate.close === "function") { + await candidate.close(); + return; + } + if (typeof candidate.destroy === "function") { + candidate.destroy(); + } + } catch { + // ignore dispatcher cleanup errors + } +} + +export async function assertPublicHostname( + hostname: string, + lookupFn: LookupFn = dnsLookup, +): Promise { + await resolvePinnedHostname(hostname, lookupFn); } diff --git a/src/media/input-files.ts b/src/media/input-files.ts index 8b1d1945a..b337e17c5 100644 --- a/src/media/input-files.ts +++ b/src/media/input-files.ts @@ -1,5 +1,10 @@ import { logWarn } from "../logger.js"; -import { assertPublicHostname } from "../infra/net/ssrf.js"; +import { + closeDispatcher, + createPinnedDispatcher, + resolvePinnedHostname, +} from "../infra/net/ssrf.js"; +import type { Dispatcher } from "undici"; type CanvasModule = typeof import("@napi-rs/canvas"); type PdfJsModule = typeof import("pdfjs-dist/legacy/build/pdf.mjs"); @@ -154,50 +159,57 @@ export async function fetchWithGuard(params: { if (!["http:", "https:"].includes(parsedUrl.protocol)) { throw new Error(`Invalid URL protocol: ${parsedUrl.protocol}. Only HTTP/HTTPS allowed.`); } - await assertPublicHostname(parsedUrl.hostname); + const pinned = await resolvePinnedHostname(parsedUrl.hostname); + const dispatcher = createPinnedDispatcher(pinned); - const response = await fetch(parsedUrl, { - signal: controller.signal, - headers: { "User-Agent": "Clawdbot-Gateway/1.0" }, - redirect: "manual", - }); + try { + const response = await fetch(parsedUrl, { + signal: controller.signal, + headers: { "User-Agent": "Clawdbot-Gateway/1.0" }, + redirect: "manual", + dispatcher, + } as RequestInit & { dispatcher: Dispatcher }); - if (isRedirectStatus(response.status)) { - const location = response.headers.get("location"); - if (!location) { - throw new Error(`Redirect missing location header (${response.status})`); + if (isRedirectStatus(response.status)) { + const location = response.headers.get("location"); + if (!location) { + throw new Error(`Redirect missing location header (${response.status})`); + } + redirectCount += 1; + if (redirectCount > params.maxRedirects) { + throw new Error(`Too many redirects (limit: ${params.maxRedirects})`); + } + void response.body?.cancel(); + currentUrl = new URL(location, parsedUrl).toString(); + continue; } - redirectCount += 1; - if (redirectCount > params.maxRedirects) { - throw new Error(`Too many redirects (limit: ${params.maxRedirects})`); + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`); } - currentUrl = new URL(location, parsedUrl).toString(); - continue; - } - if (!response.ok) { - throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`); - } - - const contentLength = response.headers.get("content-length"); - if (contentLength) { - const size = parseInt(contentLength, 10); - if (size > params.maxBytes) { - throw new Error(`Content too large: ${size} bytes (limit: ${params.maxBytes} bytes)`); + const contentLength = response.headers.get("content-length"); + if (contentLength) { + const size = parseInt(contentLength, 10); + if (size > params.maxBytes) { + throw new Error(`Content too large: ${size} bytes (limit: ${params.maxBytes} bytes)`); + } } - } - const buffer = Buffer.from(await response.arrayBuffer()); - if (buffer.byteLength > params.maxBytes) { - throw new Error( - `Content too large: ${buffer.byteLength} bytes (limit: ${params.maxBytes} bytes)`, - ); - } + const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.byteLength > params.maxBytes) { + throw new Error( + `Content too large: ${buffer.byteLength} bytes (limit: ${params.maxBytes} bytes)`, + ); + } - const contentType = response.headers.get("content-type") || undefined; - const parsed = parseContentType(contentType); - const mimeType = parsed.mimeType ?? "application/octet-stream"; - return { buffer, mimeType, contentType }; + const contentType = response.headers.get("content-type") || undefined; + const parsed = parseContentType(contentType); + const mimeType = parsed.mimeType ?? "application/octet-stream"; + return { buffer, mimeType, contentType }; + } finally { + await closeDispatcher(dispatcher); + } } } finally { clearTimeout(timeoutId); diff --git a/src/media/store.redirect.test.ts b/src/media/store.redirect.test.ts index 474f9c050..90dacba9a 100644 --- a/src/media/store.redirect.test.ts +++ b/src/media/store.redirect.test.ts @@ -18,6 +18,9 @@ vi.doMock("node:os", () => ({ vi.doMock("node:https", () => ({ request: (...args: unknown[]) => mockRequest(...args), })); +vi.doMock("node:dns/promises", () => ({ + lookup: async () => [{ address: "93.184.216.34", family: 4 }], +})); const loadStore = async () => await import("./store.js"); diff --git a/src/media/store.ts b/src/media/store.ts index cd6c92411..c24614016 100644 --- a/src/media/store.ts +++ b/src/media/store.ts @@ -1,10 +1,12 @@ import crypto from "node:crypto"; import { createWriteStream } from "node:fs"; import fs from "node:fs/promises"; -import { request } from "node:https"; +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; import path from "node:path"; import { pipeline } from "node:stream/promises"; import { resolveConfigDir } from "../utils.js"; +import { resolvePinnedHostname } from "../infra/net/ssrf.js"; import { detectMime, extensionForMime } from "./mime.js"; const resolveMediaDir = () => path.join(resolveConfigDir(), "media"); @@ -88,51 +90,67 @@ async function downloadToFile( maxRedirects = 5, ): Promise<{ headerMime?: string; sniffBuffer: Buffer; size: number }> { return await new Promise((resolve, reject) => { - const req = request(url, { headers }, (res) => { - // Follow redirects - if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) { - const location = res.headers.location; - if (!location || maxRedirects <= 0) { - reject(new Error(`Redirect loop or missing Location header`)); - return; - } - const redirectUrl = new URL(location, url).href; - resolve(downloadToFile(redirectUrl, dest, headers, maxRedirects - 1)); - return; - } - if (!res.statusCode || res.statusCode >= 400) { - reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`)); - return; - } - let total = 0; - const sniffChunks: Buffer[] = []; - let sniffLen = 0; - const out = createWriteStream(dest); - res.on("data", (chunk) => { - total += chunk.length; - if (sniffLen < 16384) { - sniffChunks.push(chunk); - sniffLen += chunk.length; - } - if (total > MAX_BYTES) { - req.destroy(new Error("Media exceeds 5MB limit")); - } - }); - pipeline(res, out) - .then(() => { - const sniffBuffer = Buffer.concat(sniffChunks, Math.min(sniffLen, 16384)); - const rawHeader = res.headers["content-type"]; - const headerMime = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader; - resolve({ - headerMime, - sniffBuffer, - size: total, + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + reject(new Error("Invalid URL")); + return; + } + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + reject(new Error(`Invalid URL protocol: ${parsedUrl.protocol}. Only HTTP/HTTPS allowed.`)); + return; + } + const requestImpl = parsedUrl.protocol === "https:" ? httpsRequest : httpRequest; + resolvePinnedHostname(parsedUrl.hostname) + .then((pinned) => { + const req = requestImpl(parsedUrl, { headers, lookup: pinned.lookup }, (res) => { + // Follow redirects + if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) { + const location = res.headers.location; + if (!location || maxRedirects <= 0) { + reject(new Error(`Redirect loop or missing Location header`)); + return; + } + const redirectUrl = new URL(location, url).href; + resolve(downloadToFile(redirectUrl, dest, headers, maxRedirects - 1)); + return; + } + if (!res.statusCode || res.statusCode >= 400) { + reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading media`)); + return; + } + let total = 0; + const sniffChunks: Buffer[] = []; + let sniffLen = 0; + const out = createWriteStream(dest); + res.on("data", (chunk) => { + total += chunk.length; + if (sniffLen < 16384) { + sniffChunks.push(chunk); + sniffLen += chunk.length; + } + if (total > MAX_BYTES) { + req.destroy(new Error("Media exceeds 5MB limit")); + } }); - }) - .catch(reject); - }); - req.on("error", reject); - req.end(); + pipeline(res, out) + .then(() => { + const sniffBuffer = Buffer.concat(sniffChunks, Math.min(sniffLen, 16384)); + const rawHeader = res.headers["content-type"]; + const headerMime = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader; + resolve({ + headerMime, + sniffBuffer, + size: total, + }); + }) + .catch(reject); + }); + req.on("error", reject); + req.end(); + }) + .catch(reject); }); } From 97200984f8187d4161be7dc6704f460622ef3de4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 16:18:29 +0000 Subject: [PATCH 14/40] fix: secure twilio webhook verification --- CHANGELOG.md | 1 + docs/plugins/voice-call.md | 2 ++ extensions/voice-call/src/config.test.ts | 2 +- extensions/voice-call/src/config.ts | 18 +++++++------ .../src/providers/twilio/webhook.ts | 2 +- extensions/voice-call/src/runtime.ts | 2 +- .../voice-call/src/webhook-security.test.ts | 25 +++++++++++++++++++ extensions/voice-call/src/webhook-security.ts | 12 --------- 8 files changed, 41 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b8740dd85..30a185e68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Status: unreleased. ### Fixes - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. +- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. - Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. - Build: align memory-core peer dependency with lockfile. - Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie. diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md index eecb80133..cd574b26e 100644 --- a/docs/plugins/voice-call.md +++ b/docs/plugins/voice-call.md @@ -103,6 +103,8 @@ Notes: - Plivo requires a **publicly reachable** webhook URL. - `mock` is a local dev provider (no network calls). - `skipSignatureVerification` is for local testing only. +- If you use ngrok free tier, set `publicUrl` to the exact ngrok URL; signature verification is always enforced. +- Ngrok free tier URLs can change or add interstitial behavior; if `publicUrl` drifts, Twilio signatures will fail. For production, prefer a stable domain or Tailscale funnel. ## TTS for calls diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts index 7334498e2..aac9fe44c 100644 --- a/extensions/voice-call/src/config.test.ts +++ b/extensions/voice-call/src/config.test.ts @@ -19,7 +19,7 @@ function createBaseConfig( maxConcurrentCalls: 1, serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" }, tailscale: { mode: "off", path: "/voice/webhook" }, - tunnel: { provider: "none", allowNgrokFreeTier: true }, + tunnel: { provider: "none", allowNgrokFreeTier: false }, streaming: { enabled: false, sttProvider: "openai-realtime", diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index 6d6036792..99916e49d 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -217,13 +217,12 @@ export const VoiceCallTunnelConfigSchema = z /** * Allow ngrok free tier compatibility mode. * When true, signature verification failures on ngrok-free.app URLs - * will be logged but allowed through. Less secure, but necessary - * for ngrok free tier which may modify URLs. + * will include extra diagnostics. Signature verification is still required. */ - allowNgrokFreeTier: z.boolean().default(true), + allowNgrokFreeTier: z.boolean().default(false), }) .strict() - .default({ provider: "none", allowNgrokFreeTier: true }); + .default({ provider: "none", allowNgrokFreeTier: false }); export type VoiceCallTunnelConfig = z.infer; // ----------------------------------------------------------------------------- @@ -418,11 +417,14 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig } // Tunnel Config - resolved.tunnel = resolved.tunnel ?? { provider: "none", allowNgrokFreeTier: true }; + resolved.tunnel = resolved.tunnel ?? { + provider: "none", + allowNgrokFreeTier: false, + }; resolved.tunnel.ngrokAuthToken = - resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN; - resolved.tunnel.ngrokDomain = - resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN; + resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN; + resolved.tunnel.ngrokDomain = + resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN; return resolved; } diff --git a/extensions/voice-call/src/providers/twilio/webhook.ts b/extensions/voice-call/src/providers/twilio/webhook.ts index 28f445c88..1cddcb164 100644 --- a/extensions/voice-call/src/providers/twilio/webhook.ts +++ b/extensions/voice-call/src/providers/twilio/webhook.ts @@ -11,7 +11,7 @@ export function verifyTwilioProviderWebhook(params: { }): WebhookVerificationResult { const result = verifyTwilioWebhook(params.ctx, params.authToken, { publicUrl: params.currentPublicUrl || undefined, - allowNgrokFreeTier: params.options.allowNgrokFreeTier ?? true, + allowNgrokFreeTier: params.options.allowNgrokFreeTier ?? false, skipVerification: params.options.skipVerification, }); diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index a2eb15315..ffa95ddff 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -48,7 +48,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider { authToken: config.twilio?.authToken, }, { - allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? true, + allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? false, publicUrl: config.publicUrl, skipVerification: config.skipSignatureVerification, streamPath: config.streaming?.enabled diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index c31d7225a..98d8a451c 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -205,4 +205,29 @@ describe("verifyTwilioWebhook", () => { expect(result.ok).toBe(true); }); + + it("rejects invalid signatures even with ngrok free tier enabled", () => { + const authToken = "test-auth-token"; + const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; + + const result = verifyTwilioWebhook( + { + headers: { + host: "127.0.0.1:3334", + "x-forwarded-proto": "https", + "x-forwarded-host": "attacker.ngrok-free.app", + "x-twilio-signature": "invalid", + }, + rawBody: postBody, + url: "http://127.0.0.1:3334/voice/webhook", + method: "POST", + }, + authToken, + { allowNgrokFreeTier: true }, + ); + + expect(result.ok).toBe(false); + expect(result.isNgrokFreeTier).toBe(true); + expect(result.reason).toMatch(/Invalid signature/); + }); }); diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index 79bd96099..98b1d9837 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -195,18 +195,6 @@ export function verifyTwilioWebhook( verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io"); - if (isNgrokFreeTier && options?.allowNgrokFreeTier) { - console.warn( - "[voice-call] Twilio signature validation failed (proceeding for ngrok free tier compatibility)", - ); - return { - ok: true, - reason: "ngrok free tier compatibility mode", - verificationUrl, - isNgrokFreeTier: true, - }; - } - return { ok: false, reason: `Invalid signature for URL: ${verificationUrl}`, From 3e07bd8b48f0491634b89790d4dcd4217af6f5eb Mon Sep 17 00:00:00 2001 From: Kentaro Kuribayashi Date: Tue, 27 Jan 2026 01:39:54 +0900 Subject: [PATCH 15/40] feat(discord): add configurable privileged Gateway Intents (GuildPresences, GuildMembers) (#2266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(discord): add configurable privileged Gateway Intents (GuildPresences, GuildMembers) Add support for optionally enabling Discord privileged Gateway Intents via config, starting with GuildPresences and GuildMembers. When `channels.discord.intents.presence` is set to true: - GatewayIntents.GuildPresences is added to the gateway connection - A PresenceUpdateListener caches user presence data in memory - The member-info action includes user status and activities (e.g. Spotify listening activity) from the cache This enables use cases like: - Seeing what music a user is currently listening to - Checking user online/offline/idle/dnd status - Tracking user activities through the bot API Both intents require Portal opt-in (Discord Developer Portal → Privileged Gateway Intents) before they can be used. Changes: - config: add `channels.discord.intents.{presence,guildMembers}` - provider: compute intents dynamically from config - listeners: add DiscordPresenceListener (extends PresenceUpdateListener) - presence-cache: simple in-memory Map - discord-actions-guild: include cached presence in member-info response - schema: add labels and descriptions for new config fields * fix(test): add PresenceUpdateListener to @buape/carbon mock * Discord: scope presence cache by account --------- Co-authored-by: kugutsushi Co-authored-by: Shadow --- src/agents/tools/discord-actions-guild.ts | 6 ++- src/config/schema.ts | 6 +++ src/config/types.discord.ts | 9 ++++ src/config/zod-schema.providers-core.ts | 7 +++ src/discord/monitor.slash.test.ts | 1 + src/discord/monitor/listeners.ts | 33 ++++++++++++++ src/discord/monitor/presence-cache.ts | 52 +++++++++++++++++++++++ src/discord/monitor/provider.ts | 36 +++++++++++++--- 8 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 src/discord/monitor/presence-cache.ts diff --git a/src/agents/tools/discord-actions-guild.ts b/src/agents/tools/discord-actions-guild.ts index 0994829bd..26e21c82e 100644 --- a/src/agents/tools/discord-actions-guild.ts +++ b/src/agents/tools/discord-actions-guild.ts @@ -1,5 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { DiscordActionConfig } from "../../config/config.js"; +import { getPresence } from "../../discord/monitor/presence-cache.js"; import { addRoleDiscord, createChannelDiscord, @@ -54,7 +55,10 @@ export async function handleDiscordGuildAction( const member = accountId ? await fetchMemberInfoDiscord(guildId, userId, { accountId }) : await fetchMemberInfoDiscord(guildId, userId); - return jsonResult({ ok: true, member }); + const presence = getPresence(accountId, userId); + const activities = presence?.activities ?? undefined; + const status = presence?.status ?? undefined; + return jsonResult({ ok: true, member, ...(presence ? { status, activities } : {}) }); } case "roleInfo": { if (!isActionEnabled("roleInfo")) { diff --git a/src/config/schema.ts b/src/config/schema.ts index ada88dde6..63c10ed88 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -321,6 +321,8 @@ const FIELD_LABELS: Record = { "channels.discord.retry.maxDelayMs": "Discord Retry Max Delay (ms)", "channels.discord.retry.jitter": "Discord Retry Jitter", "channels.discord.maxLinesPerMessage": "Discord Max Lines Per Message", + "channels.discord.intents.presence": "Discord Presence Intent", + "channels.discord.intents.guildMembers": "Discord Guild Members Intent", "channels.slack.dm.policy": "Slack DM Policy", "channels.slack.allowBots": "Slack Allow Bot Messages", "channels.discord.token": "Discord Bot Token", @@ -657,6 +659,10 @@ const FIELD_HELP: Record = { "channels.discord.retry.maxDelayMs": "Maximum retry delay cap in ms for Discord outbound calls.", "channels.discord.retry.jitter": "Jitter factor (0-1) applied to Discord retry delays.", "channels.discord.maxLinesPerMessage": "Soft max line count per Discord message (default: 17).", + "channels.discord.intents.presence": + "Enable the Guild Presences privileged intent. Must also be enabled in the Discord Developer Portal. Allows tracking user activities (e.g. Spotify). Default: false.", + "channels.discord.intents.guildMembers": + "Enable the Guild Members privileged intent. Must also be enabled in the Discord Developer Portal. Default: false.", "channels.slack.dm.policy": 'Direct message access control ("pairing" recommended). "open" requires channels.slack.dm.allowFrom=["*"].', }; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 071d6e6a7..70ea5f1fb 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -72,6 +72,13 @@ export type DiscordActionConfig = { channels?: boolean; }; +export type DiscordIntentsConfig = { + /** Enable Guild Presences privileged intent (requires Portal opt-in). Default: false. */ + presence?: boolean; + /** Enable Guild Members privileged intent (requires Portal opt-in). Default: false. */ + guildMembers?: boolean; +}; + export type DiscordExecApprovalConfig = { /** Enable exec approval forwarding to Discord DMs. Default: false. */ enabled?: boolean; @@ -139,6 +146,8 @@ export type DiscordAccountConfig = { heartbeat?: ChannelHeartbeatVisibilityConfig; /** Exec approval forwarding configuration. */ execApprovals?: DiscordExecApprovalConfig; + /** Privileged Gateway Intents (must also be enabled in Discord Developer Portal). */ + intents?: DiscordIntentsConfig; }; export type DiscordConfig = { diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 4b1b9338a..374e6e8aa 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -256,6 +256,13 @@ export const DiscordAccountSchema = z }) .strict() .optional(), + intents: z + .object({ + presence: z.boolean().optional(), + guildMembers: z.boolean().optional(), + }) + .strict() + .optional(), }) .strict(); diff --git a/src/discord/monitor.slash.test.ts b/src/discord/monitor.slash.test.ts index a6c43087d..d5488cb98 100644 --- a/src/discord/monitor.slash.test.ts +++ b/src/discord/monitor.slash.test.ts @@ -16,6 +16,7 @@ vi.mock("@buape/carbon", () => ({ MessageCreateListener: class {}, MessageReactionAddListener: class {}, MessageReactionRemoveListener: class {}, + PresenceUpdateListener: class {}, Row: class { constructor(_components: unknown[]) {} }, diff --git a/src/discord/monitor/listeners.ts b/src/discord/monitor/listeners.ts index 0eb5e2e8e..770ae6d6c 100644 --- a/src/discord/monitor/listeners.ts +++ b/src/discord/monitor/listeners.ts @@ -4,11 +4,13 @@ import { MessageCreateListener, MessageReactionAddListener, MessageReactionRemoveListener, + PresenceUpdateListener, } from "@buape/carbon"; import { danger } from "../../globals.js"; import { formatDurationSeconds } from "../../infra/format-duration.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; +import { setPresence } from "./presence-cache.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { @@ -269,3 +271,34 @@ async function handleDiscordReactionEvent(params: { params.logger.error(danger(`discord reaction handler failed: ${String(err)}`)); } } + +type PresenceUpdateEvent = Parameters[0]; + +export class DiscordPresenceListener extends PresenceUpdateListener { + private logger?: Logger; + private accountId?: string; + + constructor(params: { logger?: Logger; accountId?: string }) { + super(); + this.logger = params.logger; + this.accountId = params.accountId; + } + + async handle(data: PresenceUpdateEvent) { + try { + const userId = + "user" in data && data.user && typeof data.user === "object" && "id" in data.user + ? String(data.user.id) + : undefined; + if (!userId) return; + setPresence( + this.accountId, + userId, + data as import("discord-api-types/v10").GatewayPresenceUpdate, + ); + } catch (err) { + const logger = this.logger ?? discordEventQueueLog; + logger.error(danger(`discord presence handler failed: ${String(err)}`)); + } + } +} diff --git a/src/discord/monitor/presence-cache.ts b/src/discord/monitor/presence-cache.ts new file mode 100644 index 000000000..e112297e8 --- /dev/null +++ b/src/discord/monitor/presence-cache.ts @@ -0,0 +1,52 @@ +import type { GatewayPresenceUpdate } from "discord-api-types/v10"; + +/** + * In-memory cache of Discord user presence data. + * Populated by PRESENCE_UPDATE gateway events when the GuildPresences intent is enabled. + */ +const presenceCache = new Map>(); + +function resolveAccountKey(accountId?: string): string { + return accountId ?? "default"; +} + +/** Update cached presence for a user. */ +export function setPresence( + accountId: string | undefined, + userId: string, + data: GatewayPresenceUpdate, +): void { + const accountKey = resolveAccountKey(accountId); + let accountCache = presenceCache.get(accountKey); + if (!accountCache) { + accountCache = new Map(); + presenceCache.set(accountKey, accountCache); + } + accountCache.set(userId, data); +} + +/** Get cached presence for a user. Returns undefined if not cached. */ +export function getPresence( + accountId: string | undefined, + userId: string, +): GatewayPresenceUpdate | undefined { + return presenceCache.get(resolveAccountKey(accountId))?.get(userId); +} + +/** Clear cached presence data. */ +export function clearPresences(accountId?: string): void { + if (accountId) { + presenceCache.delete(resolveAccountKey(accountId)); + return; + } + presenceCache.clear(); +} + +/** Get the number of cached presence entries. */ +export function presenceCacheSize(): number { + let total = 0; + for (const accountCache of presenceCache.values()) { + total += accountCache.size; + } + return total; +} diff --git a/src/discord/monitor/provider.ts b/src/discord/monitor/provider.ts index 0599d104e..ed5299cf7 100644 --- a/src/discord/monitor/provider.ts +++ b/src/discord/monitor/provider.ts @@ -28,6 +28,7 @@ import { resolveDiscordUserAllowlist } from "../resolve-users.js"; import { normalizeDiscordToken } from "../token.js"; import { DiscordMessageListener, + DiscordPresenceListener, DiscordReactionListener, DiscordReactionRemoveListener, registerDiscordListener, @@ -109,6 +110,25 @@ function formatDiscordDeployErrorDetails(err: unknown): string { return details.length > 0 ? ` (${details.join(", ")})` : ""; } +function resolveDiscordGatewayIntents( + intentsConfig?: import("../../config/types.discord.js").DiscordIntentsConfig, +): number { + let intents = + GatewayIntents.Guilds | + GatewayIntents.GuildMessages | + GatewayIntents.MessageContent | + GatewayIntents.DirectMessages | + GatewayIntents.GuildMessageReactions | + GatewayIntents.DirectMessageReactions; + if (intentsConfig?.presence) { + intents |= GatewayIntents.GuildPresences; + } + if (intentsConfig?.guildMembers) { + intents |= GatewayIntents.GuildMembers; + } + return intents; +} + export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const cfg = opts.config ?? loadConfig(); const account = resolveDiscordAccount({ @@ -451,13 +471,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { reconnect: { maxAttempts: Number.POSITIVE_INFINITY, }, - intents: - GatewayIntents.Guilds | - GatewayIntents.GuildMessages | - GatewayIntents.MessageContent | - GatewayIntents.DirectMessages | - GatewayIntents.GuildMessageReactions | - GatewayIntents.DirectMessageReactions, + intents: resolveDiscordGatewayIntents(discordCfg.intents), autoInteractions: true, }), ], @@ -527,6 +541,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { }), ); + if (discordCfg.intents?.presence) { + registerDiscordListener( + client.listeners, + new DiscordPresenceListener({ logger, accountId: account.accountId }), + ); + runtime.log?.("discord: GuildPresences intent enabled — presence listener registered"); + } + runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`); // Start exec approvals handler after client is ready From 07e34e3423c67bdd79350c43a29910919f65f9b1 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 26 Jan 2026 10:43:23 -0600 Subject: [PATCH 16/40] Discord: add presence cache tests (#2266) (thanks @kentaro) --- CHANGELOG.md | 1 + src/discord/monitor/presence-cache.test.ts | 39 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 src/discord/monitor/presence-cache.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 30a185e68..9fb9388a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Status: unreleased. ### Changes - Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos. - Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. +- Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro. - Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng. - Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr. - Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger. diff --git a/src/discord/monitor/presence-cache.test.ts b/src/discord/monitor/presence-cache.test.ts new file mode 100644 index 000000000..8cdf8cefa --- /dev/null +++ b/src/discord/monitor/presence-cache.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { GatewayPresenceUpdate } from "discord-api-types/v10"; +import { + clearPresences, + getPresence, + presenceCacheSize, + setPresence, +} from "./presence-cache.js"; + +describe("presence-cache", () => { + beforeEach(() => { + clearPresences(); + }); + + it("scopes presence entries by account", () => { + const presenceA = { status: "online" } as GatewayPresenceUpdate; + const presenceB = { status: "idle" } as GatewayPresenceUpdate; + + setPresence("account-a", "user-1", presenceA); + setPresence("account-b", "user-1", presenceB); + + expect(getPresence("account-a", "user-1")).toBe(presenceA); + expect(getPresence("account-b", "user-1")).toBe(presenceB); + expect(getPresence("account-a", "user-2")).toBeUndefined(); + }); + + it("clears presence per account", () => { + const presence = { status: "dnd" } as GatewayPresenceUpdate; + + setPresence("account-a", "user-1", presence); + setPresence("account-b", "user-2", presence); + + clearPresences("account-a"); + + expect(getPresence("account-a", "user-1")).toBeUndefined(); + expect(getPresence("account-b", "user-2")).toBe(presence); + expect(presenceCacheSize()).toBe(1); + }); +}); From b9643ad60ec5ee96ed87ab7802f8c065763ed2b9 Mon Sep 17 00:00:00 2001 From: Dan Guido Date: Mon, 26 Jan 2026 02:40:31 -0500 Subject: [PATCH 17/40] docs(fly): add private/hardened deployment guide - Add fly.private.toml template for deployments with no public IP - Add "Private Deployment (Hardened)" section to Fly docs - Document how to convert existing deployment to private-only - Add security notes recommending env vars over config file for secrets This addresses security concerns about Clawdbot gateways being discoverable on internet scanners (Shodan, Censys). Private deployments are accessible only via fly proxy, WireGuard, or SSH. Co-Authored-By: Claude Opus 4.5 --- docs/platforms/fly.md | 109 +++++++++++++++++++++++++++++++++++++++++- fly.private.toml | 39 +++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 fly.private.toml diff --git a/docs/platforms/fly.md b/docs/platforms/fly.md index 0fdf176ae..2b1e97483 100644 --- a/docs/platforms/fly.md +++ b/docs/platforms/fly.md @@ -39,7 +39,9 @@ fly volumes create clawdbot_data --size 1 --region iad ## 2) Configure fly.toml -Edit `fly.toml` to match your app name and requirements: +Edit `fly.toml` to match your app name and requirements. + +**Security note:** The default config exposes a public URL. For a hardened deployment with no public IP, see [Private Deployment](#private-deployment-hardened) or use `fly.private.toml`. ```toml app = "my-clawdbot" # Your app name @@ -104,6 +106,7 @@ fly secrets set DISCORD_BOT_TOKEN=MTQ... **Notes:** - Non-loopback binds (`--bind lan`) require `CLAWDBOT_GATEWAY_TOKEN` for security. - Treat these tokens like passwords. +- **Prefer env vars over config file** for all API keys and tokens. This keeps secrets out of `clawdbot.json` where they could be accidentally exposed or logged. ## 4) Deploy @@ -337,6 +340,110 @@ fly machine update --vm-memory 2048 --command "node dist/index.js g **Note:** After `fly deploy`, the machine command may reset to what's in `fly.toml`. If you made manual changes, re-apply them after deploy. +## Private Deployment (Hardened) + +By default, Fly allocates public IPs, making your gateway accessible at `https://your-app.fly.dev`. This is convenient but means your deployment is discoverable by internet scanners (Shodan, Censys, etc.). + +For a hardened deployment with **no public exposure**, use the private template. + +### When to use private deployment + +- You only make **outbound** calls/messages (no inbound webhooks) +- You use **ngrok or Tailscale** tunnels for any webhook callbacks +- You access the gateway via **SSH, proxy, or WireGuard** instead of browser +- You want the deployment **hidden from internet scanners** + +### Setup + +Use `fly.private.toml` instead of the standard config: + +```bash +# Deploy with private config +fly deploy -c fly.private.toml +``` + +Or convert an existing deployment: + +```bash +# List current IPs +fly ips list -a my-clawdbot + +# Release public IPs +fly ips release -a my-clawdbot +fly ips release -a my-clawdbot + +# Allocate private-only IPv6 +fly ips allocate-v6 --private -a my-clawdbot +``` + +After this, `fly ips list` should show only a `private` type IP: +``` +VERSION IP TYPE REGION +v6 fdaa:x:x:x:x::x private global +``` + +### Accessing a private deployment + +Since there's no public URL, use one of these methods: + +**Option 1: Local proxy (simplest)** +```bash +# Forward local port 3000 to the app +fly proxy 3000:3000 -a my-clawdbot + +# Then open http://localhost:3000 in browser +``` + +**Option 2: WireGuard VPN** +```bash +# Create WireGuard config (one-time) +fly wireguard create + +# Import to WireGuard client, then access via internal IPv6 +# Example: http://[fdaa:x:x:x:x::x]:3000 +``` + +**Option 3: SSH only** +```bash +fly ssh console -a my-clawdbot +``` + +### Webhooks with private deployment + +If you need webhook callbacks (Twilio, Telnyx, etc.) without public exposure: + +1. **ngrok tunnel** - Run ngrok inside the container or as a sidecar +2. **Tailscale Funnel** - Expose specific paths via Tailscale +3. **Outbound-only** - Some providers (Twilio) work fine for outbound calls without webhooks + +Example voice-call config with ngrok: +```json +{ + "plugins": { + "entries": { + "voice-call": { + "enabled": true, + "config": { + "provider": "twilio", + "tunnel": { "provider": "ngrok" } + } + } + } + } +} +``` + +The ngrok tunnel runs inside the container and provides a public webhook URL without exposing the Fly app itself. + +### Security benefits + +| Aspect | Public | Private | +|--------|--------|---------| +| Internet scanners | Discoverable | Hidden | +| Direct attacks | Possible | Blocked | +| Control UI access | Browser | Proxy/VPN | +| Webhook delivery | Direct | Via tunnel | + ## Notes - Fly.io uses **x86 architecture** (not ARM) diff --git a/fly.private.toml b/fly.private.toml new file mode 100644 index 000000000..153bf5434 --- /dev/null +++ b/fly.private.toml @@ -0,0 +1,39 @@ +# Clawdbot Fly.io PRIVATE deployment configuration +# Use this template for hardened deployments with no public IP exposure. +# +# This config is suitable when: +# - You only make outbound calls (no inbound webhooks needed) +# - You use ngrok/Tailscale tunnels for any webhook callbacks +# - You access the gateway via `fly proxy` or WireGuard, not public URL +# - You want the deployment hidden from internet scanners (Shodan, etc.) +# +# See https://fly.io/docs/reference/configuration/ + +app = "clawdbot" +primary_region = "iad" # change to your closest region + +[build] + dockerfile = "Dockerfile" + +[env] + NODE_ENV = "production" + CLAWDBOT_PREFER_PNPM = "1" + CLAWDBOT_STATE_DIR = "/data" + NODE_OPTIONS = "--max-old-space-size=1536" + +[processes] + app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan" + +# NOTE: No [http_service] block = no public ingress allocated. +# The gateway will only be accessible via: +# - fly proxy 3000:3000 -a +# - fly wireguard (then access via internal IPv6) +# - fly ssh console + +[[vm]] + size = "shared-cpu-2x" + memory = "2048mb" + +[mounts] + source = "clawdbot_data" + destination = "/data" From 5b6a211583c4c6c282a652139a5174c511008a2e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 16:55:29 +0000 Subject: [PATCH 18/40] docs: tighten fly private deployment steps --- docs/platforms/fly.md | 4 ++++ fly.private.toml | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/platforms/fly.md b/docs/platforms/fly.md index 2b1e97483..dee731ea7 100644 --- a/docs/platforms/fly.md +++ b/docs/platforms/fly.md @@ -372,6 +372,10 @@ fly ips list -a my-clawdbot fly ips release -a my-clawdbot fly ips release -a my-clawdbot +# Switch to private config so future deploys don't re-allocate public IPs +# (remove [http_service] or deploy with the private template) +fly deploy -c fly.private.toml + # Allocate private-only IPv6 fly ips allocate-v6 --private -a my-clawdbot ``` diff --git a/fly.private.toml b/fly.private.toml index 153bf5434..6edbc8005 100644 --- a/fly.private.toml +++ b/fly.private.toml @@ -9,7 +9,7 @@ # # See https://fly.io/docs/reference/configuration/ -app = "clawdbot" +app = "my-clawdbot" # change to your app name primary_region = "iad" # change to your closest region [build] From c01cc61f9ae267cd3cc20287fcf7c7ae0e7ee74f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 16:58:01 +0000 Subject: [PATCH 19/40] docs: note fly private deployment fixups (#2289) (thanks @dguido) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fb9388a9..66fca971c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot Status: unreleased. ### Changes +- Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido. - Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos. - Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. - Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro. From ce60c6db1b2ec22796e13f62eb8fa59839749ffc Mon Sep 17 00:00:00 2001 From: Joshua Mitchell Date: Sun, 25 Jan 2026 13:26:37 -0600 Subject: [PATCH 20/40] feat(telegram): implement sendPayload for channelData support Add sendPayload handler to Telegram outbound adapter to support channel-specific data via the channelData pattern. This enables features like inline keyboard buttons without custom ReplyPayload fields. Implementation: - Extract telegram.buttons from payload.channelData - Pass buttons to sendMessageTelegram (already supports this) - Follows existing sendText/sendMedia patterns - Completes optional ChannelOutboundAdapter.sendPayload interface This enables plugins to send Telegram-specific features (buttons, etc.) using the standard channelData envelope pattern instead of custom fields. Related: delivery system in src/infra/outbound/deliver.ts:324 already checks for sendPayload handler and routes accordingly. Co-Authored-By: Claude Sonnet 4.5 --- src/channels/plugins/outbound/telegram.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 9b138705a..6732f5ea0 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -50,4 +50,24 @@ export const telegramOutbound: ChannelOutboundAdapter = { }); return { channel: "telegram", ...result }; }, + sendPayload: async ({ to, payload, accountId, deps, replyToId, threadId }) => { + const send = deps?.sendTelegram ?? sendMessageTelegram; + const replyToMessageId = parseReplyToMessageId(replyToId); + const messageThreadId = parseThreadId(threadId); + + // Extract Telegram-specific data from channelData + const telegramData = payload.channelData?.telegram as + | { buttons?: Array> } + | undefined; + + const result = await send(to, payload.text ?? "", { + verbose: false, + textMode: "html", + messageThreadId, + replyToMessageId, + accountId: accountId ?? undefined, + buttons: telegramData?.buttons, + }); + return { channel: "telegram", ...result }; + }, }; From 0e3340d1fc90d5b99853d9dda6f6807843fbc6c3 Mon Sep 17 00:00:00 2001 From: Joshua Mitchell Date: Sun, 25 Jan 2026 15:09:01 -0600 Subject: [PATCH 21/40] feat(plugins): sync plugin commands to Telegram menu and export gateway types - Add plugin command specs to Telegram setMyCommands for autocomplete - Export GatewayRequestHandler types in plugin-sdk for plugin authors - Enables plugins to register gateway methods and appear in command menus --- src/plugin-sdk/index.ts | 5 +++++ src/telegram/bot-native-commands.ts | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 60782ff6d..c0c201ff0 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -63,6 +63,11 @@ export type { ClawdbotPluginService, ClawdbotPluginServiceContext, } from "../plugins/types.js"; +export type { + GatewayRequestHandler, + GatewayRequestHandlerOptions, + RespondFn, +} from "../gateway/server-methods/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export { normalizePluginHttpPath } from "../plugins/http-path.js"; export { registerPluginHttpRoute } from "../plugins/http-registry.js"; diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 0f1cc1cb7..1751ebb09 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -20,6 +20,7 @@ import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js"; +import { getPluginCommandSpecs } from "../plugins/commands.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js"; import type { ReplyToMode, @@ -103,11 +104,16 @@ export const registerTelegramNativeCommands = ({ runtime.error?.(danger(issue.message)); } const customCommands = customResolution.commands; + const pluginCommandSpecs = getPluginCommandSpecs(); const allCommands: Array<{ command: string; description: string }> = [ ...nativeCommands.map((command) => ({ command: command.name, description: command.description, })), + ...pluginCommandSpecs.map((spec) => ({ + command: spec.name, + description: spec.description, + })), ...customCommands, ]; From b8e6f0b135a95118d545c17ff72bd8b1a97743a1 Mon Sep 17 00:00:00 2001 From: Joshua Mitchell Date: Sun, 25 Jan 2026 15:44:52 -0600 Subject: [PATCH 22/40] fix(telegram): register bot.command handlers for plugin commands Plugin commands were added to setMyCommands menu but didn't have bot.command() handlers registered. This meant /flow-start and other plugin commands would fall through to the general message handler instead of being dispatched to the plugin command executor. Now we register bot.command() handlers for each plugin command, with full authorization checks and proper result delivery. --- src/telegram/bot-native-commands.ts | 149 +++++++++++++++++++++++++++- 1 file changed, 148 insertions(+), 1 deletion(-) diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 1751ebb09..c29df4733 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -20,7 +20,11 @@ import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js"; -import { getPluginCommandSpecs } from "../plugins/commands.js"; +import { + getPluginCommandSpecs, + matchPluginCommand, + executePluginCommand, +} from "../plugins/commands.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js"; import type { ReplyToMode, @@ -368,6 +372,149 @@ export const registerTelegramNativeCommands = ({ }); }); } + + // Register handlers for plugin commands + for (const pluginSpec of pluginCommandSpecs) { + bot.command(pluginSpec.name, async (ctx: TelegramNativeCommandContext) => { + const msg = ctx.message; + if (!msg) return; + if (shouldSkipUpdate(ctx)) return; + const chatId = msg.chat.id; + const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; + const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; + const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; + const resolvedThreadId = resolveTelegramForumThreadId({ + isForum, + messageThreadId, + }); + const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); + const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId); + const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom); + const effectiveGroupAllow = normalizeAllowFromWithStore({ + allowFrom: groupAllowOverride ?? groupAllowFrom, + storeAllowFrom, + }); + const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined"; + + if (isGroup && groupConfig?.enabled === false) { + await bot.api.sendMessage(chatId, "This group is disabled."); + return; + } + if (isGroup && topicConfig?.enabled === false) { + await bot.api.sendMessage(chatId, "This topic is disabled."); + return; + } + if (isGroup && hasGroupAllowOverride) { + const senderId = msg.from?.id; + const senderUsername = msg.from?.username ?? ""; + if ( + senderId == null || + !isSenderAllowed({ + allow: effectiveGroupAllow, + senderId: String(senderId), + senderUsername, + }) + ) { + await bot.api.sendMessage(chatId, "You are not authorized to use this command."); + return; + } + } + + if (isGroup && useAccessGroups) { + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; + if (groupPolicy === "disabled") { + await bot.api.sendMessage(chatId, "Telegram group commands are disabled."); + return; + } + if (groupPolicy === "allowlist") { + const senderId = msg.from?.id; + if (senderId == null) { + await bot.api.sendMessage(chatId, "You are not authorized to use this command."); + return; + } + const senderUsername = msg.from?.username ?? ""; + if ( + !isSenderAllowed({ + allow: effectiveGroupAllow, + senderId: String(senderId), + senderUsername, + }) + ) { + await bot.api.sendMessage(chatId, "You are not authorized to use this command."); + return; + } + } + const groupAllowlist = resolveGroupPolicy(chatId); + if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) { + await bot.api.sendMessage(chatId, "This group is not allowed."); + return; + } + } + + const senderId = msg.from?.id ? String(msg.from.id) : ""; + const senderUsername = msg.from?.username ?? ""; + const dmAllow = normalizeAllowFromWithStore({ + allowFrom: allowFrom, + storeAllowFrom, + }); + const senderAllowed = isSenderAllowed({ + allow: dmAllow, + senderId, + senderUsername, + }); + const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }], + modeWhenAccessGroupsOff: "configured", + }); + if (!commandAuthorized) { + await bot.api.sendMessage(chatId, "You are not authorized to use this command."); + return; + } + + // Match and execute plugin command + const rawText = ctx.match?.trim() ?? ""; + const commandBody = `/${pluginSpec.name}${rawText ? ` ${rawText}` : ""}`; + const match = matchPluginCommand(commandBody); + if (!match) { + await bot.api.sendMessage(chatId, "Command not found."); + return; + } + + const result = await executePluginCommand({ + command: match.command, + args: match.args, + senderId, + channel: "telegram", + isAuthorizedSender: commandAuthorized, + commandBody, + config: cfg, + }); + + // Deliver the result + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId, + }); + const chunkMode = resolveChunkMode(cfg, "telegram", accountId); + + await deliverReplies({ + replies: [result], + chatId: String(chatId), + token: opts.token, + runtime, + bot, + replyToMode, + textLimit, + messageThreadId: resolvedThreadId, + tableMode, + chunkMode, + linkPreview: telegramCfg.linkPreview, + }); + }); + } } } else if (nativeDisabledExplicit) { bot.api.setMyCommands([]).catch((err) => { From db2395744b4562975e3c9568b13b426c2aab4058 Mon Sep 17 00:00:00 2001 From: Joshua Mitchell Date: Sun, 25 Jan 2026 16:15:40 -0600 Subject: [PATCH 23/40] fix(telegram): extract and send buttons from channelData Plugin commands can return buttons in channelData.telegram.buttons, but deliverReplies() was ignoring them. Now we: 1. Extract buttons from reply.channelData?.telegram?.buttons 2. Build inline keyboard using buildInlineKeyboard() 3. Pass reply_markup to sendMessage() Buttons are attached to the first text chunk when text is chunked. --- src/telegram/bot/delivery.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 4edc91c8a..2bc913ed1 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -18,6 +18,7 @@ import { saveMediaBuffer } from "../../media/store.js"; import type { RuntimeEnv } from "../../runtime.js"; import { loadWebMedia } from "../../web/media.js"; import { resolveTelegramVoiceSend } from "../voice.js"; +import { buildInlineKeyboard } from "../send.js"; import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js"; import type { TelegramContext } from "./types.js"; @@ -81,8 +82,18 @@ export async function deliverReplies(params: { ? [reply.mediaUrl] : []; if (mediaList.length === 0) { + // Extract Telegram buttons from channelData + const telegramData = reply.channelData?.telegram as + | { buttons?: Array> } + | undefined; + const replyMarkup = buildInlineKeyboard(telegramData?.buttons); + const chunks = chunkText(reply.text || ""); - for (const chunk of chunks) { + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + if (!chunk) continue; + // Only attach buttons to the first chunk + const shouldAttachButtons = i === 0 && replyMarkup; await sendTelegramText(bot, chatId, chunk.html, runtime, { replyToMessageId: replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined, @@ -90,6 +101,7 @@ export async function deliverReplies(params: { textMode: "html", plainText: chunk.text, linkPreview, + replyMarkup: shouldAttachButtons ? replyMarkup : undefined, }); if (replyToId && !hasReplied) { hasReplied = true; @@ -322,6 +334,7 @@ async function sendTelegramText( textMode?: "markdown" | "html"; plainText?: string; linkPreview?: boolean; + replyMarkup?: ReturnType; }, ): Promise { const baseParams = buildTelegramSendParams({ @@ -337,6 +350,7 @@ async function sendTelegramText( const res = await bot.api.sendMessage(chatId, htmlText, { parse_mode: "HTML", ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), + ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), ...baseParams, }); return res.message_id; @@ -347,6 +361,7 @@ async function sendTelegramText( const fallbackText = opts?.plainText ?? text; const res = await bot.api.sendMessage(chatId, fallbackText, { ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), + ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), ...baseParams, }); return res.message_id; From 94ead83ba47f501d48fe8e0ccc5662e283db3cef Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Mon, 26 Jan 2026 22:25:55 +0530 Subject: [PATCH 24/40] fix: telegram sendPayload and plugin auth (#1917) (thanks @JoshuaLelon) --- CHANGELOG.md | 1 + .../plugins/outbound/telegram.test.ts | 81 ++++ src/channels/plugins/outbound/telegram.ts | 39 +- .../bot-native-commands.plugin-auth.test.ts | 106 +++++ src/telegram/bot-native-commands.ts | 416 ++++++++++-------- src/telegram/bot/delivery.ts | 28 +- 6 files changed, 457 insertions(+), 214 deletions(-) create mode 100644 src/channels/plugins/outbound/telegram.test.ts create mode 100644 src/telegram/bot-native-commands.plugin-auth.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 66fca971c..f8dff89cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Status: unreleased. - Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev. - Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg. - Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra. +- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon. - Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco. - Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla. - Routing: precompile session key regexes. (#1697) Thanks @Ray0907. diff --git a/src/channels/plugins/outbound/telegram.test.ts b/src/channels/plugins/outbound/telegram.test.ts new file mode 100644 index 000000000..3bbab0cee --- /dev/null +++ b/src/channels/plugins/outbound/telegram.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../../../config/config.js"; +import { telegramOutbound } from "./telegram.js"; + +describe("telegramOutbound.sendPayload", () => { + it("sends text payload with buttons", async () => { + const sendTelegram = vi.fn(async () => ({ messageId: "m1", chatId: "c1" })); + + const result = await telegramOutbound.sendPayload?.({ + cfg: {} as ClawdbotConfig, + to: "telegram:123", + text: "ignored", + payload: { + text: "Hello", + channelData: { + telegram: { + buttons: [[{ text: "Option", callback_data: "/option" }]], + }, + }, + }, + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledTimes(1); + expect(sendTelegram).toHaveBeenCalledWith( + "telegram:123", + "Hello", + expect.objectContaining({ + buttons: [[{ text: "Option", callback_data: "/option" }]], + textMode: "html", + }), + ); + expect(result).toEqual({ channel: "telegram", messageId: "m1", chatId: "c1" }); + }); + + it("sends media payloads and attaches buttons only to first", async () => { + const sendTelegram = vi + .fn() + .mockResolvedValueOnce({ messageId: "m1", chatId: "c1" }) + .mockResolvedValueOnce({ messageId: "m2", chatId: "c1" }); + + const result = await telegramOutbound.sendPayload?.({ + cfg: {} as ClawdbotConfig, + to: "telegram:123", + text: "ignored", + payload: { + text: "Caption", + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + channelData: { + telegram: { + buttons: [[{ text: "Go", callback_data: "/go" }]], + }, + }, + }, + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledTimes(2); + expect(sendTelegram).toHaveBeenNthCalledWith( + 1, + "telegram:123", + "Caption", + expect.objectContaining({ + mediaUrl: "https://example.com/a.png", + buttons: [[{ text: "Go", callback_data: "/go" }]], + }), + ); + const secondOpts = sendTelegram.mock.calls[1]?.[2] as { buttons?: unknown } | undefined; + expect(sendTelegram).toHaveBeenNthCalledWith( + 2, + "telegram:123", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/b.png", + }), + ); + expect(secondOpts?.buttons).toBeUndefined(); + expect(result).toEqual({ channel: "telegram", messageId: "m2", chatId: "c1" }); + }); +}); diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 6732f5ea0..6db7afd28 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -18,6 +18,7 @@ function parseThreadId(threadId?: string | number | null) { const parsed = Number.parseInt(trimmed, 10); return Number.isFinite(parsed) ? parsed : undefined; } + export const telegramOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: markdownToTelegramHtmlChunks, @@ -54,20 +55,42 @@ export const telegramOutbound: ChannelOutboundAdapter = { const send = deps?.sendTelegram ?? sendMessageTelegram; const replyToMessageId = parseReplyToMessageId(replyToId); const messageThreadId = parseThreadId(threadId); - - // Extract Telegram-specific data from channelData const telegramData = payload.channelData?.telegram as | { buttons?: Array> } | undefined; - - const result = await send(to, payload.text ?? "", { + const text = payload.text ?? ""; + const mediaUrls = payload.mediaUrls?.length + ? payload.mediaUrls + : payload.mediaUrl + ? [payload.mediaUrl] + : []; + const baseOpts = { verbose: false, - textMode: "html", + textMode: "html" as const, messageThreadId, replyToMessageId, accountId: accountId ?? undefined, - buttons: telegramData?.buttons, - }); - return { channel: "telegram", ...result }; + }; + + if (mediaUrls.length === 0) { + const result = await send(to, text, { + ...baseOpts, + buttons: telegramData?.buttons, + }); + return { channel: "telegram", ...result }; + } + + // Telegram allows reply_markup on media; attach buttons only to first send. + let finalResult: Awaited> | undefined; + for (let i = 0; i < mediaUrls.length; i += 1) { + const mediaUrl = mediaUrls[i]; + const isFirst = i === 0; + finalResult = await send(to, isFirst ? text : "", { + ...baseOpts, + mediaUrl, + ...(isFirst ? { buttons: telegramData?.buttons } : {}), + }); + } + return { channel: "telegram", ...(finalResult ?? { messageId: "unknown", chatId: to }) }; }, }; diff --git a/src/telegram/bot-native-commands.plugin-auth.test.ts b/src/telegram/bot-native-commands.plugin-auth.test.ts new file mode 100644 index 000000000..5da5f0453 --- /dev/null +++ b/src/telegram/bot-native-commands.plugin-auth.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ChannelGroupPolicy } from "../config/group-policy.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import type { TelegramAccountConfig } from "../config/types.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { registerTelegramNativeCommands } from "./bot-native-commands.js"; + +const getPluginCommandSpecs = vi.hoisted(() => vi.fn()); +const matchPluginCommand = vi.hoisted(() => vi.fn()); +const executePluginCommand = vi.hoisted(() => vi.fn()); + +vi.mock("../plugins/commands.js", () => ({ + getPluginCommandSpecs, + matchPluginCommand, + executePluginCommand, +})); + +const deliverReplies = vi.hoisted(() => vi.fn(async () => {})); +vi.mock("./bot/delivery.js", () => ({ deliverReplies })); + +vi.mock("./pairing-store.js", () => ({ + readTelegramAllowFromStore: vi.fn(async () => []), +})); + +describe("registerTelegramNativeCommands (plugin auth)", () => { + it("allows requireAuth:false plugin command even when sender is unauthorized", async () => { + const command = { + name: "plugin", + description: "Plugin command", + requireAuth: false, + handler: vi.fn(), + } as const; + + getPluginCommandSpecs.mockReturnValue([{ name: "plugin", description: "Plugin command" }]); + matchPluginCommand.mockReturnValue({ command, args: undefined }); + executePluginCommand.mockResolvedValue({ text: "ok" }); + + const handlers: Record Promise> = {}; + const bot = { + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage: vi.fn(), + }, + command: (name: string, handler: (ctx: unknown) => Promise) => { + handlers[name] = handler; + }, + } as const; + + const cfg = {} as ClawdbotConfig; + const telegramCfg = {} as TelegramAccountConfig; + const resolveGroupPolicy = () => + ({ + allowlistEnabled: false, + allowed: true, + }) as ChannelGroupPolicy; + + registerTelegramNativeCommands({ + bot: bot as unknown as Parameters[0]["bot"], + cfg, + runtime: {} as RuntimeEnv, + accountId: "default", + telegramCfg, + allowFrom: ["999"], + groupAllowFrom: [], + replyToMode: "off", + textLimit: 4000, + useAccessGroups: false, + nativeEnabled: false, + nativeSkillsEnabled: false, + nativeDisabledExplicit: false, + resolveGroupPolicy, + resolveTelegramGroupConfig: () => ({ + groupConfig: undefined, + topicConfig: undefined, + }), + shouldSkipUpdate: () => false, + opts: { token: "token" }, + }); + + const ctx = { + message: { + chat: { id: 123, type: "private" }, + from: { id: 111, username: "nope" }, + message_id: 10, + date: 123456, + }, + match: "", + }; + + await handlers.plugin?.(ctx); + + expect(matchPluginCommand).toHaveBeenCalled(); + expect(executePluginCommand).toHaveBeenCalledWith( + expect.objectContaining({ + isAuthorizedSender: false, + }), + ); + expect(deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + replies: [{ text: "ok" }], + }), + ); + expect(bot.api.sendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index c29df4733..c33f1e18e 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -17,13 +17,17 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/pr import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { danger, logVerbose } from "../globals.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; +import { + normalizeTelegramCommandName, + TELEGRAM_COMMAND_NAME_PATTERN, +} from "../config/telegram-custom-commands.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../routing/session-key.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js"; import { + executePluginCommand, getPluginCommandSpecs, matchPluginCommand, - executePluginCommand, } from "../plugins/commands.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js"; import type { @@ -47,6 +51,18 @@ import { readTelegramAllowFromStore } from "./pairing-store.js"; type TelegramNativeCommandContext = Context & { match?: string }; +type TelegramCommandAuthResult = { + chatId: number; + isGroup: boolean; + isForum: boolean; + resolvedThreadId?: number; + senderId: string; + senderUsername: string; + groupConfig?: TelegramGroupConfig; + topicConfig?: TelegramTopicConfig; + commandAuthorized: boolean; +}; + type RegisterTelegramNativeCommandsParams = { bot: Bot; cfg: ClawdbotConfig; @@ -70,6 +86,134 @@ type RegisterTelegramNativeCommandsParams = { opts: { token: string }; }; +async function resolveTelegramCommandAuth(params: { + msg: NonNullable; + bot: Bot; + cfg: ClawdbotConfig; + telegramCfg: TelegramAccountConfig; + allowFrom?: Array; + groupAllowFrom?: Array; + useAccessGroups: boolean; + resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy; + resolveTelegramGroupConfig: ( + chatId: string | number, + messageThreadId?: number, + ) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig }; + requireAuth: boolean; +}): Promise { + const { + msg, + bot, + cfg, + telegramCfg, + allowFrom, + groupAllowFrom, + useAccessGroups, + resolveGroupPolicy, + resolveTelegramGroupConfig, + requireAuth, + } = params; + const chatId = msg.chat.id; + const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; + const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; + const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; + const resolvedThreadId = resolveTelegramForumThreadId({ + isForum, + messageThreadId, + }); + const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); + const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId); + const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom); + const effectiveGroupAllow = normalizeAllowFromWithStore({ + allowFrom: groupAllowOverride ?? groupAllowFrom, + storeAllowFrom, + }); + const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined"; + const senderIdRaw = msg.from?.id; + const senderId = senderIdRaw ? String(senderIdRaw) : ""; + const senderUsername = msg.from?.username ?? ""; + + if (isGroup && groupConfig?.enabled === false) { + await bot.api.sendMessage(chatId, "This group is disabled."); + return null; + } + if (isGroup && topicConfig?.enabled === false) { + await bot.api.sendMessage(chatId, "This topic is disabled."); + return null; + } + if (requireAuth && isGroup && hasGroupAllowOverride) { + if ( + senderIdRaw == null || + !isSenderAllowed({ + allow: effectiveGroupAllow, + senderId: String(senderIdRaw), + senderUsername, + }) + ) { + await bot.api.sendMessage(chatId, "You are not authorized to use this command."); + return null; + } + } + + if (isGroup && useAccessGroups) { + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; + if (groupPolicy === "disabled") { + await bot.api.sendMessage(chatId, "Telegram group commands are disabled."); + return null; + } + if (groupPolicy === "allowlist" && requireAuth) { + if ( + senderIdRaw == null || + !isSenderAllowed({ + allow: effectiveGroupAllow, + senderId: String(senderIdRaw), + senderUsername, + }) + ) { + await bot.api.sendMessage(chatId, "You are not authorized to use this command."); + return null; + } + } + const groupAllowlist = resolveGroupPolicy(chatId); + if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) { + await bot.api.sendMessage(chatId, "This group is not allowed."); + return null; + } + } + + const dmAllow = normalizeAllowFromWithStore({ + allowFrom: allowFrom, + storeAllowFrom, + }); + const senderAllowed = isSenderAllowed({ + allow: dmAllow, + senderId, + senderUsername, + }); + const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }], + modeWhenAccessGroupsOff: "configured", + }); + if (requireAuth && !commandAuthorized) { + await bot.api.sendMessage(chatId, "You are not authorized to use this command."); + return null; + } + + return { + chatId, + isGroup, + isForum, + resolvedThreadId, + senderId, + senderUsername, + groupConfig, + topicConfig, + commandAuthorized, + }; +} + export const registerTelegramNativeCommands = ({ bot, cfg, @@ -109,15 +253,49 @@ export const registerTelegramNativeCommands = ({ } const customCommands = customResolution.commands; const pluginCommandSpecs = getPluginCommandSpecs(); + const pluginCommands: Array<{ command: string; description: string }> = []; + const existingCommands = new Set( + [ + ...nativeCommands.map((command) => command.name), + ...customCommands.map((command) => command.command), + ].map((command) => command.toLowerCase()), + ); + const pluginCommandNames = new Set(); + for (const spec of pluginCommandSpecs) { + const normalized = normalizeTelegramCommandName(spec.name); + if (!normalized || !TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) { + runtime.error?.( + danger( + `Plugin command "/${spec.name}" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).`, + ), + ); + continue; + } + const description = spec.description.trim(); + if (!description) { + runtime.error?.(danger(`Plugin command "/${normalized}" is missing a description.`)); + continue; + } + if (existingCommands.has(normalized)) { + runtime.error?.( + danger(`Plugin command "/${normalized}" conflicts with an existing Telegram command.`), + ); + continue; + } + if (pluginCommandNames.has(normalized)) { + runtime.error?.(danger(`Plugin command "/${normalized}" is duplicated.`)); + continue; + } + pluginCommandNames.add(normalized); + existingCommands.add(normalized); + pluginCommands.push({ command: normalized, description }); + } const allCommands: Array<{ command: string; description: string }> = [ ...nativeCommands.map((command) => ({ command: command.name, description: command.description, })), - ...pluginCommandSpecs.map((spec) => ({ - command: spec.name, - description: spec.description, - })), + ...pluginCommands, ...customCommands, ]; @@ -134,99 +312,30 @@ export const registerTelegramNativeCommands = ({ const msg = ctx.message; if (!msg) return; if (shouldSkipUpdate(ctx)) return; - const chatId = msg.chat.id; - const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; - const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; - const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; - const resolvedThreadId = resolveTelegramForumThreadId({ + const auth = await resolveTelegramCommandAuth({ + msg, + bot, + cfg, + telegramCfg, + allowFrom, + groupAllowFrom, + useAccessGroups, + resolveGroupPolicy, + resolveTelegramGroupConfig, + requireAuth: true, + }); + if (!auth) return; + const { + chatId, + isGroup, isForum, - messageThreadId, - }); - const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); - const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId); - const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom); - const effectiveGroupAllow = normalizeAllowFromWithStore({ - allowFrom: groupAllowOverride ?? groupAllowFrom, - storeAllowFrom, - }); - const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined"; - - if (isGroup && groupConfig?.enabled === false) { - await bot.api.sendMessage(chatId, "This group is disabled."); - return; - } - if (isGroup && topicConfig?.enabled === false) { - await bot.api.sendMessage(chatId, "This topic is disabled."); - return; - } - if (isGroup && hasGroupAllowOverride) { - const senderId = msg.from?.id; - const senderUsername = msg.from?.username ?? ""; - if ( - senderId == null || - !isSenderAllowed({ - allow: effectiveGroupAllow, - senderId: String(senderId), - senderUsername, - }) - ) { - await bot.api.sendMessage(chatId, "You are not authorized to use this command."); - return; - } - } - - if (isGroup && useAccessGroups) { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; - if (groupPolicy === "disabled") { - await bot.api.sendMessage(chatId, "Telegram group commands are disabled."); - return; - } - if (groupPolicy === "allowlist") { - const senderId = msg.from?.id; - if (senderId == null) { - await bot.api.sendMessage(chatId, "You are not authorized to use this command."); - return; - } - const senderUsername = msg.from?.username ?? ""; - if ( - !isSenderAllowed({ - allow: effectiveGroupAllow, - senderId: String(senderId), - senderUsername, - }) - ) { - await bot.api.sendMessage(chatId, "You are not authorized to use this command."); - return; - } - } - const groupAllowlist = resolveGroupPolicy(chatId); - if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) { - await bot.api.sendMessage(chatId, "This group is not allowed."); - return; - } - } - - const senderId = msg.from?.id ? String(msg.from.id) : ""; - const senderUsername = msg.from?.username ?? ""; - const dmAllow = normalizeAllowFromWithStore({ - allowFrom: allowFrom, - storeAllowFrom, - }); - const senderAllowed = isSenderAllowed({ - allow: dmAllow, + resolvedThreadId, senderId, senderUsername, - }); - const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }], - modeWhenAccessGroupsOff: "configured", - }); - if (!commandAuthorized) { - await bot.api.sendMessage(chatId, "You are not authorized to use this command."); - return; - } + groupConfig, + topicConfig, + commandAuthorized, + } = auth; const commandDefinition = findCommandByNativeName(command.name, "telegram"); const rawText = ctx.match?.trim() ?? ""; @@ -373,114 +482,33 @@ export const registerTelegramNativeCommands = ({ }); } - // Register handlers for plugin commands - for (const pluginSpec of pluginCommandSpecs) { - bot.command(pluginSpec.name, async (ctx: TelegramNativeCommandContext) => { + for (const pluginCommand of pluginCommands) { + bot.command(pluginCommand.command, async (ctx: TelegramNativeCommandContext) => { const msg = ctx.message; if (!msg) return; if (shouldSkipUpdate(ctx)) return; const chatId = msg.chat.id; - const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup"; - const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; - const isForum = (msg.chat as { is_forum?: boolean }).is_forum === true; - const resolvedThreadId = resolveTelegramForumThreadId({ - isForum, - messageThreadId, - }); - const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []); - const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId); - const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom); - const effectiveGroupAllow = normalizeAllowFromWithStore({ - allowFrom: groupAllowOverride ?? groupAllowFrom, - storeAllowFrom, - }); - const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined"; - - if (isGroup && groupConfig?.enabled === false) { - await bot.api.sendMessage(chatId, "This group is disabled."); - return; - } - if (isGroup && topicConfig?.enabled === false) { - await bot.api.sendMessage(chatId, "This topic is disabled."); - return; - } - if (isGroup && hasGroupAllowOverride) { - const senderId = msg.from?.id; - const senderUsername = msg.from?.username ?? ""; - if ( - senderId == null || - !isSenderAllowed({ - allow: effectiveGroupAllow, - senderId: String(senderId), - senderUsername, - }) - ) { - await bot.api.sendMessage(chatId, "You are not authorized to use this command."); - return; - } - } - - if (isGroup && useAccessGroups) { - const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; - const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open"; - if (groupPolicy === "disabled") { - await bot.api.sendMessage(chatId, "Telegram group commands are disabled."); - return; - } - if (groupPolicy === "allowlist") { - const senderId = msg.from?.id; - if (senderId == null) { - await bot.api.sendMessage(chatId, "You are not authorized to use this command."); - return; - } - const senderUsername = msg.from?.username ?? ""; - if ( - !isSenderAllowed({ - allow: effectiveGroupAllow, - senderId: String(senderId), - senderUsername, - }) - ) { - await bot.api.sendMessage(chatId, "You are not authorized to use this command."); - return; - } - } - const groupAllowlist = resolveGroupPolicy(chatId); - if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) { - await bot.api.sendMessage(chatId, "This group is not allowed."); - return; - } - } - - const senderId = msg.from?.id ? String(msg.from.id) : ""; - const senderUsername = msg.from?.username ?? ""; - const dmAllow = normalizeAllowFromWithStore({ - allowFrom: allowFrom, - storeAllowFrom, - }); - const senderAllowed = isSenderAllowed({ - allow: dmAllow, - senderId, - senderUsername, - }); - const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups, - authorizers: [{ configured: dmAllow.hasEntries, allowed: senderAllowed }], - modeWhenAccessGroupsOff: "configured", - }); - if (!commandAuthorized) { - await bot.api.sendMessage(chatId, "You are not authorized to use this command."); - return; - } - - // Match and execute plugin command const rawText = ctx.match?.trim() ?? ""; - const commandBody = `/${pluginSpec.name}${rawText ? ` ${rawText}` : ""}`; + const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`; const match = matchPluginCommand(commandBody); if (!match) { await bot.api.sendMessage(chatId, "Command not found."); return; } + const auth = await resolveTelegramCommandAuth({ + msg, + bot, + cfg, + telegramCfg, + allowFrom, + groupAllowFrom, + useAccessGroups, + resolveGroupPolicy, + resolveTelegramGroupConfig, + requireAuth: match.command.requireAuth !== false, + }); + if (!auth) return; + const { resolvedThreadId, senderId, commandAuthorized } = auth; const result = await executePluginCommand({ command: match.command, @@ -491,8 +519,6 @@ export const registerTelegramNativeCommands = ({ commandBody, config: cfg, }); - - // Deliver the result const tableMode = resolveMarkdownTableMode({ cfg, channel: "telegram", diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 2bc913ed1..36a680227 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -17,8 +17,8 @@ import { isGifMedia } from "../../media/mime.js"; import { saveMediaBuffer } from "../../media/store.js"; import type { RuntimeEnv } from "../../runtime.js"; import { loadWebMedia } from "../../web/media.js"; -import { resolveTelegramVoiceSend } from "../voice.js"; import { buildInlineKeyboard } from "../send.js"; +import { resolveTelegramVoiceSend } from "../voice.js"; import { buildTelegramThreadParams, resolveTelegramReplyId } from "./helpers.js"; import type { TelegramContext } from "./types.js"; @@ -81,18 +81,16 @@ export async function deliverReplies(params: { : reply.mediaUrl ? [reply.mediaUrl] : []; + const telegramData = reply.channelData?.telegram as + | { buttons?: Array> } + | undefined; + const replyMarkup = buildInlineKeyboard(telegramData?.buttons); if (mediaList.length === 0) { - // Extract Telegram buttons from channelData - const telegramData = reply.channelData?.telegram as - | { buttons?: Array> } - | undefined; - const replyMarkup = buildInlineKeyboard(telegramData?.buttons); - const chunks = chunkText(reply.text || ""); - for (let i = 0; i < chunks.length; i++) { + for (let i = 0; i < chunks.length; i += 1) { const chunk = chunks[i]; if (!chunk) continue; - // Only attach buttons to the first chunk + // Only attach buttons to the first chunk. const shouldAttachButtons = i === 0 && replyMarkup; await sendTelegramText(bot, chatId, chunk.html, runtime, { replyToMessageId: @@ -137,10 +135,12 @@ export async function deliverReplies(params: { first = false; const replyToMessageId = replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; + const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText; const mediaParams: Record = { caption: htmlCaption, reply_to_message_id: replyToMessageId, ...(htmlCaption ? { parse_mode: "HTML" } : {}), + ...(shouldAttachButtonsToMedia ? { reply_markup: replyMarkup } : {}), }; if (threadParams) { mediaParams.message_thread_id = threadParams.message_thread_id; @@ -195,6 +195,7 @@ export async function deliverReplies(params: { hasReplied, messageThreadId, linkPreview, + replyMarkup, }); // Skip this media item; continue with next. continue; @@ -219,7 +220,8 @@ export async function deliverReplies(params: { // Chunk it in case it's extremely long (same logic as text-only replies). if (pendingFollowUpText && isFirstMedia) { const chunks = chunkText(pendingFollowUpText); - for (const chunk of chunks) { + for (let i = 0; i < chunks.length; i += 1) { + const chunk = chunks[i]; const replyToMessageIdFollowup = replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; await sendTelegramText(bot, chatId, chunk.html, runtime, { @@ -228,6 +230,7 @@ export async function deliverReplies(params: { textMode: "html", plainText: chunk.text, linkPreview, + replyMarkup: i === 0 ? replyMarkup : undefined, }); if (replyToId && !hasReplied) { hasReplied = true; @@ -289,10 +292,12 @@ async function sendTelegramVoiceFallbackText(opts: { hasReplied: boolean; messageThreadId?: number; linkPreview?: boolean; + replyMarkup?: ReturnType; }): Promise { const chunks = opts.chunkText(opts.text); let hasReplied = opts.hasReplied; - for (const chunk of chunks) { + for (let i = 0; i < chunks.length; i += 1) { + const chunk = chunks[i]; await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, { replyToMessageId: opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined, @@ -300,6 +305,7 @@ async function sendTelegramVoiceFallbackText(opts: { textMode: "html", plainText: chunk.text, linkPreview: opts.linkPreview, + replyMarkup: i === 0 ? opts.replyMarkup : undefined, }); if (opts.replyToId && !hasReplied) { hasReplied = true; From b06fc50e25395a8349e4d359d48c4a29c6a200df Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 16:58:51 +0000 Subject: [PATCH 25/40] docs: clarify onboarding security warning --- CHANGELOG.md | 1 + src/wizard/onboarding.ts | 22 ++++++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8dff89cd..91db944fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Status: unreleased. - Docs: add LINE channel guide. - Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD. - Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub. +- Onboarding: strengthen security warning copy for beta + access control expectations. - Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a. - Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst. - Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7. diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 5c5590bf2..1016e5680 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -51,12 +51,26 @@ async function requireRiskAcknowledgement(params: { await params.prompter.note( [ - "Please read: https://docs.clawd.bot/security", + "Security warning — please read.", "", - "Clawdbot agents can run commands, read/write files, and act through any tools you enable. They can only send messages on channels you configure (for example, an account you log in on this machine, or a bot account like Slack/Discord).", + "Clawdbot is a hobby project and still in beta. Expect sharp edges.", + "This bot can read files and run actions if tools are enabled.", + "A bad prompt can trick it into doing unsafe things.", "", - "If you’re new to this, start with the sandbox and least privilege. It helps limit what an agent can do if it’s tricked or makes a mistake.", - "Learn more: https://docs.clawd.bot/sandboxing", + "If you’re not comfortable with basic security and access control, don’t run Clawdbot.", + "Ask someone experienced to help before enabling tools or exposing it to the internet.", + "", + "Recommended baseline:", + "- Pairing/allowlists + mention gating.", + "- Sandbox + least-privilege tools.", + "- Keep secrets out of the agent’s reachable filesystem.", + "- Use the strongest available model for any bot with tools or untrusted inboxes.", + "", + "Run regularly:", + "clawdbot security audit --deep", + "clawdbot security audit --fix", + "", + "Must read: https://docs.clawd.bot/gateway/security", ].join("\n"), "Security", ); From 287ab840603321d9f39ff578e656bb765081e29b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 16:57:53 +0000 Subject: [PATCH 26/40] fix(slack): handle file redirects Co-authored-by: Glucksberg --- src/slack/monitor/media.test.ts | 278 ++++++++++++++++++++++++++++++++ src/slack/monitor/media.ts | 42 ++++- 2 files changed, 316 insertions(+), 4 deletions(-) create mode 100644 src/slack/monitor/media.test.ts diff --git a/src/slack/monitor/media.test.ts b/src/slack/monitor/media.test.ts new file mode 100644 index 000000000..bfe70f005 --- /dev/null +++ b/src/slack/monitor/media.test.ts @@ -0,0 +1,278 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Store original fetch +const originalFetch = globalThis.fetch; +let mockFetch: ReturnType; + +describe("fetchWithSlackAuth", () => { + beforeEach(() => { + // Create a new mock for each test + mockFetch = vi.fn(); + globalThis.fetch = mockFetch as typeof fetch; + }); + + afterEach(() => { + // Restore original fetch + globalThis.fetch = originalFetch; + vi.resetModules(); + }); + + it("sends Authorization header on initial request with manual redirect", async () => { + // Import after mocking fetch + const { fetchWithSlackAuth } = await import("./media.js"); + + // Simulate direct 200 response (no redirect) + const mockResponse = new Response(Buffer.from("image data"), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + mockFetch.mockResolvedValueOnce(mockResponse); + + const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token"); + + expect(result).toBe(mockResponse); + + // Verify fetch was called with correct params + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith("https://files.slack.com/test.jpg", { + headers: { Authorization: "Bearer xoxb-test-token" }, + redirect: "manual", + }); + }); + + it("follows redirects without Authorization header", async () => { + const { fetchWithSlackAuth } = await import("./media.js"); + + // First call: redirect response from Slack + const redirectResponse = new Response(null, { + status: 302, + headers: { location: "https://cdn.slack-edge.com/presigned-url?sig=abc123" }, + }); + + // Second call: actual file content from CDN + const fileResponse = new Response(Buffer.from("actual image data"), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + + mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse); + + const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token"); + + expect(result).toBe(fileResponse); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // First call should have Authorization header and manual redirect + expect(mockFetch).toHaveBeenNthCalledWith(1, "https://files.slack.com/test.jpg", { + headers: { Authorization: "Bearer xoxb-test-token" }, + redirect: "manual", + }); + + // Second call should follow the redirect without Authorization + expect(mockFetch).toHaveBeenNthCalledWith( + 2, + "https://cdn.slack-edge.com/presigned-url?sig=abc123", + { redirect: "follow" }, + ); + }); + + it("handles relative redirect URLs", async () => { + const { fetchWithSlackAuth } = await import("./media.js"); + + // Redirect with relative URL + const redirectResponse = new Response(null, { + status: 302, + headers: { location: "/files/redirect-target" }, + }); + + const fileResponse = new Response(Buffer.from("image data"), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + + mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse); + + await fetchWithSlackAuth("https://files.slack.com/original.jpg", "xoxb-test-token"); + + // Second call should resolve the relative URL against the original + expect(mockFetch).toHaveBeenNthCalledWith(2, "https://files.slack.com/files/redirect-target", { + redirect: "follow", + }); + }); + + it("returns redirect response when no location header is provided", async () => { + const { fetchWithSlackAuth } = await import("./media.js"); + + // Redirect without location header + const redirectResponse = new Response(null, { + status: 302, + // No location header + }); + + mockFetch.mockResolvedValueOnce(redirectResponse); + + const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token"); + + // Should return the redirect response directly + expect(result).toBe(redirectResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("returns 4xx/5xx responses directly without following", async () => { + const { fetchWithSlackAuth } = await import("./media.js"); + + const errorResponse = new Response("Not Found", { + status: 404, + }); + + mockFetch.mockResolvedValueOnce(errorResponse); + + const result = await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token"); + + expect(result).toBe(errorResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("handles 301 permanent redirects", async () => { + const { fetchWithSlackAuth } = await import("./media.js"); + + const redirectResponse = new Response(null, { + status: 301, + headers: { location: "https://cdn.slack.com/new-url" }, + }); + + const fileResponse = new Response(Buffer.from("image data"), { + status: 200, + }); + + mockFetch.mockResolvedValueOnce(redirectResponse).mockResolvedValueOnce(fileResponse); + + await fetchWithSlackAuth("https://files.slack.com/test.jpg", "xoxb-test-token"); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch).toHaveBeenNthCalledWith(2, "https://cdn.slack.com/new-url", { + redirect: "follow", + }); + }); +}); + +describe("resolveSlackMedia", () => { + beforeEach(() => { + mockFetch = vi.fn(); + globalThis.fetch = mockFetch as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.resetModules(); + }); + + it("prefers url_private_download over url_private", async () => { + // Mock the store module + vi.doMock("../../media/store.js", () => ({ + saveMediaBuffer: vi.fn().mockResolvedValue({ + path: "/tmp/test.jpg", + contentType: "image/jpeg", + }), + })); + + const { resolveSlackMedia } = await import("./media.js"); + + const mockResponse = new Response(Buffer.from("image data"), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + mockFetch.mockResolvedValueOnce(mockResponse); + + await resolveSlackMedia({ + files: [ + { + url_private: "https://files.slack.com/private.jpg", + url_private_download: "https://files.slack.com/download.jpg", + name: "test.jpg", + }, + ], + token: "xoxb-test-token", + maxBytes: 1024 * 1024, + }); + + expect(mockFetch).toHaveBeenCalledWith( + "https://files.slack.com/download.jpg", + expect.anything(), + ); + }); + + it("returns null when download fails", async () => { + const { resolveSlackMedia } = await import("./media.js"); + + // Simulate a network error + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + const result = await resolveSlackMedia({ + files: [{ url_private: "https://files.slack.com/test.jpg", name: "test.jpg" }], + token: "xoxb-test-token", + maxBytes: 1024 * 1024, + }); + + expect(result).toBeNull(); + }); + + it("returns null when no files are provided", async () => { + const { resolveSlackMedia } = await import("./media.js"); + + const result = await resolveSlackMedia({ + files: [], + token: "xoxb-test-token", + maxBytes: 1024 * 1024, + }); + + expect(result).toBeNull(); + }); + + it("skips files without url_private", async () => { + const { resolveSlackMedia } = await import("./media.js"); + + const result = await resolveSlackMedia({ + files: [{ name: "test.jpg" }], // No url_private + token: "xoxb-test-token", + maxBytes: 1024 * 1024, + }); + + expect(result).toBeNull(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("falls through to next file when first file returns error", async () => { + // Mock the store module + vi.doMock("../../media/store.js", () => ({ + saveMediaBuffer: vi.fn().mockResolvedValue({ + path: "/tmp/test.jpg", + contentType: "image/jpeg", + }), + })); + + const { resolveSlackMedia } = await import("./media.js"); + + // First file: 404 + const errorResponse = new Response("Not Found", { status: 404 }); + // Second file: success + const successResponse = new Response(Buffer.from("image data"), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }); + + mockFetch.mockResolvedValueOnce(errorResponse).mockResolvedValueOnce(successResponse); + + const result = await resolveSlackMedia({ + files: [ + { url_private: "https://files.slack.com/first.jpg", name: "first.jpg" }, + { url_private: "https://files.slack.com/second.jpg", name: "second.jpg" }, + ], + token: "xoxb-test-token", + maxBytes: 1024 * 1024, + }); + + expect(result).not.toBeNull(); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/slack/monitor/media.ts b/src/slack/monitor/media.ts index 143d6b36f..2674e2d50 100644 --- a/src/slack/monitor/media.ts +++ b/src/slack/monitor/media.ts @@ -5,6 +5,38 @@ import { fetchRemoteMedia } from "../../media/fetch.js"; import { saveMediaBuffer } from "../../media/store.js"; import type { SlackFile } from "../types.js"; +/** + * Fetches a URL with Authorization header, handling cross-origin redirects. + * Node.js fetch strips Authorization headers on cross-origin redirects for security. + * Slack's files.slack.com URLs redirect to CDN domains with pre-signed URLs that + * don't need the Authorization header, so we handle the initial auth request manually. + */ +export async function fetchWithSlackAuth(url: string, token: string): Promise { + // Initial request with auth and manual redirect handling + const initialRes = await fetch(url, { + headers: { Authorization: `Bearer ${token}` }, + redirect: "manual", + }); + + // If not a redirect, return the response directly + if (initialRes.status < 300 || initialRes.status >= 400) { + return initialRes; + } + + // Handle redirect - the redirected URL should be pre-signed and not need auth + const redirectUrl = initialRes.headers.get("location"); + if (!redirectUrl) { + return initialRes; + } + + // Resolve relative URLs against the original + const resolvedUrl = new URL(redirectUrl, url).toString(); + + // Follow the redirect without the Authorization header + // (Slack's CDN URLs are pre-signed and don't need it) + return fetch(resolvedUrl, { redirect: "follow" }); +} + export async function resolveSlackMedia(params: { files?: SlackFile[]; token: string; @@ -19,10 +51,12 @@ export async function resolveSlackMedia(params: { const url = file.url_private_download ?? file.url_private; if (!url) continue; try { - const fetchImpl: FetchLike = (input, init) => { - const headers = new Headers(init?.headers); - headers.set("Authorization", `Bearer ${params.token}`); - return fetch(input, { ...init, headers }); + // Note: We ignore init options because fetchWithSlackAuth handles + // redirect behavior specially. fetchRemoteMedia only passes the URL. + const fetchImpl: FetchLike = (input) => { + const inputUrl = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + return fetchWithSlackAuth(inputUrl, params.token); }; const fetched = await fetchRemoteMedia({ url, From bfe9bb8a23fd23935db59cdfc128652897bafc3d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 16:57:57 +0000 Subject: [PATCH 27/40] docs(changelog): note slack redirect fix Co-authored-by: Glucksberg --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91db944fe..99471a6bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Status: unreleased. ## 2026.1.24-3 ### Fixes +- Slack: fix image downloads failing due to missing Authorization header on cross-origin redirects. (#1936) Thanks @sanderhelgesen. - Gateway: harden reverse proxy handling for local-client detection and unauthenticated proxied connects. (#1795) Thanks @orlyjamie. - Security audit: flag loopback Control UI with auth disabled as critical. (#1795) Thanks @orlyjamie. - CLI: resume claude-cli sessions and stream CLI replies to TUI clients. (#1921) Thanks @rmorse. From e0b8661eee3d8a86bd612be58547023bc5f1c2aa Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 26 Jan 2026 11:02:10 -0600 Subject: [PATCH 28/40] Docs: credit LINE channel guide contributor --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99471a6bf..d8cd54aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ Status: unreleased. - Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto. - Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto. - Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev. -- Docs: add LINE channel guide. +- Docs: add LINE channel guide. Thanks @thewilloftheshadow. - Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD. - Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub. - Onboarding: strengthen security warning copy for beta + access control expectations. From 2a4ccb624a4f8070854036a01c5b3c1595240480 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 26 Jan 2026 11:02:52 -0600 Subject: [PATCH 29/40] Docs: update clawtributors --- README.md | 55 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 217a4b61c..535cd1c75 100644 --- a/README.md +++ b/README.md @@ -479,32 +479,33 @@ Thanks to all clawtributors:

steipete plum-dawg bohdanpodvirnyi iHildy joaohlisboa mneves75 MatthieuBizien MaudeBot Glucksberg rahthakor vrknetha radek-paclt Tobias Bischoff joshp123 czekaj mukhtharcm sebslight maxsumrall xadenryan rodrigouroz - juanpablodlc hsrvc magimetal meaningfool tyler6204 patelhiren NicholasSpisak jonisjongithub zerone0x abhisekbasu1 + juanpablodlc hsrvc magimetal zerone0x meaningfool tyler6204 patelhiren NicholasSpisak jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc Hyaxia dantelex SocialNerd42069 daveonkels google-labs-jules[bot] lc0rp mousberg - vignesh07 mteam88 dbhurley Mariano Belinky Eng. Juan Combetto TSavo julianengel bradleypriest benithors rohannagpal - timolins f-trycua benostein nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino - Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan davidguttman sleontenko denysvitali orlyjamie - thewilloftheshadow sircrumpet peschee rafaelreis-r ratulsarna lutr0 danielz1z emanuelst KristijanJovanovski rdev - joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek artuskg Takhoffman onutc pauloportella - neooriginal manuelhettich minghinmatthewlam myfunc travisirby buddyh connorshea kyleok mcinteerj dependabot[bot] - John-Rood timkrase uos-status gerardward2007 obviyus roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter - cheeeee Josh Phillips robbyczgw-cla dlauer pookNast Whoaa512 YuriNachos chriseidhof ngutman ysqander - aj47 superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy - imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 - Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz - Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Kit koala73 manmal ogulcancelik pasogott petradonka - rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis zats 24601 adam91holt ameno- - Chris Taylor Django Navarro evalexpr henrino3 humanwritten larlyssa odysseus0 oswalpalash pcty-nextgen-service-account rmorse - Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot Clawd ClawdFx dguido EnzeD - erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior jverdi - longmaba mickahouan mjrussell odnxe p6l-richard philipp-spiess robaxelsen Sash Catanzarite T5-AndyML travisp - VAC william arzt zknicker abhaymundhara alejandro maza andrewting19 anpoirier arthyn Asleep123 bolismauro - conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 Felix Krause foeken ganghyun kim grrowl - gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn Kevin Lin kitze levifig - Lloyd loukotal louzhixian martinpucik Matt mini Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman - nexty5870 Noctivoro prathamdby ptn1411 reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann - Seredeep sergical shiv19 shiyuanhai siraht snopoke testingabc321 The Admiral thesash Ubuntu - voidserf Vultr-Clawd Admin Wimmie wstock yazinsai ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee - atalovesyou Azade carlulsoe ddyo Erik hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik - pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock + vignesh07 mteam88 joeynyc orlyjamie dbhurley Mariano Belinky Eng. Juan Combetto TSavo julianengel bradleypriest + benithors rohannagpal timolins f-trycua benostein nachx639 pvoo sreekaransrinath gupsammy cristip73 + stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan davidguttman sleontenko + denysvitali thewilloftheshadow shakkernerd sircrumpet peschee rafaelreis-r ratulsarna lutr0 danielz1z emanuelst + KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek artuskg + Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby buddyh connorshea + kyleok mcinteerj dependabot[bot] John-Rood timkrase uos-status gerardward2007 obviyus roshanasingh4 tosh-hamburg + azade-c JonUleis bjesuiter cheeeee Josh Phillips YuriNachos robbyczgw-cla dlauer pookNast Whoaa512 + chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] + damoahdominic dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures + Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr + neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Kit koala73 manmal + ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis zats + 24601 adam91holt ameno- Chris Taylor Django Navarro evalexpr henrino3 humanwritten larlyssa odysseus0 + oswalpalash pcty-nextgen-service-account rmorse Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot Clawd + ClawdFx dguido EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira jayhickey + jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell odnxe p6l-richard philipp-spiess robaxelsen + Sash Catanzarite T5-AndyML travisp VAC william arzt zknicker abhaymundhara alejandro maza Alex-Alaniz andrewting19 + anpoirier arthyn Asleep123 bolismauro conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 + Felix Krause foeken ganghyun kim grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis + Jefferson Nunn Kevin Lin kitze levifig Lloyd loukotal louzhixian martinpucik Matt mini mertcicekci0 + Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro prathamdby ptn1411 reeltimeapps + RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht + snopoke testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai + ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik + hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani + William Stock

From a48694078171f0e5865a58a23771de731aa1459d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 17:22:40 +0000 Subject: [PATCH 30/40] fix: honor tools.exec.safeBins config --- CHANGELOG.md | 1 + src/agents/pi-tools.safe-bins.test.ts | 78 +++++++++++++++++++++++++++ src/agents/pi-tools.ts | 2 + 3 files changed, 81 insertions(+) create mode 100644 src/agents/pi-tools.safe-bins.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d8cd54aac..9ba49a2ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot Status: unreleased. ### Changes +- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281) - Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido. - Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos. - Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. diff --git a/src/agents/pi-tools.safe-bins.test.ts b/src/agents/pi-tools.safe-bins.test.ts new file mode 100644 index 000000000..43202bbb5 --- /dev/null +++ b/src/agents/pi-tools.safe-bins.test.ts @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import type { ClawdbotConfig } from "../config/config.js"; +import type { ExecApprovalsResolved } from "../infra/exec-approvals.js"; +import { createClawdbotCodingTools } from "./pi-tools.js"; + +vi.mock("../infra/exec-approvals.js", async (importOriginal) => { + const mod = await importOriginal(); + const approvals: ExecApprovalsResolved = { + path: "/tmp/exec-approvals.json", + socketPath: "/tmp/exec-approvals.sock", + token: "token", + defaults: { + security: "allowlist", + ask: "off", + askFallback: "deny", + autoAllowSkills: false, + }, + agent: { + security: "allowlist", + ask: "off", + askFallback: "deny", + autoAllowSkills: false, + }, + allowlist: [], + file: { + version: 1, + socket: { path: "/tmp/exec-approvals.sock", token: "token" }, + defaults: { + security: "allowlist", + ask: "off", + askFallback: "deny", + autoAllowSkills: false, + }, + agents: {}, + }, + }; + return { ...mod, resolveExecApprovals: () => approvals }; +}); + +describe("createClawdbotCodingTools safeBins", () => { + it("threads tools.exec.safeBins into exec allowlist checks", async () => { + if (process.platform === "win32") return; + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-safe-bins-")); + const cfg: ClawdbotConfig = { + tools: { + exec: { + host: "gateway", + security: "allowlist", + ask: "off", + safeBins: ["echo"], + }, + }, + }; + + const tools = createClawdbotCodingTools({ + config: cfg, + sessionKey: "agent:main:main", + workspaceDir: tmpDir, + agentDir: path.join(tmpDir, "agent"), + }); + const execTool = tools.find((tool) => tool.name === "exec"); + expect(execTool).toBeDefined(); + + const marker = `safe-bins-${Date.now()}`; + const result = await execTool!.execute("call1", { + command: `echo ${marker}`, + workdir: tmpDir, + }); + const text = result.content.find((content) => content.type === "text")?.text ?? ""; + + expect(result.details.status).toBe("completed"); + expect(text).toContain(marker); + }); +}); diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index bd745da03..9013f1e52 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -86,6 +86,7 @@ function resolveExecConfig(cfg: ClawdbotConfig | undefined) { ask: globalExec?.ask, node: globalExec?.node, pathPrepend: globalExec?.pathPrepend, + safeBins: globalExec?.safeBins, backgroundMs: globalExec?.backgroundMs, timeoutSec: globalExec?.timeoutSec, approvalRunningNoticeMs: globalExec?.approvalRunningNoticeMs, @@ -235,6 +236,7 @@ export function createClawdbotCodingTools(options?: { ask: options?.exec?.ask ?? execConfig.ask, node: options?.exec?.node ?? execConfig.node, pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend, + safeBins: options?.exec?.safeBins ?? execConfig.safeBins, agentId, cwd: options?.workspaceDir, allowBackground, From e6bdffe568175e2b718e7611f73e191a9e1f771a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 17:40:24 +0000 Subject: [PATCH 31/40] feat: add control ui device auth bypass --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 6 ++- docs/gateway/protocol.md | 3 +- docs/gateway/security.md | 6 ++- src/config/schema.ts | 3 ++ src/config/types.gateway.ts | 2 + src/config/zod-schema.ts | 1 + src/gateway/server.auth.e2e.test.ts | 47 +++++++++++++++++++ .../server/ws-connection/message-handler.ts | 20 ++++---- src/security/audit.test.ts | 25 +++++++++- src/security/audit.ts | 13 ++++- 11 files changed, 112 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ba49a2ff..f3955b1fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Status: unreleased. - Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281) - Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido. - Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos. +- Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248) - Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. - Discord: add configurable privileged gateway intents for presences/members. (#2266) Thanks @kentaro. - Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 024c0b1c5..8db2844fd 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -2847,9 +2847,11 @@ Control UI base path: - `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served. - Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`. - Default: root (`/`) (unchanged). -- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI and skips - device identity + pairing (even on HTTPS). Default: `false`. Prefer HTTPS +- `gateway.controlUi.allowInsecureAuth` allows token-only auth for the Control UI when + device identity is omitted (typically over HTTP). Default: `false`. Prefer HTTPS (Tailscale Serve) or `127.0.0.1`. +- `gateway.controlUi.dangerouslyDisableDeviceAuth` disables device identity checks for the + Control UI (token/password only). Default: `false`. Break-glass only. Related docs: - [Control UI](/web/control-ui) diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index fc6682708..279b37614 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -198,7 +198,8 @@ The Gateway treats these as **claims** and enforces server-side allowlists. - **Local** connects include loopback and the gateway host’s own tailnet address (so same‑host tailnet binds can still auto‑approve). - All WS clients must include `device` identity during `connect` (operator + node). - Control UI can omit it **only** when `gateway.controlUi.allowInsecureAuth` is enabled. + Control UI can omit it **only** when `gateway.controlUi.allowInsecureAuth` is enabled + (or `gateway.controlUi.dangerouslyDisableDeviceAuth` for break-glass use). - Non-local connections must sign the server-provided `connect.challenge` nonce. ## TLS + pinning diff --git a/docs/gateway/security.md b/docs/gateway/security.md index f5526ca73..564b248fe 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -58,9 +58,13 @@ When the audit prints findings, treat this as a priority order: The Control UI needs a **secure context** (HTTPS or localhost) to generate device identity. If you enable `gateway.controlUi.allowInsecureAuth`, the UI falls back -to **token-only auth** and skips device pairing (even on HTTPS). This is a security +to **token-only auth** and skips device pairing when device identity is omitted. This is a security downgrade—prefer HTTPS (Tailscale Serve) or open the UI on `127.0.0.1`. +For break-glass scenarios only, `gateway.controlUi.dangerouslyDisableDeviceAuth` +disables device identity checks entirely. This is a severe security downgrade; +keep it off unless you are actively debugging and can revert quickly. + `clawdbot security audit` warns when this setting is enabled. ## Reverse Proxy Configuration diff --git a/src/config/schema.ts b/src/config/schema.ts index 63c10ed88..24d6bccfe 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -199,6 +199,7 @@ const FIELD_LABELS: Record = { "tools.web.fetch.userAgent": "Web Fetch User-Agent", "gateway.controlUi.basePath": "Control UI Base Path", "gateway.controlUi.allowInsecureAuth": "Allow Insecure Control UI Auth", + "gateway.controlUi.dangerouslyDisableDeviceAuth": "Dangerously Disable Control UI Device Auth", "gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint", "gateway.reload.mode": "Config Reload Mode", "gateway.reload.debounceMs": "Config Reload Debounce (ms)", @@ -381,6 +382,8 @@ const FIELD_HELP: Record = { "Optional URL prefix where the Control UI is served (e.g. /clawdbot).", "gateway.controlUi.allowInsecureAuth": "Allow Control UI auth over insecure HTTP (token-only; not recommended).", + "gateway.controlUi.dangerouslyDisableDeviceAuth": + "DANGEROUS. Disable Control UI device identity checks (token/password only).", "gateway.http.endpoints.chatCompletions.enabled": "Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).", "gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).', diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 4c7ddcdf3..d80b721ec 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -66,6 +66,8 @@ export type GatewayControlUiConfig = { basePath?: string; /** Allow token-only auth over insecure HTTP (default: false). */ allowInsecureAuth?: boolean; + /** DANGEROUS: Disable device identity checks for the Control UI (default: false). */ + dangerouslyDisableDeviceAuth?: boolean; }; export type GatewayAuthMode = "token" | "password"; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 3c5bba8d7..f39b001fa 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -319,6 +319,7 @@ export const ClawdbotSchema = z enabled: z.boolean().optional(), basePath: z.string().optional(), allowInsecureAuth: z.boolean().optional(), + dangerouslyDisableDeviceAuth: z.boolean().optional(), }) .strict() .optional(), diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 6474f285b..3f9994205 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -352,6 +352,53 @@ describe("gateway server auth/connect", () => { } }); + test("allows control ui with stale device identity when device auth is disabled", async () => { + testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true }; + const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; + process.env.CLAWDBOT_GATEWAY_TOKEN = "secret"; + const port = await getFreePort(); + const server = await startGatewayServer(port); + const ws = await openWs(port); + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + await import("../infra/device-identity.js"); + const identity = loadOrCreateDeviceIdentity(); + const signedAtMs = Date.now() - 60 * 60 * 1000; + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: GATEWAY_CLIENT_NAMES.CONTROL_UI, + clientMode: GATEWAY_CLIENT_MODES.WEBCHAT, + role: "operator", + scopes: [], + signedAtMs, + token: "secret", + }); + const device = { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + }; + const res = await connectReq(ws, { + token: "secret", + device, + client: { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + version: "1.0.0", + platform: "web", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, + }, + }); + expect(res.ok).toBe(true); + expect((res.payload as { auth?: unknown } | undefined)?.auth).toBeUndefined(); + ws.close(); + await server.close(); + if (prevToken === undefined) { + delete process.env.CLAWDBOT_GATEWAY_TOKEN; + } else { + process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; + } + }); + test("rejects proxied connections without auth when proxy headers are untrusted", async () => { testState.gatewayAuth = { mode: "none" }; const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 7f8f9f2c6..3ff455295 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -335,7 +335,7 @@ export function attachGatewayWsMessageHandler(params: { connectParams.role = role; connectParams.scopes = scopes; - const device = connectParams.device; + const deviceRaw = connectParams.device; let devicePublicKey: string | null = null; const hasTokenAuth = Boolean(connectParams.auth?.token); const hasPasswordAuth = Boolean(connectParams.auth?.password); @@ -343,6 +343,10 @@ export function attachGatewayWsMessageHandler(params: { const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI; const allowInsecureControlUi = isControlUi && configSnapshot.gateway?.controlUi?.allowInsecureAuth === true; + const disableControlUiDeviceAuth = + isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true; + const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth; + const device = disableControlUiDeviceAuth ? null : deviceRaw; if (hasUntrustedProxyHeaders && resolvedAuth.mode === "none") { setHandshakeState("failed"); setCloseCause("proxy-auth-required", { @@ -370,9 +374,9 @@ export function attachGatewayWsMessageHandler(params: { } if (!device) { - const canSkipDevice = allowInsecureControlUi ? hasSharedAuth : hasTokenAuth; + const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth; - if (isControlUi && !allowInsecureControlUi) { + if (isControlUi && !allowControlUiBypass) { const errorMessage = "control ui requires HTTPS or localhost (secure context)"; setHandshakeState("failed"); setCloseCause("control-ui-insecure-auth", { @@ -615,7 +619,7 @@ export function attachGatewayWsMessageHandler(params: { return; } - const skipPairing = allowInsecureControlUi && hasSharedAuth; + const skipPairing = allowControlUiBypass && hasSharedAuth; if (device && devicePublicKey && !skipPairing) { const requirePairing = async (reason: string, _paired?: { deviceId: string }) => { const pairing = await requestDevicePairing({ @@ -736,9 +740,7 @@ export function attachGatewayWsMessageHandler(params: { const shouldTrackPresence = !isGatewayCliClient(connectParams.client); const clientId = connectParams.client.id; const instanceId = connectParams.client.instanceId; - const presenceKey = shouldTrackPresence - ? (connectParams.device?.id ?? instanceId ?? connId) - : undefined; + const presenceKey = shouldTrackPresence ? (device?.id ?? instanceId ?? connId) : undefined; logWs("in", "connect", { connId, @@ -766,10 +768,10 @@ export function attachGatewayWsMessageHandler(params: { deviceFamily: connectParams.client.deviceFamily, modelIdentifier: connectParams.client.modelIdentifier, mode: connectParams.client.mode, - deviceId: connectParams.device?.id, + deviceId: device?.id, roles: [role], scopes, - instanceId: connectParams.device?.id ?? instanceId, + instanceId: device?.id ?? instanceId, reason: "connect", }); incrementPresenceVersion(); diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 2ee7e27ee..294384abd 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -293,7 +293,30 @@ describe("security audit", () => { expect.arrayContaining([ expect.objectContaining({ checkId: "gateway.control_ui.insecure_auth", - severity: "warn", + severity: "critical", + }), + ]), + ); + }); + + it("warns when control UI device auth is disabled", async () => { + const cfg: ClawdbotConfig = { + gateway: { + controlUi: { dangerouslyDisableDeviceAuth: true }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + checkId: "gateway.control_ui.device_auth_disabled", + severity: "critical", }), ]), ); diff --git a/src/security/audit.ts b/src/security/audit.ts index b2f9691c7..5b6df61b8 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -274,7 +274,7 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding if (cfg.gateway?.controlUi?.allowInsecureAuth === true) { findings.push({ checkId: "gateway.control_ui.insecure_auth", - severity: "warn", + severity: "critical", title: "Control UI allows insecure HTTP auth", detail: "gateway.controlUi.allowInsecureAuth=true allows token-only auth over HTTP and skips device identity.", @@ -282,6 +282,17 @@ function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding }); } + if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) { + findings.push({ + checkId: "gateway.control_ui.device_auth_disabled", + severity: "critical", + title: "DANGEROUS: Control UI device auth disabled", + detail: + "gateway.controlUi.dangerouslyDisableDeviceAuth=true disables device identity checks for the Control UI.", + remediation: "Disable it unless you are in a short-lived break-glass scenario.", + }); + } + const token = typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null; if (auth.mode === "token" && token && token.length < 24) { From b9098f340112e14f9fc52e55c283ae2ec0d4d093 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 17:44:13 +0000 Subject: [PATCH 32/40] fix: remove unsupported gateway auth off option --- CHANGELOG.md | 1 + docs/cli/index.md | 2 +- docs/gateway/troubleshooting.md | 2 +- src/cli/program/register.onboard.ts | 2 +- src/commands/configure.gateway-auth.test.ts | 20 ++++++------------- src/commands/configure.gateway-auth.ts | 5 +---- src/commands/configure.gateway.ts | 12 +---------- .../onboard-non-interactive.gateway.test.ts | 3 +-- .../local/gateway-config.ts | 10 +++++++--- src/commands/onboard-types.ts | 2 +- src/wizard/onboarding.gateway-config.ts | 11 ---------- src/wizard/onboarding.ts | 1 - 12 files changed, 21 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3955b1fb..20e14f73d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ Status: unreleased. - Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93. - Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0. - Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed). +- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags. ## 2026.1.24-3 diff --git a/docs/cli/index.md b/docs/cli/index.md index d23ee3a5e..9a72322e2 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -314,7 +314,7 @@ Options: - `--opencode-zen-api-key ` - `--gateway-port ` - `--gateway-bind ` -- `--gateway-auth ` +- `--gateway-auth ` - `--gateway-token ` - `--gateway-password ` - `--remote-url ` diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 24815e258..5cbffd815 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -214,7 +214,7 @@ the Gateway likely refused to bind. - Fix: run `clawdbot doctor` to update it (or `clawdbot gateway install --force` for a full rewrite). **If `Last gateway error:` mentions “refusing to bind … without auth”** -- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) but left auth off. +- You set `gateway.bind` to a non-loopback mode (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) but didn’t configure auth. - Fix: set `gateway.auth.mode` + `gateway.auth.token` (or export `CLAWDBOT_GATEWAY_TOKEN`) and restart the service. **If `clawdbot gateway status` says `bind=tailnet` but no tailnet interface was found** diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index ee9d5ccd2..a2d5d4a66 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -78,7 +78,7 @@ export function registerOnboardCommand(program: Command) { .option("--opencode-zen-api-key ", "OpenCode Zen API key") .option("--gateway-port ", "Gateway port") .option("--gateway-bind ", "Gateway bind: loopback|tailnet|lan|auto|custom") - .option("--gateway-auth ", "Gateway auth: off|token|password") + .option("--gateway-auth ", "Gateway auth: token|password") .option("--gateway-token ", "Gateway token (token auth)") .option("--gateway-password ", "Gateway password (password auth)") .option("--remote-url ", "Remote Gateway WebSocket URL") diff --git a/src/commands/configure.gateway-auth.test.ts b/src/commands/configure.gateway-auth.test.ts index 69faad450..26a3729f2 100644 --- a/src/commands/configure.gateway-auth.test.ts +++ b/src/commands/configure.gateway-auth.test.ts @@ -3,26 +3,18 @@ import { describe, expect, it } from "vitest"; import { buildGatewayAuthConfig } from "./configure.js"; describe("buildGatewayAuthConfig", () => { - it("clears token/password when auth is off", () => { - const result = buildGatewayAuthConfig({ - existing: { mode: "token", token: "abc", password: "secret" }, - mode: "off", - }); - - expect(result).toBeUndefined(); - }); - - it("preserves allowTailscale when auth is off", () => { + it("preserves allowTailscale when switching to token", () => { const result = buildGatewayAuthConfig({ existing: { - mode: "token", - token: "abc", + mode: "password", + password: "secret", allowTailscale: true, }, - mode: "off", + mode: "token", + token: "abc", }); - expect(result).toEqual({ allowTailscale: true }); + expect(result).toEqual({ mode: "token", token: "abc", allowTailscale: true }); }); it("drops password when switching to token", () => { diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index ad9406195..6d3522ab4 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -12,7 +12,7 @@ import { promptModelAllowlist, } from "./model-picker.js"; -type GatewayAuthChoice = "off" | "token" | "password"; +type GatewayAuthChoice = "token" | "password"; const ANTHROPIC_OAUTH_MODEL_KEYS = [ "anthropic/claude-opus-4-5", @@ -30,9 +30,6 @@ export function buildGatewayAuthConfig(params: { const base: GatewayAuthConfig = {}; if (typeof allowTailscale === "boolean") base.allowTailscale = allowTailscale; - if (params.mode === "off") { - return Object.keys(base).length > 0 ? base : undefined; - } if (params.mode === "token") { return { ...base, mode: "token", token: params.token }; } diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts index ba44c3dcf..d572e54a9 100644 --- a/src/commands/configure.gateway.ts +++ b/src/commands/configure.gateway.ts @@ -7,7 +7,7 @@ import { buildGatewayAuthConfig } from "./configure.gateway-auth.js"; import { confirm, select, text } from "./configure.shared.js"; import { guardCancel, randomToken } from "./onboard-helpers.js"; -type GatewayAuthChoice = "off" | "token" | "password"; +type GatewayAuthChoice = "token" | "password"; export async function promptGatewayConfig( cfg: ClawdbotConfig, @@ -91,11 +91,6 @@ export async function promptGatewayConfig( await select({ message: "Gateway auth", options: [ - { - value: "off", - label: "Off (loopback only)", - hint: "Not recommended unless you fully trust local processes", - }, { value: "token", label: "Token", hint: "Recommended default" }, { value: "password", label: "Password" }, ], @@ -165,11 +160,6 @@ export async function promptGatewayConfig( bind = "loopback"; } - if (authMode === "off" && bind !== "loopback") { - note("Non-loopback bind requires auth. Switching to token auth.", "Note"); - authMode = "token"; - } - if (tailscaleMode === "funnel" && authMode !== "password") { note("Tailscale funnel requires password auth.", "Note"); authMode = "password"; diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index b5cf45166..a33cc531f 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -210,7 +210,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { await fs.rm(stateDir, { recursive: true, force: true }); }, 60_000); - it("auto-enables token auth when binding LAN and persists the token", async () => { + it("auto-generates token auth when binding LAN and persists the token", async () => { if (process.platform === "win32") { // Windows runner occasionally drops the temp config write in this flow; skip to keep CI green. return; @@ -242,7 +242,6 @@ describe("onboard (non-interactive): gateway and remote auth", () => { installDaemon: false, gatewayPort: port, gatewayBind: "lan", - gatewayAuth: "off", }, runtime, ); diff --git a/src/commands/onboard-non-interactive/local/gateway-config.ts b/src/commands/onboard-non-interactive/local/gateway-config.ts index fedf1ad19..70772fa9f 100644 --- a/src/commands/onboard-non-interactive/local/gateway-config.ts +++ b/src/commands/onboard-non-interactive/local/gateway-config.ts @@ -28,16 +28,20 @@ export function applyNonInteractiveGatewayConfig(params: { const port = hasGatewayPort ? (opts.gatewayPort as number) : params.defaultPort; let bind = opts.gatewayBind ?? "loopback"; - let authMode = opts.gatewayAuth ?? "token"; + const authModeRaw = opts.gatewayAuth ?? "token"; + if (authModeRaw !== "token" && authModeRaw !== "password") { + runtime.error("Invalid --gateway-auth (use token|password)."); + runtime.exit(1); + return null; + } + let authMode = authModeRaw; const tailscaleMode = opts.tailscale ?? "off"; const tailscaleResetOnExit = Boolean(opts.tailscaleResetOnExit); // Tighten config to safe combos: // - If Tailscale is on, force loopback bind (the tunnel handles external access). - // - If binding beyond loopback, disallow auth=off. // - If using Tailscale Funnel, require password auth. if (tailscaleMode !== "off" && bind !== "loopback") bind = "loopback"; - if (authMode === "off" && bind !== "loopback") authMode = "token"; if (tailscaleMode === "funnel" && authMode !== "password") authMode = "password"; let nextConfig = params.nextConfig; diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 84c15afc4..aa1d9afe0 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -32,7 +32,7 @@ export type AuthChoice = | "copilot-proxy" | "qwen-portal" | "skip"; -export type GatewayAuthChoice = "off" | "token" | "password"; +export type GatewayAuthChoice = "token" | "password"; export type ResetScope = "config" | "config+creds+sessions" | "full"; export type GatewayBind = "loopback" | "lan" | "auto" | "custom" | "tailnet"; export type TailscaleMode = "off" | "serve" | "funnel"; diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts index e8163cbad..c68836b32 100644 --- a/src/wizard/onboarding.gateway-config.ts +++ b/src/wizard/onboarding.gateway-config.ts @@ -93,11 +93,6 @@ export async function configureGatewayForOnboarding( : ((await prompter.select({ message: "Gateway auth", options: [ - { - value: "off", - label: "Off (loopback only)", - hint: "Not recommended unless you fully trust local processes", - }, { value: "token", label: "Token", @@ -165,7 +160,6 @@ export async function configureGatewayForOnboarding( // Safety + constraints: // - Tailscale wants bind=loopback so we never expose a non-loopback server + tailscale serve/funnel at once. - // - Auth off only allowed for bind=loopback. // - Funnel requires password auth. if (tailscaleMode !== "off" && bind !== "loopback") { await prompter.note("Tailscale requires bind=loopback. Adjusting bind to loopback.", "Note"); @@ -173,11 +167,6 @@ export async function configureGatewayForOnboarding( customBindHost = undefined; } - if (authMode === "off" && bind !== "loopback") { - await prompter.note("Non-loopback bind requires auth. Switching to token auth.", "Note"); - authMode = "token"; - } - if (tailscaleMode === "funnel" && authMode !== "password") { await prompter.note("Tailscale funnel requires password auth.", "Note"); authMode = "password"; diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 1016e5680..77b7f770d 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -244,7 +244,6 @@ export async function runOnboardingWizard( return "Auto"; }; const formatAuth = (value: GatewayAuthChoice) => { - if (value === "off") return "Off (loopback only)"; if (value === "token") return "Token (default)"; return "Password"; }; From ab73aceb27de266d842fa94f9b5c6ace29e66915 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 18:19:58 +0000 Subject: [PATCH 33/40] fix: use Windows ACLs for security audit --- CHANGELOG.md | 1 + src/cli/security-cli.ts | 27 +++-- src/security/audit-extra.ts | 168 ++++++++++++++++++++--------- src/security/audit-fs.ts | 120 +++++++++++++++++++++ src/security/audit.test.ts | 77 ++++++++++++++ src/security/audit.ts | 134 +++++++++++++++++------- src/security/fix.ts | 140 ++++++++++++++++++++++--- src/security/windows-acl.ts | 203 ++++++++++++++++++++++++++++++++++++ 8 files changed, 760 insertions(+), 110 deletions(-) create mode 100644 src/security/windows-acl.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 20e14f73d..d95c8c124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Status: unreleased. - Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra. - Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon. - Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco. +- Security: use Windows ACLs for permission audits and fixes on Windows. (#1957) - Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla. - Routing: precompile session key regexes. (#1697) Thanks @Ray0907. - TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein. diff --git a/src/cli/security-cli.ts b/src/cli/security-cli.ts index 42bca4ca4..2bd5a36b7 100644 --- a/src/cli/security-cli.ts +++ b/src/cli/security-cli.ts @@ -87,16 +87,23 @@ export function registerSecurityCli(program: Command) { lines.push(muted(` ${shortenHomeInString(change)}`)); } for (const action of fixResult.actions) { - const mode = action.mode.toString(8).padStart(3, "0"); - if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`)); - else if (action.skipped) - lines.push( - muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`), - ); - else if (action.error) - lines.push( - muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`), - ); + if (action.kind === "chmod") { + const mode = action.mode.toString(8).padStart(3, "0"); + if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`)); + else if (action.skipped) + lines.push( + muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`), + ); + else if (action.error) + lines.push( + muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`), + ); + continue; + } + const command = shortenHomeInString(action.command); + if (action.ok) lines.push(muted(` ${command}`)); + else if (action.skipped) lines.push(muted(` skip ${command} (${action.skipped})`)); + else if (action.error) lines.push(muted(` ${command} failed: ${action.error}`)); } if (fixResult.errors.length > 0) { for (const err of fixResult.errors) { diff --git a/src/security/audit-extra.ts b/src/security/audit-extra.ts index 6dce5c896..9aabb9721 100644 --- a/src/security/audit-extra.ts +++ b/src/security/audit-extra.ts @@ -22,14 +22,12 @@ import type { SandboxToolPolicy } from "../agents/sandbox/types.js"; import { INCLUDE_KEY, MAX_INCLUDE_DEPTH } from "../config/includes.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { - formatOctal, - isGroupReadable, - isGroupWritable, - isWorldReadable, - isWorldWritable, - modeBits, + formatPermissionDetail, + formatPermissionRemediation, + inspectPathPermissions, safeStat, } from "./audit-fs.js"; +import type { ExecFn } from "./windows-acl.js"; export type SecurityAuditFinding = { checkId: string; @@ -707,6 +705,9 @@ async function collectIncludePathsRecursive(params: { export async function collectIncludeFilePermFindings(params: { configSnapshot: ConfigFileSnapshot; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + execIcacls?: ExecFn; }): Promise { const findings: SecurityAuditFinding[] = []; if (!params.configSnapshot.exists) return findings; @@ -720,32 +721,53 @@ export async function collectIncludeFilePermFindings(params: { for (const p of includePaths) { // eslint-disable-next-line no-await-in-loop - const st = await safeStat(p); - if (!st.ok) continue; - const bits = modeBits(st.mode); - if (isWorldWritable(bits) || isGroupWritable(bits)) { + const perms = await inspectPathPermissions(p, { + env: params.env, + platform: params.platform, + exec: params.execIcacls, + }); + if (!perms.ok) continue; + if (perms.worldWritable || perms.groupWritable) { findings.push({ checkId: "fs.config_include.perms_writable", severity: "critical", title: "Config include file is writable by others", - detail: `${p} mode=${formatOctal(bits)}; another user could influence your effective config.`, - remediation: `chmod 600 ${p}`, + detail: `${formatPermissionDetail(p, perms)}; another user could influence your effective config.`, + remediation: formatPermissionRemediation({ + targetPath: p, + perms, + isDir: false, + posixMode: 0o600, + env: params.env, + }), }); - } else if (isWorldReadable(bits)) { + } else if (perms.worldReadable) { findings.push({ checkId: "fs.config_include.perms_world_readable", severity: "critical", title: "Config include file is world-readable", - detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`, - remediation: `chmod 600 ${p}`, + detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`, + remediation: formatPermissionRemediation({ + targetPath: p, + perms, + isDir: false, + posixMode: 0o600, + env: params.env, + }), }); - } else if (isGroupReadable(bits)) { + } else if (perms.groupReadable) { findings.push({ checkId: "fs.config_include.perms_group_readable", severity: "warn", title: "Config include file is group-readable", - detail: `${p} mode=${formatOctal(bits)}; include files can contain tokens and private settings.`, - remediation: `chmod 600 ${p}`, + detail: `${formatPermissionDetail(p, perms)}; include files can contain tokens and private settings.`, + remediation: formatPermissionRemediation({ + targetPath: p, + perms, + isDir: false, + posixMode: 0o600, + env: params.env, + }), }); } } @@ -757,28 +779,45 @@ export async function collectStateDeepFilesystemFindings(params: { cfg: ClawdbotConfig; env: NodeJS.ProcessEnv; stateDir: string; + platform?: NodeJS.Platform; + execIcacls?: ExecFn; }): Promise { const findings: SecurityAuditFinding[] = []; const oauthDir = resolveOAuthDir(params.env, params.stateDir); - const oauthStat = await safeStat(oauthDir); - if (oauthStat.ok && oauthStat.isDir) { - const bits = modeBits(oauthStat.mode); - if (isWorldWritable(bits) || isGroupWritable(bits)) { + const oauthPerms = await inspectPathPermissions(oauthDir, { + env: params.env, + platform: params.platform, + exec: params.execIcacls, + }); + if (oauthPerms.ok && oauthPerms.isDir) { + if (oauthPerms.worldWritable || oauthPerms.groupWritable) { findings.push({ checkId: "fs.credentials_dir.perms_writable", severity: "critical", title: "Credentials dir is writable by others", - detail: `${oauthDir} mode=${formatOctal(bits)}; another user could drop/modify credential files.`, - remediation: `chmod 700 ${oauthDir}`, + detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; another user could drop/modify credential files.`, + remediation: formatPermissionRemediation({ + targetPath: oauthDir, + perms: oauthPerms, + isDir: true, + posixMode: 0o700, + env: params.env, + }), }); - } else if (isGroupReadable(bits) || isWorldReadable(bits)) { + } else if (oauthPerms.groupReadable || oauthPerms.worldReadable) { findings.push({ checkId: "fs.credentials_dir.perms_readable", severity: "warn", title: "Credentials dir is readable by others", - detail: `${oauthDir} mode=${formatOctal(bits)}; credentials and allowlists can be sensitive.`, - remediation: `chmod 700 ${oauthDir}`, + detail: `${formatPermissionDetail(oauthDir, oauthPerms)}; credentials and allowlists can be sensitive.`, + remediation: formatPermissionRemediation({ + targetPath: oauthDir, + perms: oauthPerms, + isDir: true, + posixMode: 0o700, + env: params.env, + }), }); } } @@ -795,40 +834,64 @@ export async function collectStateDeepFilesystemFindings(params: { const agentDir = path.join(params.stateDir, "agents", agentId, "agent"); const authPath = path.join(agentDir, "auth-profiles.json"); // eslint-disable-next-line no-await-in-loop - const authStat = await safeStat(authPath); - if (authStat.ok) { - const bits = modeBits(authStat.mode); - if (isWorldWritable(bits) || isGroupWritable(bits)) { + const authPerms = await inspectPathPermissions(authPath, { + env: params.env, + platform: params.platform, + exec: params.execIcacls, + }); + if (authPerms.ok) { + if (authPerms.worldWritable || authPerms.groupWritable) { findings.push({ checkId: "fs.auth_profiles.perms_writable", severity: "critical", title: "auth-profiles.json is writable by others", - detail: `${authPath} mode=${formatOctal(bits)}; another user could inject credentials.`, - remediation: `chmod 600 ${authPath}`, + detail: `${formatPermissionDetail(authPath, authPerms)}; another user could inject credentials.`, + remediation: formatPermissionRemediation({ + targetPath: authPath, + perms: authPerms, + isDir: false, + posixMode: 0o600, + env: params.env, + }), }); - } else if (isWorldReadable(bits) || isGroupReadable(bits)) { + } else if (authPerms.worldReadable || authPerms.groupReadable) { findings.push({ checkId: "fs.auth_profiles.perms_readable", severity: "warn", title: "auth-profiles.json is readable by others", - detail: `${authPath} mode=${formatOctal(bits)}; auth-profiles.json contains API keys and OAuth tokens.`, - remediation: `chmod 600 ${authPath}`, + detail: `${formatPermissionDetail(authPath, authPerms)}; auth-profiles.json contains API keys and OAuth tokens.`, + remediation: formatPermissionRemediation({ + targetPath: authPath, + perms: authPerms, + isDir: false, + posixMode: 0o600, + env: params.env, + }), }); } } const storePath = path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json"); // eslint-disable-next-line no-await-in-loop - const storeStat = await safeStat(storePath); - if (storeStat.ok) { - const bits = modeBits(storeStat.mode); - if (isWorldReadable(bits) || isGroupReadable(bits)) { + const storePerms = await inspectPathPermissions(storePath, { + env: params.env, + platform: params.platform, + exec: params.execIcacls, + }); + if (storePerms.ok) { + if (storePerms.worldReadable || storePerms.groupReadable) { findings.push({ checkId: "fs.sessions_store.perms_readable", severity: "warn", title: "sessions.json is readable by others", - detail: `${storePath} mode=${formatOctal(bits)}; routing and transcript metadata can be sensitive.`, - remediation: `chmod 600 ${storePath}`, + detail: `${formatPermissionDetail(storePath, storePerms)}; routing and transcript metadata can be sensitive.`, + remediation: formatPermissionRemediation({ + targetPath: storePath, + perms: storePerms, + isDir: false, + posixMode: 0o600, + env: params.env, + }), }); } } @@ -840,16 +903,25 @@ export async function collectStateDeepFilesystemFindings(params: { const expanded = logFile.startsWith("~") ? expandTilde(logFile, params.env) : logFile; if (expanded) { const logPath = path.resolve(expanded); - const st = await safeStat(logPath); - if (st.ok) { - const bits = modeBits(st.mode); - if (isWorldReadable(bits) || isGroupReadable(bits)) { + const logPerms = await inspectPathPermissions(logPath, { + env: params.env, + platform: params.platform, + exec: params.execIcacls, + }); + if (logPerms.ok) { + if (logPerms.worldReadable || logPerms.groupReadable) { findings.push({ checkId: "fs.log_file.perms_readable", severity: "warn", title: "Log file is readable by others", - detail: `${logPath} mode=${formatOctal(bits)}; logs can contain private messages and tool output.`, - remediation: `chmod 600 ${logPath}`, + detail: `${formatPermissionDetail(logPath, logPerms)}; logs can contain private messages and tool output.`, + remediation: formatPermissionRemediation({ + targetPath: logPath, + perms: logPerms, + isDir: false, + posixMode: 0o600, + env: params.env, + }), }); } } diff --git a/src/security/audit-fs.ts b/src/security/audit-fs.ts index 5832b64f8..6bf0aec26 100644 --- a/src/security/audit-fs.ts +++ b/src/security/audit-fs.ts @@ -1,5 +1,33 @@ import fs from "node:fs/promises"; +import { + formatIcaclsResetCommand, + formatWindowsAclSummary, + inspectWindowsAcl, + type ExecFn, +} from "./windows-acl.js"; + +export type PermissionCheck = { + ok: boolean; + isSymlink: boolean; + isDir: boolean; + mode: number | null; + bits: number | null; + source: "posix" | "windows-acl" | "unknown"; + worldWritable: boolean; + groupWritable: boolean; + worldReadable: boolean; + groupReadable: boolean; + aclSummary?: string; + error?: string; +}; + +export type PermissionCheckOptions = { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + exec?: ExecFn; +}; + export async function safeStat(targetPath: string): Promise<{ ok: boolean; isSymlink: boolean; @@ -32,6 +60,98 @@ export async function safeStat(targetPath: string): Promise<{ } } +export async function inspectPathPermissions( + targetPath: string, + opts?: PermissionCheckOptions, +): Promise { + const st = await safeStat(targetPath); + if (!st.ok) { + return { + ok: false, + isSymlink: false, + isDir: false, + mode: null, + bits: null, + source: "unknown", + worldWritable: false, + groupWritable: false, + worldReadable: false, + groupReadable: false, + error: st.error, + }; + } + + const bits = modeBits(st.mode); + const platform = opts?.platform ?? process.platform; + + if (platform === "win32") { + const acl = await inspectWindowsAcl(targetPath, { env: opts?.env, exec: opts?.exec }); + if (!acl.ok) { + return { + ok: true, + isSymlink: st.isSymlink, + isDir: st.isDir, + mode: st.mode, + bits, + source: "unknown", + worldWritable: false, + groupWritable: false, + worldReadable: false, + groupReadable: false, + error: acl.error, + }; + } + return { + ok: true, + isSymlink: st.isSymlink, + isDir: st.isDir, + mode: st.mode, + bits, + source: "windows-acl", + worldWritable: acl.untrustedWorld.some((entry) => entry.canWrite), + groupWritable: acl.untrustedGroup.some((entry) => entry.canWrite), + worldReadable: acl.untrustedWorld.some((entry) => entry.canRead), + groupReadable: acl.untrustedGroup.some((entry) => entry.canRead), + aclSummary: formatWindowsAclSummary(acl), + }; + } + + return { + ok: true, + isSymlink: st.isSymlink, + isDir: st.isDir, + mode: st.mode, + bits, + source: "posix", + worldWritable: isWorldWritable(bits), + groupWritable: isGroupWritable(bits), + worldReadable: isWorldReadable(bits), + groupReadable: isGroupReadable(bits), + }; +} + +export function formatPermissionDetail(targetPath: string, perms: PermissionCheck): string { + if (perms.source === "windows-acl") { + const summary = perms.aclSummary ?? "unknown"; + return `${targetPath} acl=${summary}`; + } + return `${targetPath} mode=${formatOctal(perms.bits)}`; +} + +export function formatPermissionRemediation(params: { + targetPath: string; + perms: PermissionCheck; + isDir: boolean; + posixMode: number; + env?: NodeJS.ProcessEnv; +}): string { + if (params.perms.source === "windows-acl") { + return formatIcaclsResetCommand(params.targetPath, { isDir: params.isDir, env: params.env }); + } + const mode = params.posixMode.toString(8).padStart(3, "0"); + return `chmod ${mode} ${params.targetPath}`; +} + export function modeBits(mode: number | null): number | null { if (mode == null) return null; return mode & 0o777; diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 294384abd..7dc0dd263 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -120,6 +120,83 @@ describe("security audit", () => { ); }); + it("treats Windows ACL-only perms as secure", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-audit-win-")); + const stateDir = path.join(tmp, "state"); + await fs.mkdir(stateDir, { recursive: true }); + const configPath = path.join(stateDir, "clawdbot.json"); + await fs.writeFile(configPath, "{}\n", "utf-8"); + + const user = "DESKTOP-TEST\\Tester"; + const execIcacls = async (_cmd: string, args: string[]) => ({ + stdout: `${args[0]} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`, + stderr: "", + }); + + const res = await runSecurityAudit({ + config: {}, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath, + platform: "win32", + env: { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" }, + execIcacls, + }); + + const forbidden = new Set([ + "fs.state_dir.perms_world_writable", + "fs.state_dir.perms_group_writable", + "fs.state_dir.perms_readable", + "fs.config.perms_writable", + "fs.config.perms_world_readable", + "fs.config.perms_group_readable", + ]); + for (const id of forbidden) { + expect(res.findings.some((f) => f.checkId === id)).toBe(false); + } + }); + + it("flags Windows ACLs when Users can read the state dir", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-security-audit-win-open-")); + const stateDir = path.join(tmp, "state"); + await fs.mkdir(stateDir, { recursive: true }); + const configPath = path.join(stateDir, "clawdbot.json"); + await fs.writeFile(configPath, "{}\n", "utf-8"); + + const user = "DESKTOP-TEST\\Tester"; + const execIcacls = async (_cmd: string, args: string[]) => { + const target = args[0]; + if (target === stateDir) { + return { + stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(RX)\n ${user}:(F)\n`, + stderr: "", + }; + } + return { + stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`, + stderr: "", + }; + }; + + const res = await runSecurityAudit({ + config: {}, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath, + platform: "win32", + env: { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" }, + execIcacls, + }); + + expect( + res.findings.some( + (f) => f.checkId === "fs.state_dir.perms_readable" && f.severity === "warn", + ), + ).toBe(true); + }); + it("warns when small models are paired with web/browser tools", async () => { const cfg: ClawdbotConfig = { agents: { defaults: { model: { primary: "ollama/mistral-8b" } } }, diff --git a/src/security/audit.ts b/src/security/audit.ts index 5b6df61b8..2169f197d 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -24,14 +24,11 @@ import { import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js"; import { - formatOctal, - isGroupReadable, - isGroupWritable, - isWorldReadable, - isWorldWritable, - modeBits, - safeStat, + formatPermissionDetail, + formatPermissionRemediation, + inspectPathPermissions, } from "./audit-fs.js"; +import type { ExecFn } from "./windows-acl.js"; export type SecurityAuditSeverity = "info" | "warn" | "critical"; @@ -66,6 +63,8 @@ export type SecurityAuditReport = { export type SecurityAuditOptions = { config: ClawdbotConfig; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; deep?: boolean; includeFilesystem?: boolean; includeChannelSecurity?: boolean; @@ -79,6 +78,8 @@ export type SecurityAuditOptions = { plugins?: ReturnType; /** Dependency injection for tests. */ probeGatewayFn?: typeof probeGateway; + /** Dependency injection for tests (Windows ACL checks). */ + execIcacls?: ExecFn; }; function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary { @@ -119,13 +120,19 @@ function classifyChannelWarningSeverity(message: string): SecurityAuditSeverity async function collectFilesystemFindings(params: { stateDir: string; configPath: string; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + execIcacls?: ExecFn; }): Promise { const findings: SecurityAuditFinding[] = []; - const stateDirStat = await safeStat(params.stateDir); - if (stateDirStat.ok) { - const bits = modeBits(stateDirStat.mode); - if (stateDirStat.isSymlink) { + const stateDirPerms = await inspectPathPermissions(params.stateDir, { + env: params.env, + platform: params.platform, + exec: params.execIcacls, + }); + if (stateDirPerms.ok) { + if (stateDirPerms.isSymlink) { findings.push({ checkId: "fs.state_dir.symlink", severity: "warn", @@ -133,37 +140,58 @@ async function collectFilesystemFindings(params: { detail: `${params.stateDir} is a symlink; treat this as an extra trust boundary.`, }); } - if (isWorldWritable(bits)) { + if (stateDirPerms.worldWritable) { findings.push({ checkId: "fs.state_dir.perms_world_writable", severity: "critical", title: "State dir is world-writable", - detail: `${params.stateDir} mode=${formatOctal(bits)}; other users can write into your Clawdbot state.`, - remediation: `chmod 700 ${params.stateDir}`, + detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; other users can write into your Clawdbot state.`, + remediation: formatPermissionRemediation({ + targetPath: params.stateDir, + perms: stateDirPerms, + isDir: true, + posixMode: 0o700, + env: params.env, + }), }); - } else if (isGroupWritable(bits)) { + } else if (stateDirPerms.groupWritable) { findings.push({ checkId: "fs.state_dir.perms_group_writable", severity: "warn", title: "State dir is group-writable", - detail: `${params.stateDir} mode=${formatOctal(bits)}; group users can write into your Clawdbot state.`, - remediation: `chmod 700 ${params.stateDir}`, + detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; group users can write into your Clawdbot state.`, + remediation: formatPermissionRemediation({ + targetPath: params.stateDir, + perms: stateDirPerms, + isDir: true, + posixMode: 0o700, + env: params.env, + }), }); - } else if (isGroupReadable(bits) || isWorldReadable(bits)) { + } else if (stateDirPerms.groupReadable || stateDirPerms.worldReadable) { findings.push({ checkId: "fs.state_dir.perms_readable", severity: "warn", title: "State dir is readable by others", - detail: `${params.stateDir} mode=${formatOctal(bits)}; consider restricting to 700.`, - remediation: `chmod 700 ${params.stateDir}`, + detail: `${formatPermissionDetail(params.stateDir, stateDirPerms)}; consider restricting to 700.`, + remediation: formatPermissionRemediation({ + targetPath: params.stateDir, + perms: stateDirPerms, + isDir: true, + posixMode: 0o700, + env: params.env, + }), }); } } - const configStat = await safeStat(params.configPath); - if (configStat.ok) { - const bits = modeBits(configStat.mode); - if (configStat.isSymlink) { + const configPerms = await inspectPathPermissions(params.configPath, { + env: params.env, + platform: params.platform, + exec: params.execIcacls, + }); + if (configPerms.ok) { + if (configPerms.isSymlink) { findings.push({ checkId: "fs.config.symlink", severity: "warn", @@ -171,29 +199,47 @@ async function collectFilesystemFindings(params: { detail: `${params.configPath} is a symlink; make sure you trust its target.`, }); } - if (isWorldWritable(bits) || isGroupWritable(bits)) { + if (configPerms.worldWritable || configPerms.groupWritable) { findings.push({ checkId: "fs.config.perms_writable", severity: "critical", title: "Config file is writable by others", - detail: `${params.configPath} mode=${formatOctal(bits)}; another user could change gateway/auth/tool policies.`, - remediation: `chmod 600 ${params.configPath}`, + detail: `${formatPermissionDetail(params.configPath, configPerms)}; another user could change gateway/auth/tool policies.`, + remediation: formatPermissionRemediation({ + targetPath: params.configPath, + perms: configPerms, + isDir: false, + posixMode: 0o600, + env: params.env, + }), }); - } else if (isWorldReadable(bits)) { + } else if (configPerms.worldReadable) { findings.push({ checkId: "fs.config.perms_world_readable", severity: "critical", title: "Config file is world-readable", - detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`, - remediation: `chmod 600 ${params.configPath}`, + detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`, + remediation: formatPermissionRemediation({ + targetPath: params.configPath, + perms: configPerms, + isDir: false, + posixMode: 0o600, + env: params.env, + }), }); - } else if (isGroupReadable(bits)) { + } else if (configPerms.groupReadable) { findings.push({ checkId: "fs.config.perms_group_readable", severity: "warn", title: "Config file is group-readable", - detail: `${params.configPath} mode=${formatOctal(bits)}; config can contain tokens and private settings.`, - remediation: `chmod 600 ${params.configPath}`, + detail: `${formatPermissionDetail(params.configPath, configPerms)}; config can contain tokens and private settings.`, + remediation: formatPermissionRemediation({ + targetPath: params.configPath, + perms: configPerms, + isDir: false, + posixMode: 0o600, + env: params.env, + }), }); } } @@ -850,7 +896,9 @@ async function maybeProbeGateway(params: { export async function runSecurityAudit(opts: SecurityAuditOptions): Promise { const findings: SecurityAuditFinding[] = []; const cfg = opts.config; - const env = process.env; + const env = opts.env ?? process.env; + const platform = opts.platform ?? process.platform; + const execIcacls = opts.execIcacls; const stateDir = opts.stateDir ?? resolveStateDir(env); const configPath = opts.configPath ?? resolveConfigPath(env, stateDir); @@ -873,11 +921,23 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise { + const display = formatIcaclsResetCommand(params.path, { + isDir: params.require === "dir", + env: params.env, + }); + try { + const st = await fs.lstat(params.path); + if (st.isSymbolicLink()) { + return { + kind: "icacls", + path: params.path, + command: display, + ok: false, + skipped: "symlink", + }; + } + if (params.require === "dir" && !st.isDirectory()) { + return { + kind: "icacls", + path: params.path, + command: display, + ok: false, + skipped: "not-a-directory", + }; + } + if (params.require === "file" && !st.isFile()) { + return { + kind: "icacls", + path: params.path, + command: display, + ok: false, + skipped: "not-a-file", + }; + } + const cmd = createIcaclsResetCommand(params.path, { + isDir: st.isDirectory(), + env: params.env, + }); + if (!cmd) { + return { + kind: "icacls", + path: params.path, + command: display, + ok: false, + skipped: "missing-user", + }; + } + const exec = params.exec ?? runExec; + await exec(cmd.command, cmd.args); + return { kind: "icacls", path: params.path, command: cmd.display, ok: true }; + } catch (err) { + const code = (err as { code?: string }).code; + if (code === "ENOENT") { + return { + kind: "icacls", + path: params.path, + command: display, + ok: false, + skipped: "missing", + }; + } + return { + kind: "icacls", + path: params.path, + command: display, + ok: false, + error: String(err), + }; + } +} + function setGroupPolicyAllowlist(params: { cfg: ClawdbotConfig; channel: string; @@ -261,7 +350,12 @@ async function chmodCredentialsAndAgentState(params: { env: NodeJS.ProcessEnv; stateDir: string; cfg: ClawdbotConfig; - actions: SecurityFixChmodAction[]; + actions: SecurityFixAction[]; + applyPerms: (params: { + path: string; + mode: number; + require: "dir" | "file"; + }) => Promise; }): Promise { const credsDir = resolveOAuthDir(params.env, params.stateDir); params.actions.push(await safeChmod({ path: credsDir, mode: 0o700, require: "dir" })); @@ -294,18 +388,20 @@ async function chmodCredentialsAndAgentState(params: { // eslint-disable-next-line no-await-in-loop params.actions.push(await safeChmod({ path: agentRoot, mode: 0o700, require: "dir" })); // eslint-disable-next-line no-await-in-loop - params.actions.push(await safeChmod({ path: agentDir, mode: 0o700, require: "dir" })); + params.actions.push(await params.applyPerms({ path: agentDir, mode: 0o700, require: "dir" })); const authPath = path.join(agentDir, "auth-profiles.json"); // eslint-disable-next-line no-await-in-loop - params.actions.push(await safeChmod({ path: authPath, mode: 0o600, require: "file" })); + params.actions.push(await params.applyPerms({ path: authPath, mode: 0o600, require: "file" })); // eslint-disable-next-line no-await-in-loop - params.actions.push(await safeChmod({ path: sessionsDir, mode: 0o700, require: "dir" })); + params.actions.push( + await params.applyPerms({ path: sessionsDir, mode: 0o700, require: "dir" }), + ); const storePath = path.join(sessionsDir, "sessions.json"); // eslint-disable-next-line no-await-in-loop - params.actions.push(await safeChmod({ path: storePath, mode: 0o600, require: "file" })); + params.actions.push(await params.applyPerms({ path: storePath, mode: 0o600, require: "file" })); } } @@ -313,11 +409,16 @@ export async function fixSecurityFootguns(opts?: { env?: NodeJS.ProcessEnv; stateDir?: string; configPath?: string; + platform?: NodeJS.Platform; + exec?: ExecFn; }): Promise { const env = opts?.env ?? process.env; + const platform = opts?.platform ?? process.platform; + const exec = opts?.exec ?? runExec; + const isWindows = platform === "win32"; const stateDir = opts?.stateDir ?? resolveStateDir(env); const configPath = opts?.configPath ?? resolveConfigPath(env, stateDir); - const actions: SecurityFixChmodAction[] = []; + const actions: SecurityFixAction[] = []; const errors: string[] = []; const io = createConfigIO({ env, configPath }); @@ -352,8 +453,13 @@ export async function fixSecurityFootguns(opts?: { } } - actions.push(await safeChmod({ path: stateDir, mode: 0o700, require: "dir" })); - actions.push(await safeChmod({ path: configPath, mode: 0o600, require: "file" })); + const applyPerms = (params: { path: string; mode: number; require: "dir" | "file" }) => + isWindows + ? safeAclReset({ path: params.path, require: params.require, env, exec }) + : safeChmod({ path: params.path, mode: params.mode, require: params.require }); + + actions.push(await applyPerms({ path: stateDir, mode: 0o700, require: "dir" })); + actions.push(await applyPerms({ path: configPath, mode: 0o600, require: "file" })); if (snap.exists) { const includePaths = await collectIncludePathsRecursive({ @@ -362,15 +468,19 @@ export async function fixSecurityFootguns(opts?: { }).catch(() => []); for (const p of includePaths) { // eslint-disable-next-line no-await-in-loop - actions.push(await safeChmod({ path: p, mode: 0o600, require: "file" })); + actions.push(await applyPerms({ path: p, mode: 0o600, require: "file" })); } } - await chmodCredentialsAndAgentState({ env, stateDir, cfg: snap.config ?? {}, actions }).catch( - (err) => { - errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`); - }, - ); + await chmodCredentialsAndAgentState({ + env, + stateDir, + cfg: snap.config ?? {}, + actions, + applyPerms, + }).catch((err) => { + errors.push(`chmodCredentialsAndAgentState failed: ${String(err)}`); + }); return { ok: errors.length === 0, diff --git a/src/security/windows-acl.ts b/src/security/windows-acl.ts new file mode 100644 index 000000000..0a6779214 --- /dev/null +++ b/src/security/windows-acl.ts @@ -0,0 +1,203 @@ +import os from "node:os"; + +import { runExec } from "../process/exec.js"; + +export type ExecFn = typeof runExec; + +export type WindowsAclEntry = { + principal: string; + rights: string[]; + rawRights: string; + canRead: boolean; + canWrite: boolean; +}; + +export type WindowsAclSummary = { + ok: boolean; + entries: WindowsAclEntry[]; + untrustedWorld: WindowsAclEntry[]; + untrustedGroup: WindowsAclEntry[]; + trusted: WindowsAclEntry[]; + error?: string; +}; + +const INHERIT_FLAGS = new Set(["I", "OI", "CI", "IO", "NP"]); +const WORLD_PRINCIPALS = new Set([ + "everyone", + "users", + "builtin\\users", + "authenticated users", + "nt authority\\authenticated users", +]); +const TRUSTED_BASE = new Set([ + "nt authority\\system", + "system", + "builtin\\administrators", + "creator owner", +]); +const WORLD_SUFFIXES = ["\\users", "\\authenticated users"]; +const TRUSTED_SUFFIXES = ["\\administrators", "\\system"]; + +const normalize = (value: string) => value.trim().toLowerCase(); + +export function resolveWindowsUserPrincipal(env?: NodeJS.ProcessEnv): string | null { + const username = env?.USERNAME?.trim() || os.userInfo().username?.trim(); + if (!username) return null; + const domain = env?.USERDOMAIN?.trim(); + return domain ? `${domain}\\${username}` : username; +} + +function buildTrustedPrincipals(env?: NodeJS.ProcessEnv): Set { + const trusted = new Set(TRUSTED_BASE); + const principal = resolveWindowsUserPrincipal(env); + if (principal) { + trusted.add(normalize(principal)); + const parts = principal.split("\\"); + const userOnly = parts.at(-1); + if (userOnly) trusted.add(normalize(userOnly)); + } + return trusted; +} + +function classifyPrincipal( + principal: string, + env?: NodeJS.ProcessEnv, +): "trusted" | "world" | "group" { + const normalized = normalize(principal); + const trusted = buildTrustedPrincipals(env); + if (trusted.has(normalized) || TRUSTED_SUFFIXES.some((s) => normalized.endsWith(s))) + return "trusted"; + if (WORLD_PRINCIPALS.has(normalized) || WORLD_SUFFIXES.some((s) => normalized.endsWith(s))) + return "world"; + return "group"; +} + +function rightsFromTokens(tokens: string[]): { canRead: boolean; canWrite: boolean } { + const upper = tokens.join("").toUpperCase(); + const canWrite = + upper.includes("F") || upper.includes("M") || upper.includes("W") || upper.includes("D"); + const canRead = upper.includes("F") || upper.includes("M") || upper.includes("R"); + return { canRead, canWrite }; +} + +export function parseIcaclsOutput(output: string, targetPath: string): WindowsAclEntry[] { + const entries: WindowsAclEntry[] = []; + const normalizedTarget = targetPath.trim(); + const lowerTarget = normalizedTarget.toLowerCase(); + const quotedTarget = `"${normalizedTarget}"`; + const quotedLower = quotedTarget.toLowerCase(); + + for (const rawLine of output.split(/\r?\n/)) { + const line = rawLine.trimEnd(); + if (!line.trim()) continue; + const trimmed = line.trim(); + const lower = trimmed.toLowerCase(); + if ( + lower.startsWith("successfully processed") || + lower.startsWith("processed") || + lower.startsWith("failed processing") || + lower.startsWith("no mapping between account names") + ) { + continue; + } + + let entry = trimmed; + if (lower.startsWith(lowerTarget)) { + entry = trimmed.slice(normalizedTarget.length).trim(); + } else if (lower.startsWith(quotedLower)) { + entry = trimmed.slice(quotedTarget.length).trim(); + } + if (!entry) continue; + + const idx = entry.indexOf(":"); + if (idx === -1) continue; + + const principal = entry.slice(0, idx).trim(); + const rawRights = entry.slice(idx + 1).trim(); + const tokens = + rawRights + .match(/\(([^)]+)\)/g) + ?.map((token) => token.slice(1, -1).trim()) + .filter(Boolean) ?? []; + if (tokens.some((token) => token.toUpperCase() === "DENY")) continue; + const rights = tokens.filter((token) => !INHERIT_FLAGS.has(token.toUpperCase())); + if (rights.length === 0) continue; + const { canRead, canWrite } = rightsFromTokens(rights); + entries.push({ principal, rights, rawRights, canRead, canWrite }); + } + + return entries; +} + +export function summarizeWindowsAcl( + entries: WindowsAclEntry[], + env?: NodeJS.ProcessEnv, +): Pick { + const trusted: WindowsAclEntry[] = []; + const untrustedWorld: WindowsAclEntry[] = []; + const untrustedGroup: WindowsAclEntry[] = []; + for (const entry of entries) { + const classification = classifyPrincipal(entry.principal, env); + if (classification === "trusted") trusted.push(entry); + else if (classification === "world") untrustedWorld.push(entry); + else untrustedGroup.push(entry); + } + return { trusted, untrustedWorld, untrustedGroup }; +} + +export async function inspectWindowsAcl( + targetPath: string, + opts?: { env?: NodeJS.ProcessEnv; exec?: ExecFn }, +): Promise { + const exec = opts?.exec ?? runExec; + try { + const { stdout, stderr } = await exec("icacls", [targetPath]); + const output = `${stdout}\n${stderr}`.trim(); + const entries = parseIcaclsOutput(output, targetPath); + const { trusted, untrustedWorld, untrustedGroup } = summarizeWindowsAcl(entries, opts?.env); + return { ok: true, entries, trusted, untrustedWorld, untrustedGroup }; + } catch (err) { + return { + ok: false, + entries: [], + trusted: [], + untrustedWorld: [], + untrustedGroup: [], + error: String(err), + }; + } +} + +export function formatWindowsAclSummary(summary: WindowsAclSummary): string { + if (!summary.ok) return "unknown"; + const untrusted = [...summary.untrustedWorld, ...summary.untrustedGroup]; + if (untrusted.length === 0) return "trusted-only"; + return untrusted.map((entry) => `${entry.principal}:${entry.rawRights}`).join(", "); +} + +export function formatIcaclsResetCommand( + targetPath: string, + opts: { isDir: boolean; env?: NodeJS.ProcessEnv }, +): string { + const user = resolveWindowsUserPrincipal(opts.env) ?? "%USERNAME%"; + const grant = opts.isDir ? "(OI)(CI)F" : "F"; + return `icacls "${targetPath}" /inheritance:r /grant:r "${user}:${grant}" /grant:r "SYSTEM:${grant}"`; +} + +export function createIcaclsResetCommand( + targetPath: string, + opts: { isDir: boolean; env?: NodeJS.ProcessEnv }, +): { command: string; args: string[]; display: string } | null { + const user = resolveWindowsUserPrincipal(opts.env); + if (!user) return null; + const grant = opts.isDir ? "(OI)(CI)F" : "F"; + const args = [ + targetPath, + "/inheritance:r", + "/grant:r", + `${user}:${grant}`, + "/grant:r", + `SYSTEM:${grant}`, + ]; + return { command: "icacls", args, display: formatIcaclsResetCommand(targetPath, opts) }; +} From 3314b3996e3af2494e27c6f4401647bf15958ece Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 18:18:55 +0000 Subject: [PATCH 34/40] fix: harden gateway auth defaults --- CHANGELOG.md | 4 + src/gateway/auth.test.ts | 91 +------------------ src/gateway/auth.ts | 13 +-- src/gateway/gateway.e2e.test.ts | 3 +- src/gateway/server.auth.e2e.test.ts | 36 +++----- .../server/ws-connection/message-handler.ts | 64 ++++++------- src/gateway/test-helpers.server.ts | 3 + src/security/audit.test.ts | 2 +- 8 files changed, 65 insertions(+), 151 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d95c8c124..16c1a05ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,9 @@ Status: unreleased. - Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999. - macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal. +### Breaking +- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). + ### Fixes - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. @@ -53,6 +56,7 @@ Status: unreleased. - Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93. - Security: wrap external hook content by default with a per-hook opt-out. (#1827) Thanks @mertcicekci0. - Gateway: default auth now fail-closed (token/password required; Tailscale Serve identity remains allowed). +- Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present. - Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags. ## 2026.1.24-3 diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 90bd5c41e..7e1022124 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -5,8 +5,8 @@ import { authorizeGatewayConnect } from "./auth.js"; describe("gateway auth", () => { it("does not throw when req is missing socket", async () => { const res = await authorizeGatewayConnect({ - auth: { mode: "none", allowTailscale: false }, - connectAuth: null, + auth: { mode: "token", token: "secret", allowTailscale: false }, + connectAuth: { token: "secret" }, // Regression: avoid crashing on req.socket.remoteAddress when callers pass a non-IncomingMessage. req: {} as never, }); @@ -63,40 +63,10 @@ describe("gateway auth", () => { expect(res.reason).toBe("password_missing_config"); }); - it("reports tailscale auth reasons when required", async () => { - const reqBase = { - socket: { remoteAddress: "100.100.100.100" }, - headers: { host: "gateway.local" }, - }; - - const missingUser = await authorizeGatewayConnect({ - auth: { mode: "none", allowTailscale: true }, - connectAuth: null, - req: reqBase as never, - }); - expect(missingUser.ok).toBe(false); - expect(missingUser.reason).toBe("tailscale_user_missing"); - - const missingProxy = await authorizeGatewayConnect({ - auth: { mode: "none", allowTailscale: true }, - connectAuth: null, - req: { - ...reqBase, - headers: { - host: "gateway.local", - "tailscale-user-login": "peter", - "tailscale-user-name": "Peter", - }, - } as never, - }); - expect(missingProxy.ok).toBe(false); - expect(missingProxy.reason).toBe("tailscale_proxy_missing"); - }); - it("treats local tailscale serve hostnames as direct", async () => { const res = await authorizeGatewayConnect({ - auth: { mode: "none", allowTailscale: true }, - connectAuth: null, + auth: { mode: "token", token: "secret", allowTailscale: true }, + connectAuth: { token: "secret" }, req: { socket: { remoteAddress: "127.0.0.1" }, headers: { host: "gateway.tailnet-1234.ts.net:443" }, @@ -104,21 +74,7 @@ describe("gateway auth", () => { }); expect(res.ok).toBe(true); - expect(res.method).toBe("none"); - }); - - it("does not treat tailscale clients as direct", async () => { - const res = await authorizeGatewayConnect({ - auth: { mode: "none", allowTailscale: true }, - connectAuth: null, - req: { - socket: { remoteAddress: "100.64.0.42" }, - headers: { host: "gateway.tailnet-1234.ts.net" }, - } as never, - }); - - expect(res.ok).toBe(false); - expect(res.reason).toBe("tailscale_user_missing"); + expect(res.method).toBe("token"); }); it("allows tailscale identity to satisfy token mode auth", async () => { @@ -143,41 +99,4 @@ describe("gateway auth", () => { expect(res.method).toBe("tailscale"); expect(res.user).toBe("peter"); }); - - it("rejects mismatched tailscale identity when required", async () => { - const res = await authorizeGatewayConnect({ - auth: { mode: "none", allowTailscale: true }, - connectAuth: null, - tailscaleWhois: async () => ({ login: "alice@example.com", name: "Alice" }), - req: { - socket: { remoteAddress: "127.0.0.1" }, - headers: { - host: "gateway.local", - "x-forwarded-for": "100.64.0.1", - "x-forwarded-proto": "https", - "x-forwarded-host": "ai-hub.bone-egret.ts.net", - "tailscale-user-login": "peter@example.com", - "tailscale-user-name": "Peter", - }, - } as never, - }); - - expect(res.ok).toBe(false); - expect(res.reason).toBe("tailscale_user_mismatch"); - }); - - it("treats trusted proxy loopback clients as direct", async () => { - const res = await authorizeGatewayConnect({ - auth: { mode: "none", allowTailscale: true }, - connectAuth: null, - trustedProxies: ["10.0.0.2"], - req: { - socket: { remoteAddress: "10.0.0.2" }, - headers: { host: "localhost", "x-forwarded-for": "127.0.0.1" }, - } as never, - }); - - expect(res.ok).toBe(true); - expect(res.method).toBe("none"); - }); }); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index f716be5dd..1adc367a2 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -3,7 +3,7 @@ import type { IncomingMessage } from "node:http"; import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js"; import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js"; import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js"; -export type ResolvedGatewayAuthMode = "none" | "token" | "password"; +export type ResolvedGatewayAuthMode = "token" | "password"; export type ResolvedGatewayAuth = { mode: ResolvedGatewayAuthMode; @@ -14,7 +14,7 @@ export type ResolvedGatewayAuth = { export type GatewayAuthResult = { ok: boolean; - method?: "none" | "token" | "password" | "tailscale" | "device-token"; + method?: "token" | "password" | "tailscale" | "device-token"; user?: string; reason?: string; }; @@ -84,7 +84,7 @@ function resolveRequestClientIp( }); } -function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean { +export function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean { if (!req) return false; const clientIp = resolveRequestClientIp(req, trustedProxies) ?? ""; if (!isLoopbackAddress(clientIp)) return false; @@ -219,13 +219,6 @@ export async function authorizeGatewayConnect(params: { user: tailscaleCheck.user.login, }; } - if (auth.mode === "none") { - return { ok: false, reason: tailscaleCheck.reason }; - } - } - - if (auth.mode === "none") { - return { ok: true, method: "none" }; } if (auth.mode === "token") { diff --git a/src/gateway/gateway.e2e.test.ts b/src/gateway/gateway.e2e.test.ts index 47ce694ce..0f65d16ac 100644 --- a/src/gateway/gateway.e2e.test.ts +++ b/src/gateway/gateway.e2e.test.ts @@ -181,7 +181,7 @@ describe("gateway e2e", () => { const port = await getFreeGatewayPort(); const server = await startGatewayServer(port, { bind: "loopback", - auth: { mode: "none" }, + auth: { mode: "token", token: wizardToken }, controlUiEnabled: false, wizardRunner: async (_opts, _runtime, prompter) => { await prompter.intro("Wizard E2E"); @@ -197,6 +197,7 @@ describe("gateway e2e", () => { const client = await connectGatewayClient({ url: `ws://127.0.0.1:${port}`, + token: wizardToken, clientDisplayName: "vitest-wizard", }); diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 3f9994205..2eb3dcef9 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -122,6 +122,18 @@ describe("gateway server auth/connect", () => { await new Promise((resolve) => ws.once("close", () => resolve())); }); + test("requires nonce when host is non-local", async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`, { + headers: { host: "example.com" }, + }); + await new Promise((resolve) => ws.once("open", resolve)); + + const res = await connectReq(ws); + expect(res.ok).toBe(false); + expect(res.error?.message).toBe("device nonce required"); + await new Promise((resolve) => ws.once("close", () => resolve())); + }); + test( "invalid connect params surface in response and close reason", { timeout: 60_000 }, @@ -290,6 +302,7 @@ describe("gateway server auth/connect", () => { test("allows control ui with device identity when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; + testState.gatewayAuth = { mode: "token", token: "secret" }; const { writeConfigFile } = await import("../config/config.js"); await writeConfigFile({ gateway: { @@ -354,6 +367,7 @@ describe("gateway server auth/connect", () => { test("allows control ui with stale device identity when device auth is disabled", async () => { testState.gatewayControlUi = { dangerouslyDisableDeviceAuth: true }; + testState.gatewayAuth = { mode: "token", token: "secret" }; const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; process.env.CLAWDBOT_GATEWAY_TOKEN = "secret"; const port = await getFreePort(); @@ -399,28 +413,6 @@ describe("gateway server auth/connect", () => { } }); - test("rejects proxied connections without auth when proxy headers are untrusted", async () => { - testState.gatewayAuth = { mode: "none" }; - const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN; - delete process.env.CLAWDBOT_GATEWAY_TOKEN; - const port = await getFreePort(); - const server = await startGatewayServer(port); - const ws = new WebSocket(`ws://127.0.0.1:${port}`, { - headers: { "x-forwarded-for": "203.0.113.10" }, - }); - await new Promise((resolve) => ws.once("open", resolve)); - const res = await connectReq(ws, { skipDefaultAuth: true }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("gateway auth required"); - ws.close(); - await server.close(); - if (prevToken === undefined) { - delete process.env.CLAWDBOT_GATEWAY_TOKEN; - } else { - process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; - } - }); - test("accepts device token auth for paired device", async () => { const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const { approveDevicePairing, getPairedDevice, listDevicePairing } = diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 3ff455295..d1f6ae511 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -23,10 +23,10 @@ import { rawDataToString } from "../../../infra/ws.js"; import type { createSubsystemLogger } from "../../../logging/subsystem.js"; import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js"; import type { ResolvedGatewayAuth } from "../../auth.js"; -import { authorizeGatewayConnect } from "../../auth.js"; +import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js"; import { loadConfig } from "../../../config/config.js"; import { buildDeviceAuthPayload } from "../../device-auth.js"; -import { isLocalGatewayAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js"; +import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js"; import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; import { type ConnectParams, @@ -60,6 +60,17 @@ type SubsystemLogger = ReturnType; const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000; +function resolveHostName(hostHeader?: string): string { + const host = (hostHeader ?? "").trim().toLowerCase(); + if (!host) return ""; + if (host.startsWith("[")) { + const end = host.indexOf("]"); + if (end !== -1) return host.slice(1, end); + } + const [name] = host.split(":"); + return name ?? ""; +} + type AuthProvidedKind = "token" | "password" | "none"; function formatGatewayAuthFailureMessage(params: { @@ -189,8 +200,17 @@ export function attachGatewayWsMessageHandler(params: { const hasProxyHeaders = Boolean(forwardedFor || realIp); const remoteIsTrustedProxy = isTrustedProxyAddress(remoteAddr, trustedProxies); const hasUntrustedProxyHeaders = hasProxyHeaders && !remoteIsTrustedProxy; - const isLocalClient = !hasUntrustedProxyHeaders && isLocalGatewayAddress(clientIp); - const reportedClientIp = hasUntrustedProxyHeaders ? undefined : clientIp; + const hostName = resolveHostName(requestHost); + const hostIsLocal = hostName === "localhost" || hostName === "127.0.0.1" || hostName === "::1"; + const hostIsTailscaleServe = hostName.endsWith(".ts.net"); + const hostIsLocalish = hostIsLocal || hostIsTailscaleServe; + const isLocalClient = isLocalDirectRequest(upgradeReq, trustedProxies); + const reportedClientIp = + isLocalClient || hasUntrustedProxyHeaders + ? undefined + : clientIp && !isLoopbackAddress(clientIp) + ? clientIp + : undefined; if (hasUntrustedProxyHeaders) { logWsControl.warn( @@ -199,6 +219,13 @@ export function attachGatewayWsMessageHandler(params: { "Configure gateway.trustedProxies to restore local client detection behind your proxy.", ); } + if (!hostIsLocalish && isLoopbackAddress(remoteAddr) && !hasProxyHeaders) { + logWsControl.warn( + "Loopback connection with non-local Host header. " + + "Treating it as remote. If you're behind a reverse proxy, " + + "set gateway.trustedProxies and forward X-Forwarded-For/X-Real-IP.", + ); + } const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client); @@ -347,32 +374,6 @@ export function attachGatewayWsMessageHandler(params: { isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true; const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth; const device = disableControlUiDeviceAuth ? null : deviceRaw; - if (hasUntrustedProxyHeaders && resolvedAuth.mode === "none") { - setHandshakeState("failed"); - setCloseCause("proxy-auth-required", { - client: connectParams.client.id, - clientDisplayName: connectParams.client.displayName, - mode: connectParams.client.mode, - version: connectParams.client.version, - }); - send({ - type: "res", - id: frame.id, - ok: false, - error: errorShape( - ErrorCodes.INVALID_REQUEST, - "gateway auth required behind reverse proxy", - { - details: { - hint: "set gateway.auth or configure gateway.trustedProxies", - }, - }, - ), - }); - close(1008, "gateway auth required"); - return; - } - if (!device) { const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth; @@ -570,7 +571,8 @@ export function attachGatewayWsMessageHandler(params: { trustedProxies, }); let authOk = authResult.ok; - let authMethod = authResult.method ?? "none"; + let authMethod = + authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token"); if (!authOk && connectParams.auth?.token && device) { const tokenCheck = await verifyDeviceToken({ deviceId: device.id, diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 254365564..34c22c573 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -260,6 +260,9 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio export async function startServerWithClient(token?: string, opts?: GatewayServerOptions) { let port = await getFreePort(); const prev = process.env.CLAWDBOT_GATEWAY_TOKEN; + if (typeof token === "string") { + testState.gatewayAuth = { mode: "token", token }; + } const fallbackToken = token ?? (typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 7dc0dd263..e87a6b47c 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -82,7 +82,7 @@ describe("security audit", () => { gateway: { bind: "loopback", controlUi: { enabled: true }, - auth: { mode: "none" as any }, + auth: {}, }, }; From 39d219da591858def0a5072f61cb8e8da34b8f18 Mon Sep 17 00:00:00 2001 From: alexstyl <1665273+alexstyl@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:25:55 +0700 Subject: [PATCH 35/40] Add FUNDING.yml --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..f6fca8c5e --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ['https://github.com/sponsors/steipete'] From 526303d9a2cf108308954639aa33a285fb8000f6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 19:04:29 +0000 Subject: [PATCH 36/40] refactor(auth)!: remove external CLI OAuth reuse --- src/agents/auth-health.ts | 8 +- src/agents/auth-profiles/external-cli-sync.ts | 185 +----------------- src/agents/auth-profiles/oauth.ts | 9 +- src/agents/auth-profiles/store.ts | 24 +-- src/cli/models-cli.ts | 2 +- src/cli/program/register.onboard.ts | 2 +- src/commands/agents.commands.add.ts | 1 - src/commands/auth-choice-options.ts | 70 +------ src/commands/auth-choice-prompt.ts | 2 - src/commands/auth-choice.apply.anthropic.ts | 155 +-------------- src/commands/auth-choice.apply.openai.ts | 41 ---- src/commands/channels/list.ts | 8 +- src/commands/configure.gateway-auth.ts | 6 +- src/commands/doctor-auth.ts | 151 +++++++++++++- src/commands/doctor.ts | 7 +- src/commands/models/auth.ts | 53 +++-- src/commands/models/list.status-command.ts | 6 +- .../local/auth-choice.ts | 65 +++--- src/commands/onboard.ts | 26 ++- src/infra/provider-usage.auth.ts | 5 +- src/wizard/onboarding.ts | 1 - 21 files changed, 260 insertions(+), 567 deletions(-) diff --git a/src/agents/auth-health.ts b/src/agents/auth-health.ts index 96e79dc66..15bf3a07f 100644 --- a/src/agents/auth-health.ts +++ b/src/agents/auth-health.ts @@ -2,12 +2,10 @@ import type { ClawdbotConfig } from "../config/config.js"; import { type AuthProfileCredential, type AuthProfileStore, - CLAUDE_CLI_PROFILE_ID, - CODEX_CLI_PROFILE_ID, resolveAuthProfileDisplayLabel, } from "./auth-profiles.js"; -export type AuthProfileSource = "claude-cli" | "codex-cli" | "store"; +export type AuthProfileSource = "store"; export type AuthProfileHealthStatus = "ok" | "expiring" | "expired" | "missing" | "static"; @@ -41,9 +39,7 @@ export type AuthHealthSummary = { export const DEFAULT_OAUTH_WARN_MS = 24 * 60 * 60 * 1000; -export function resolveAuthProfileSource(profileId: string): AuthProfileSource { - if (profileId === CLAUDE_CLI_PROFILE_ID) return "claude-cli"; - if (profileId === CODEX_CLI_PROFILE_ID) return "codex-cli"; +export function resolveAuthProfileSource(_profileId: string): AuthProfileSource { return "store"; } diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 8a7d8270f..d1fa31f23 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -1,22 +1,11 @@ +import { readQwenCliCredentialsCached } from "../cli-credentials.js"; import { - readClaudeCliCredentialsCached, - readCodexCliCredentialsCached, - readQwenCliCredentialsCached, -} from "../cli-credentials.js"; -import { - CLAUDE_CLI_PROFILE_ID, - CODEX_CLI_PROFILE_ID, EXTERNAL_CLI_NEAR_EXPIRY_MS, EXTERNAL_CLI_SYNC_TTL_MS, QWEN_CLI_PROFILE_ID, log, } from "./constants.js"; -import type { - AuthProfileCredential, - AuthProfileStore, - OAuthCredential, - TokenCredential, -} from "./types.js"; +import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from "./types.js"; function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean { if (!a) return false; @@ -33,25 +22,10 @@ function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCr ); } -function shallowEqualTokenCredentials(a: TokenCredential | undefined, b: TokenCredential): boolean { - if (!a) return false; - if (a.type !== "token") return false; - return ( - a.provider === b.provider && - a.token === b.token && - a.expires === b.expires && - a.email === b.email - ); -} - function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean { if (!cred) return false; if (cred.type !== "oauth" && cred.type !== "token") return false; - if ( - cred.provider !== "anthropic" && - cred.provider !== "openai-codex" && - cred.provider !== "qwen-portal" - ) { + if (cred.provider !== "qwen-portal") { return false; } if (typeof cred.expires !== "number") return true; @@ -59,163 +33,14 @@ function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: nu } /** - * Find any existing openai-codex profile (other than codex-cli) that has the same - * access and refresh tokens. This prevents creating a duplicate codex-cli profile - * when the user has already set up a custom profile with the same credentials. - */ -export function findDuplicateCodexProfile( - store: AuthProfileStore, - creds: OAuthCredential, -): string | undefined { - for (const [profileId, profile] of Object.entries(store.profiles)) { - if (profileId === CODEX_CLI_PROFILE_ID) continue; - if (profile.type !== "oauth") continue; - if (profile.provider !== "openai-codex") continue; - if (profile.access === creds.access && profile.refresh === creds.refresh) { - return profileId; - } - } - return undefined; -} - -/** - * Sync OAuth credentials from external CLI tools (Claude Code CLI, Codex CLI) into the store. - * This allows clawdbot to use the same credentials as these tools without requiring - * separate authentication, and keeps credentials in sync when CLI tools refresh tokens. + * Sync OAuth credentials from external CLI tools (Qwen Code CLI) into the store. * * Returns true if any credentials were updated. */ -export function syncExternalCliCredentials( - store: AuthProfileStore, - options?: { allowKeychainPrompt?: boolean }, -): boolean { +export function syncExternalCliCredentials(store: AuthProfileStore): boolean { let mutated = false; const now = Date.now(); - // Sync from Claude Code CLI (supports both OAuth and Token credentials) - const existingClaude = store.profiles[CLAUDE_CLI_PROFILE_ID]; - const shouldSyncClaude = - !existingClaude || - existingClaude.provider !== "anthropic" || - existingClaude.type === "token" || - !isExternalProfileFresh(existingClaude, now); - const claudeCreds = shouldSyncClaude - ? readClaudeCliCredentialsCached({ - allowKeychainPrompt: options?.allowKeychainPrompt, - ttlMs: EXTERNAL_CLI_SYNC_TTL_MS, - }) - : null; - if (claudeCreds) { - const existing = store.profiles[CLAUDE_CLI_PROFILE_ID]; - const claudeCredsExpires = claudeCreds.expires ?? 0; - - // Determine if we should update based on credential comparison - let shouldUpdate = false; - let isEqual = false; - - if (claudeCreds.type === "oauth") { - const existingOAuth = existing?.type === "oauth" ? existing : undefined; - isEqual = shallowEqualOAuthCredentials(existingOAuth, claudeCreds); - // Update if: no existing profile, type changed to oauth, expired, or CLI has newer token - shouldUpdate = - !existingOAuth || - existingOAuth.provider !== "anthropic" || - existingOAuth.expires <= now || - (claudeCredsExpires > now && claudeCredsExpires > existingOAuth.expires); - } else { - const existingToken = existing?.type === "token" ? existing : undefined; - isEqual = shallowEqualTokenCredentials(existingToken, claudeCreds); - // Update if: no existing profile, expired, or CLI has newer token - shouldUpdate = - !existingToken || - existingToken.provider !== "anthropic" || - (existingToken.expires ?? 0) <= now || - (claudeCredsExpires > now && claudeCredsExpires > (existingToken.expires ?? 0)); - } - - // Also update if credential type changed (token -> oauth upgrade) - if (existing && existing.type !== claudeCreds.type) { - // Prefer oauth over token (enables auto-refresh) - if (claudeCreds.type === "oauth") { - shouldUpdate = true; - isEqual = false; - } - } - - // Avoid downgrading from oauth to token-only credentials. - if (existing?.type === "oauth" && claudeCreds.type === "token") { - shouldUpdate = false; - } - - if (shouldUpdate && !isEqual) { - store.profiles[CLAUDE_CLI_PROFILE_ID] = claudeCreds; - mutated = true; - log.info("synced anthropic credentials from claude cli", { - profileId: CLAUDE_CLI_PROFILE_ID, - type: claudeCreds.type, - expires: - typeof claudeCreds.expires === "number" - ? new Date(claudeCreds.expires).toISOString() - : "unknown", - }); - } - } - - // Sync from Codex CLI - const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID]; - const existingCodexOAuth = existingCodex?.type === "oauth" ? existingCodex : undefined; - const duplicateExistingId = existingCodexOAuth - ? findDuplicateCodexProfile(store, existingCodexOAuth) - : undefined; - if (duplicateExistingId) { - delete store.profiles[CODEX_CLI_PROFILE_ID]; - mutated = true; - log.info("removed codex-cli profile: credentials already exist in another profile", { - existingProfileId: duplicateExistingId, - removedProfileId: CODEX_CLI_PROFILE_ID, - }); - } - const shouldSyncCodex = - !existingCodex || - existingCodex.provider !== "openai-codex" || - !isExternalProfileFresh(existingCodex, now); - const codexCreds = - shouldSyncCodex || duplicateExistingId - ? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }) - : null; - if (codexCreds) { - const duplicateProfileId = findDuplicateCodexProfile(store, codexCreds); - if (duplicateProfileId) { - if (store.profiles[CODEX_CLI_PROFILE_ID]) { - delete store.profiles[CODEX_CLI_PROFILE_ID]; - mutated = true; - log.info("removed codex-cli profile: credentials already exist in another profile", { - existingProfileId: duplicateProfileId, - removedProfileId: CODEX_CLI_PROFILE_ID, - }); - } - } else { - const existing = store.profiles[CODEX_CLI_PROFILE_ID]; - const existingOAuth = existing?.type === "oauth" ? existing : undefined; - - // Codex creds don't carry expiry; use file mtime heuristic for freshness. - const shouldUpdate = - !existingOAuth || - existingOAuth.provider !== "openai-codex" || - existingOAuth.expires <= now || - codexCreds.expires > existingOAuth.expires; - - if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, codexCreds)) { - store.profiles[CODEX_CLI_PROFILE_ID] = codexCreds; - mutated = true; - log.info("synced openai-codex credentials from codex cli", { - profileId: CODEX_CLI_PROFILE_ID, - expires: new Date(codexCreds.expires).toISOString(), - }); - } - } - } - // Sync from Qwen Code CLI const existingQwen = store.profiles[QWEN_CLI_PROFILE_ID]; const shouldSyncQwen = diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 8c59a3044..4138cda94 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -4,8 +4,7 @@ import lockfile from "proper-lockfile"; import type { ClawdbotConfig } from "../../config/config.js"; import { refreshChutesTokens } from "../chutes-oauth.js"; import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js"; -import { writeClaudeCliCredentials } from "../cli-credentials.js"; -import { AUTH_STORE_LOCK_OPTIONS, CLAUDE_CLI_PROFILE_ID } from "./constants.js"; +import { AUTH_STORE_LOCK_OPTIONS } from "./constants.js"; import { formatAuthDoctorHint } from "./doctor.js"; import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js"; import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js"; @@ -72,12 +71,6 @@ async function refreshOAuthTokenWithLock(params: { }; saveAuthProfileStore(store, params.agentDir); - // Sync refreshed credentials back to Claude Code CLI if this is the claude-cli profile - // This ensures Claude Code continues to work after ClawdBot refreshes the token - if (params.profileId === CLAUDE_CLI_PROFILE_ID && cred.provider === "anthropic") { - writeClaudeCliCredentials(result.newCredentials); - } - return result; } finally { if (release) { diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 010f0e9b7..ae4a999b9 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -3,13 +3,8 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai"; import lockfile from "proper-lockfile"; import { resolveOAuthPath } from "../../config/paths.js"; import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js"; -import { - AUTH_STORE_LOCK_OPTIONS, - AUTH_STORE_VERSION, - CODEX_CLI_PROFILE_ID, - log, -} from "./constants.js"; -import { findDuplicateCodexProfile, syncExternalCliCredentials } from "./external-cli-sync.js"; +import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION, log } from "./constants.js"; +import { syncExternalCliCredentials } from "./external-cli-sync.js"; import { ensureAuthStoreFile, resolveAuthStorePath, resolveLegacyAuthStorePath } from "./paths.js"; import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js"; @@ -229,14 +224,14 @@ export function loadAuthProfileStore(): AuthProfileStore { function loadAuthProfileStoreForAgent( agentDir?: string, - options?: { allowKeychainPrompt?: boolean }, + _options?: { allowKeychainPrompt?: boolean }, ): AuthProfileStore { const authPath = resolveAuthStorePath(agentDir); const raw = loadJsonFile(authPath); const asStore = coerceAuthStore(raw); if (asStore) { // Sync from external CLI tools on every load - const synced = syncExternalCliCredentials(asStore, options); + const synced = syncExternalCliCredentials(asStore); if (synced) { saveJsonFile(authPath, asStore); } @@ -297,7 +292,7 @@ function loadAuthProfileStoreForAgent( } const mergedOAuth = mergeOAuthFileIntoStore(store); - const syncedCli = syncExternalCliCredentials(store, options); + const syncedCli = syncExternalCliCredentials(store); const shouldWrite = legacy !== null || mergedOAuth || syncedCli; if (shouldWrite) { saveJsonFile(authPath, store); @@ -337,15 +332,6 @@ export function ensureAuthProfileStore( const mainStore = loadAuthProfileStoreForAgent(undefined, options); const merged = mergeAuthProfileStores(mainStore, store); - // Keep per-agent view clean even if the main store has codex-cli. - const codexProfile = merged.profiles[CODEX_CLI_PROFILE_ID]; - if (codexProfile?.type === "oauth") { - const duplicateId = findDuplicateCodexProfile(merged, codexProfile); - if (duplicateId) { - delete merged.profiles[CODEX_CLI_PROFILE_ID]; - } - } - return merged; } diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 20a476f81..d914629e7 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -389,7 +389,7 @@ export function registerModelsCli(program: Command) { .description("Set per-agent auth order override (locks rotation to this list)") .requiredOption("--provider ", "Provider id (e.g. anthropic)") .option("--agent ", "Agent id (default: configured default agent)") - .argument("", "Auth profile ids (e.g. anthropic:claude-cli)") + .argument("", "Auth profile ids (e.g. anthropic:default)") .action(async (profileIds: string[], opts) => { await runModelsCommand(async () => { await modelsAuthOrderSetCommand( diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index a2d5d4a66..eac6a60df 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) { .option("--mode ", "Wizard mode: local|remote") .option( "--auth-choice ", - "Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", + "Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", ) .option( "--token-provider ", diff --git a/src/commands/agents.commands.add.ts b/src/commands/agents.commands.add.ts index c8a6a3e0a..53b8ba049 100644 --- a/src/commands/agents.commands.add.ts +++ b/src/commands/agents.commands.add.ts @@ -258,7 +258,6 @@ export async function agentsAddCommand( prompter, store: authStore, includeSkip: true, - includeClaudeCliIfMissing: true, }); const authResult = await applyAuthChoice({ diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index f13eef365..6b49ff17b 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -1,6 +1,4 @@ import type { AuthProfileStore } from "../agents/auth-profiles.js"; -import { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "../agents/auth-profiles.js"; -import { colorize, isRich, theme } from "../terminal/theme.js"; import type { AuthChoice } from "./onboard-types.js"; export type AuthChoiceOption = { @@ -41,13 +39,13 @@ const AUTH_CHOICE_GROUP_DEFS: { value: "openai", label: "OpenAI", hint: "Codex OAuth + API key", - choices: ["codex-cli", "openai-codex", "openai-api-key"], + choices: ["openai-codex", "openai-api-key"], }, { value: "anthropic", label: "Anthropic", - hint: "Claude Code CLI + API key", - choices: ["token", "claude-cli", "apiKey"], + hint: "setup-token + API key", + choices: ["token", "apiKey"], }, { value: "minimax", @@ -117,65 +115,12 @@ const AUTH_CHOICE_GROUP_DEFS: { }, ]; -function formatOAuthHint(expires?: number, opts?: { allowStale?: boolean }): string { - const rich = isRich(); - if (!expires) { - return colorize(rich, theme.muted, "token unavailable"); - } - const now = Date.now(); - const remaining = expires - now; - if (remaining <= 0) { - if (opts?.allowStale) { - return colorize(rich, theme.warn, "token present · refresh on use"); - } - return colorize(rich, theme.error, "token expired"); - } - const minutes = Math.round(remaining / (60 * 1000)); - const duration = - minutes >= 120 - ? `${Math.round(minutes / 60)}h` - : minutes >= 60 - ? "1h" - : `${Math.max(minutes, 1)}m`; - const label = `token ok · expires in ${duration}`; - if (minutes <= 10) { - return colorize(rich, theme.warn, label); - } - return colorize(rich, theme.success, label); -} - export function buildAuthChoiceOptions(params: { store: AuthProfileStore; includeSkip: boolean; - includeClaudeCliIfMissing?: boolean; - platform?: NodeJS.Platform; }): AuthChoiceOption[] { + void params.store; const options: AuthChoiceOption[] = []; - const platform = params.platform ?? process.platform; - - const codexCli = params.store.profiles[CODEX_CLI_PROFILE_ID]; - if (codexCli?.type === "oauth") { - options.push({ - value: "codex-cli", - label: "OpenAI Codex OAuth (Codex CLI)", - hint: formatOAuthHint(codexCli.expires, { allowStale: true }), - }); - } - - const claudeCli = params.store.profiles[CLAUDE_CLI_PROFILE_ID]; - if (claudeCli?.type === "oauth" || claudeCli?.type === "token") { - options.push({ - value: "claude-cli", - label: "Anthropic token (Claude Code CLI)", - hint: `reuses existing Claude Code auth · ${formatOAuthHint(claudeCli.expires)}`, - }); - } else if (params.includeClaudeCliIfMissing && platform === "darwin") { - options.push({ - value: "claude-cli", - label: "Anthropic token (Claude Code CLI)", - hint: "reuses existing Claude Code auth · requires Keychain access", - }); - } options.push({ value: "token", @@ -245,12 +190,7 @@ export function buildAuthChoiceOptions(params: { return options; } -export function buildAuthChoiceGroups(params: { - store: AuthProfileStore; - includeSkip: boolean; - includeClaudeCliIfMissing?: boolean; - platform?: NodeJS.Platform; -}): { +export function buildAuthChoiceGroups(params: { store: AuthProfileStore; includeSkip: boolean }): { groups: AuthChoiceGroup[]; skipOption?: AuthChoiceOption; } { diff --git a/src/commands/auth-choice-prompt.ts b/src/commands/auth-choice-prompt.ts index 82756229e..275fa72c9 100644 --- a/src/commands/auth-choice-prompt.ts +++ b/src/commands/auth-choice-prompt.ts @@ -9,8 +9,6 @@ export async function promptAuthChoiceGrouped(params: { prompter: WizardPrompter; store: AuthProfileStore; includeSkip: boolean; - includeClaudeCliIfMissing?: boolean; - platform?: NodeJS.Platform; }): Promise { const { groups, skipOption } = buildAuthChoiceGroups(params); const availableGroups = groups.filter((group) => group.options.length > 0); diff --git a/src/commands/auth-choice.apply.anthropic.ts b/src/commands/auth-choice.apply.anthropic.ts index c5700663c..b28b8ebee 100644 --- a/src/commands/auth-choice.apply.anthropic.ts +++ b/src/commands/auth-choice.apply.anthropic.ts @@ -1,8 +1,4 @@ -import { - CLAUDE_CLI_PROFILE_ID, - ensureAuthProfileStore, - upsertAuthProfile, -} from "../agents/auth-profiles.js"; +import { upsertAuthProfile } from "../agents/auth-profiles.js"; import { formatApiKeyPreview, normalizeApiKeyInput, @@ -15,153 +11,17 @@ import { applyAuthProfileConfig, setAnthropicApiKey } from "./onboard-auth.js"; export async function applyAuthChoiceAnthropic( params: ApplyAuthChoiceParams, ): Promise { - if (params.authChoice === "claude-cli") { + if ( + params.authChoice === "setup-token" || + params.authChoice === "oauth" || + params.authChoice === "token" + ) { let nextConfig = params.config; - const store = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); - const hasClaudeCli = Boolean(store.profiles[CLAUDE_CLI_PROFILE_ID]); - if (!hasClaudeCli && process.platform === "darwin") { - await params.prompter.note( - [ - "macOS will show a Keychain prompt next.", - 'Choose "Always Allow" so the launchd gateway can start without prompts.', - 'If you choose "Allow" or "Deny", each restart will block on a Keychain alert.', - ].join("\n"), - "Claude Code CLI Keychain", - ); - const proceed = await params.prompter.confirm({ - message: "Check Keychain for Claude Code CLI credentials now?", - initialValue: true, - }); - if (!proceed) return { config: nextConfig }; - } - - const storeWithKeychain = hasClaudeCli - ? store - : ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: true, - }); - - if (!storeWithKeychain.profiles[CLAUDE_CLI_PROFILE_ID]) { - if (process.stdin.isTTY) { - const runNow = await params.prompter.confirm({ - message: "Run `claude setup-token` now?", - initialValue: true, - }); - if (runNow) { - 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)}`, - "Claude setup-token", - ); - } - } - } else { - await params.prompter.note( - "`claude setup-token` requires an interactive TTY.", - "Claude setup-token", - ); - } - - const refreshed = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: true, - }); - if (!refreshed.profiles[CLAUDE_CLI_PROFILE_ID]) { - await params.prompter.note( - process.platform === "darwin" - ? 'No Claude Code CLI credentials found in Keychain ("Claude Code-credentials") or ~/.claude/.credentials.json.' - : "No Claude Code CLI credentials found at ~/.claude/.credentials.json.", - "Claude Code CLI OAuth", - ); - return { config: nextConfig }; - } - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: CLAUDE_CLI_PROFILE_ID, - provider: "anthropic", - mode: "oauth", - }); - return { config: nextConfig }; - } - - if (params.authChoice === "setup-token" || params.authChoice === "oauth") { - let nextConfig = params.config; - 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 }; - } - - const proceed = await params.prompter.confirm({ - message: "Run `claude setup-token` now?", - initialValue: true, - }); - if (!proceed) return { config: nextConfig }; - - 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 }; - } - 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 }; - } - - const store = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: true, - }); - if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) { - await params.prompter.note( - `No Claude Code CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`, - "Anthropic setup-token", - ); - return { config: nextConfig }; - } - - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: CLAUDE_CLI_PROFILE_ID, - provider: "anthropic", - mode: "oauth", - }); - return { config: nextConfig }; - } - - if (params.authChoice === "token") { - let nextConfig = params.config; - const provider = (await params.prompter.select({ - message: "Token provider", - options: [{ value: "anthropic", label: "Anthropic (only supported)" }], - })) as "anthropic"; await params.prompter.note( ["Run `claude setup-token` in your terminal.", "Then paste the generated token below."].join( "\n", ), - "Anthropic token", + "Anthropic setup-token", ); const tokenRaw = await params.prompter.text({ @@ -174,6 +34,7 @@ export async function applyAuthChoiceAnthropic( message: "Token name (blank = default)", placeholder: "default", }); + const provider = "anthropic"; const namedProfileId = buildTokenProfileId({ provider, name: String(profileNameRaw ?? ""), diff --git a/src/commands/auth-choice.apply.openai.ts b/src/commands/auth-choice.apply.openai.ts index 7d96a35a1..947b81181 100644 --- a/src/commands/auth-choice.apply.openai.ts +++ b/src/commands/auth-choice.apply.openai.ts @@ -1,5 +1,4 @@ import { loginOpenAICodex } from "@mariozechner/pi-ai"; -import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { resolveEnvApiKey } from "../agents/model-auth.js"; import { upsertSharedEnvVar } from "../infra/env-file.js"; import { isRemoteEnvironment } from "./oauth-env.js"; @@ -146,45 +145,5 @@ export async function applyAuthChoiceOpenAI( return { config: nextConfig, agentModelOverride }; } - if (params.authChoice === "codex-cli") { - let nextConfig = params.config; - let agentModelOverride: string | undefined; - const noteAgentModel = async (model: string) => { - if (!params.agentId) return; - await params.prompter.note( - `Default model set to ${model} for agent "${params.agentId}".`, - "Model configured", - ); - }; - - const store = ensureAuthProfileStore(params.agentDir); - if (!store.profiles[CODEX_CLI_PROFILE_ID]) { - await params.prompter.note( - "No Codex CLI credentials found at ~/.codex/auth.json.", - "Codex CLI OAuth", - ); - return { config: nextConfig, agentModelOverride }; - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: CODEX_CLI_PROFILE_ID, - provider: "openai-codex", - mode: "oauth", - }); - if (params.setDefaultModel) { - const applied = applyOpenAICodexModelDefault(nextConfig); - nextConfig = applied.next; - if (applied.changed) { - await params.prompter.note( - `Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`, - "Model configured", - ); - } - } else { - agentModelOverride = OPENAI_CODEX_DEFAULT_MODEL; - await noteAgentModel(OPENAI_CODEX_DEFAULT_MODEL); - } - return { config: nextConfig, agentModelOverride }; - } - return null; } diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index 93571312f..bd707e4e0 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -1,8 +1,4 @@ -import { - CLAUDE_CLI_PROFILE_ID, - CODEX_CLI_PROFILE_ID, - loadAuthProfileStore, -} from "../../agents/auth-profiles.js"; +import { loadAuthProfileStore } from "../../agents/auth-profiles.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js"; import type { ChannelAccountSnapshot, ChannelPlugin } from "../../channels/plugins/types.js"; @@ -115,7 +111,7 @@ export async function channelsListCommand( id: profileId, provider: profile.provider, type: profile.type, - isExternal: profileId === CLAUDE_CLI_PROFILE_ID || profileId === CODEX_CLI_PROFILE_ID, + isExternal: false, })); if (opts.json) { const usage = includeUsage ? await loadProviderUsageSummary() : undefined; diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index 6d3522ab4..d60453a98 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -47,7 +47,6 @@ export async function promptAuthConfig( allowKeychainPrompt: false, }), includeSkip: true, - includeClaudeCliIfMissing: true, }); let next = cfg; @@ -74,10 +73,7 @@ export async function promptAuthConfig( } const anthropicOAuth = - authChoice === "claude-cli" || - authChoice === "setup-token" || - authChoice === "token" || - authChoice === "oauth"; + authChoice === "setup-token" || authChoice === "token" || authChoice === "oauth"; const allowlistSelection = await promptModelAllowlist({ config: next, diff --git a/src/commands/doctor-auth.ts b/src/commands/doctor-auth.ts index 7fc17e28f..4ef6f7a0e 100644 --- a/src/commands/doctor-auth.ts +++ b/src/commands/doctor-auth.ts @@ -11,6 +11,7 @@ import { resolveApiKeyForProfile, resolveProfileUnusableUntilForDisplay, } from "../agents/auth-profiles.js"; +import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js"; import type { ClawdbotConfig } from "../config/config.js"; import { note } from "../terminal/note.js"; import { formatCliCommand } from "../cli/command-format.js"; @@ -38,6 +39,148 @@ export async function maybeRepairAnthropicOAuthProfileId( return repair.config; } +function pruneAuthOrder( + order: Record | undefined, + profileIds: Set, +): { next: Record | undefined; changed: boolean } { + if (!order) return { next: order, changed: false }; + let changed = false; + const next: Record = {}; + for (const [provider, list] of Object.entries(order)) { + const filtered = list.filter((id) => !profileIds.has(id)); + if (filtered.length !== list.length) changed = true; + if (filtered.length > 0) next[provider] = filtered; + } + return { next: Object.keys(next).length > 0 ? next : undefined, changed }; +} + +function pruneAuthProfiles( + cfg: ClawdbotConfig, + profileIds: Set, +): { next: ClawdbotConfig; changed: boolean } { + const profiles = cfg.auth?.profiles; + const order = cfg.auth?.order; + const nextProfiles = profiles ? { ...profiles } : undefined; + let changed = false; + + if (nextProfiles) { + for (const id of profileIds) { + if (id in nextProfiles) { + delete nextProfiles[id]; + changed = true; + } + } + } + + const prunedOrder = pruneAuthOrder(order, profileIds); + if (prunedOrder.changed) changed = true; + + if (!changed) return { next: cfg, changed: false }; + + const nextAuth = + nextProfiles || prunedOrder.next + ? { + ...cfg.auth, + profiles: nextProfiles && Object.keys(nextProfiles).length > 0 ? nextProfiles : undefined, + order: prunedOrder.next, + } + : undefined; + + return { + next: { + ...cfg, + auth: nextAuth, + }, + changed: true, + }; +} + +export async function maybeRemoveDeprecatedCliAuthProfiles( + cfg: ClawdbotConfig, + prompter: DoctorPrompter, +): Promise { + const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false }); + const deprecated = new Set(); + if (store.profiles[CLAUDE_CLI_PROFILE_ID] || cfg.auth?.profiles?.[CLAUDE_CLI_PROFILE_ID]) { + deprecated.add(CLAUDE_CLI_PROFILE_ID); + } + if (store.profiles[CODEX_CLI_PROFILE_ID] || cfg.auth?.profiles?.[CODEX_CLI_PROFILE_ID]) { + deprecated.add(CODEX_CLI_PROFILE_ID); + } + + if (deprecated.size === 0) return cfg; + + const lines = ["Deprecated external CLI auth profiles detected (no longer supported):"]; + if (deprecated.has(CLAUDE_CLI_PROFILE_ID)) { + lines.push( + `- ${CLAUDE_CLI_PROFILE_ID} (Anthropic): use setup-token → ${formatCliCommand("clawdbot models auth setup-token")}`, + ); + } + if (deprecated.has(CODEX_CLI_PROFILE_ID)) { + lines.push( + `- ${CODEX_CLI_PROFILE_ID} (OpenAI Codex): use OAuth → ${formatCliCommand( + "clawdbot models auth login --provider openai-codex", + )}`, + ); + } + note(lines.join("\n"), "Auth profiles"); + + const shouldRemove = await prompter.confirmRepair({ + message: "Remove deprecated CLI auth profiles now?", + initialValue: true, + }); + if (!shouldRemove) return cfg; + + await updateAuthProfileStoreWithLock({ + updater: (nextStore) => { + let mutated = false; + for (const id of deprecated) { + if (nextStore.profiles[id]) { + delete nextStore.profiles[id]; + mutated = true; + } + if (nextStore.usageStats?.[id]) { + delete nextStore.usageStats[id]; + mutated = true; + } + } + if (nextStore.order) { + for (const [provider, list] of Object.entries(nextStore.order)) { + const filtered = list.filter((id) => !deprecated.has(id)); + if (filtered.length !== list.length) { + mutated = true; + if (filtered.length > 0) { + nextStore.order[provider] = filtered; + } else { + delete nextStore.order[provider]; + } + } + } + } + if (nextStore.lastGood) { + for (const [provider, profileId] of Object.entries(nextStore.lastGood)) { + if (deprecated.has(profileId)) { + delete nextStore.lastGood[provider]; + mutated = true; + } + } + } + return mutated; + }, + }); + + const pruned = pruneAuthProfiles(cfg, deprecated); + if (pruned.changed) { + note( + Array.from(deprecated.values()) + .map((id) => `- removed ${id} from config`) + .join("\n"), + "Doctor changes", + ); + } + return pruned.next; +} + type AuthIssue = { profileId: string; provider: string; @@ -47,10 +190,14 @@ type AuthIssue = { function formatAuthIssueHint(issue: AuthIssue): string | null { if (issue.provider === "anthropic" && issue.profileId === CLAUDE_CLI_PROFILE_ID) { - return "Run `claude setup-token` on the gateway host."; + return `Deprecated profile. Use ${formatCliCommand("clawdbot models auth setup-token")} or ${formatCliCommand( + "clawdbot configure", + )}.`; } if (issue.provider === "openai-codex" && issue.profileId === CODEX_CLI_PROFILE_ID) { - return `Run \`codex login\` (or \`${formatCliCommand("clawdbot configure")}\` → OpenAI Codex OAuth).`; + return `Deprecated profile. Use ${formatCliCommand( + "clawdbot models auth login --provider openai-codex", + )} or ${formatCliCommand("clawdbot configure")}.`; } return `Re-auth via \`${formatCliCommand("clawdbot configure")}\` or \`${formatCliCommand("clawdbot onboard")}\`.`; } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index aa4f4d7a3..658504ecc 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -22,7 +22,11 @@ import { defaultRuntime } from "../runtime.js"; import { note } from "../terminal/note.js"; import { stylePromptTitle } from "../terminal/prompt-style.js"; import { shortenHomePath } from "../utils.js"; -import { maybeRepairAnthropicOAuthProfileId, noteAuthProfileHealth } from "./doctor-auth.js"; +import { + maybeRemoveDeprecatedCliAuthProfiles, + maybeRepairAnthropicOAuthProfileId, + noteAuthProfileHealth, +} from "./doctor-auth.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js"; import { checkGatewayHealth } from "./doctor-gateway-health.js"; @@ -104,6 +108,7 @@ export async function doctorCommand( } cfg = await maybeRepairAnthropicOAuthProfileId(cfg, prompter); + cfg = await maybeRemoveDeprecatedCliAuthProfiles(cfg, prompter); await noteAuthProfileHealth({ cfg, prompter, diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index b2da0cde1..c38cf4520 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -1,12 +1,6 @@ -import { spawnSync } from "node:child_process"; - import { confirm as clackConfirm, select as clackSelect, text as clackText } from "@clack/prompts"; -import { - CLAUDE_CLI_PROFILE_ID, - ensureAuthProfileStore, - upsertAuthProfile, -} from "../../agents/auth-profiles.js"; +import { upsertAuthProfile } from "../../agents/auth-profiles.js"; import { normalizeProviderId } from "../../agents/model-selection.js"; import { resolveAgentDir, @@ -33,6 +27,7 @@ import type { ProviderPlugin, } from "../../plugins/types.js"; import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js"; +import { validateAnthropicSetupToken } from "../auth-token.js"; const confirm = (params: Parameters[0]) => clackConfirm({ @@ -73,9 +68,7 @@ export async function modelsAuthSetupTokenCommand( ) { const provider = resolveTokenProvider(opts.provider ?? "anthropic"); if (provider !== "anthropic") { - throw new Error( - "Only --provider anthropic is supported for setup-token (uses `claude setup-token`).", - ); + throw new Error("Only --provider anthropic is supported for setup-token."); } if (!process.stdin.isTTY) { @@ -84,38 +77,38 @@ export async function modelsAuthSetupTokenCommand( if (!opts.yes) { const proceed = await confirm({ - message: "Run `claude setup-token` now?", + message: "Have you run `claude setup-token` and copied the token?", initialValue: true, }); if (!proceed) return; } - const res = spawnSync("claude", ["setup-token"], { stdio: "inherit" }); - if (res.error) throw res.error; - if (typeof res.status === "number" && res.status !== 0) { - throw new Error(`claude setup-token failed (exit ${res.status})`); - } - - const store = ensureAuthProfileStore(undefined, { - allowKeychainPrompt: true, + const tokenInput = await text({ + message: "Paste Anthropic setup-token", + validate: (value) => validateAnthropicSetupToken(String(value ?? "")), + }); + const token = String(tokenInput).trim(); + const profileId = resolveDefaultTokenProfileId(provider); + + upsertAuthProfile({ + profileId, + credential: { + type: "token", + provider, + token, + }, }); - const synced = store.profiles[CLAUDE_CLI_PROFILE_ID]; - if (!synced) { - throw new Error( - `No Claude Code CLI credentials found after setup-token. Expected auth profile ${CLAUDE_CLI_PROFILE_ID}.`, - ); - } await updateConfig((cfg) => applyAuthProfileConfig(cfg, { - profileId: CLAUDE_CLI_PROFILE_ID, - provider: "anthropic", - mode: "oauth", + profileId, + provider, + mode: "token", }), ); logConfigUpdated(runtime); - runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/oauth)`); + runtime.log(`Auth profile: ${profileId} (${provider}/token)`); } export async function modelsAuthPasteTokenCommand( @@ -189,7 +182,7 @@ export async function modelsAuthAddCommand(_opts: Record, runtime { value: "setup-token", label: "setup-token (claude)", - hint: "Runs `claude setup-token` (recommended)", + hint: "Paste a setup-token from `claude setup-token`", }, ] : []), diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 8aa7015c8..fc29cc5d5 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -487,7 +487,7 @@ export async function modelsStatusCommand( for (const provider of missingProvidersInUse) { const hint = provider === "anthropic" - ? `Run \`claude setup-token\` or \`${formatCliCommand("clawdbot configure")}\`.` + ? `Run \`claude setup-token\`, then \`${formatCliCommand("clawdbot models auth setup-token")}\` or \`${formatCliCommand("clawdbot configure")}\`.` : `Run \`${formatCliCommand("clawdbot configure")}\` or set an API key env var.`; runtime.log(`- ${theme.heading(provider)} ${hint}`); } @@ -558,9 +558,7 @@ export async function modelsStatusCommand( : profile.expiresAt ? ` expires in ${formatRemainingShort(profile.remainingMs)}` : " expires unknown"; - const source = - profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : ""; - runtime.log(` - ${label} ${status}${expiry}${source}`); + runtime.log(` - ${label} ${status}${expiry}`); } } } diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts index 02e0a75b9..c5558596a 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.ts @@ -1,9 +1,4 @@ -import { - CLAUDE_CLI_PROFILE_ID, - CODEX_CLI_PROFILE_ID, - ensureAuthProfileStore, - upsertAuthProfile, -} from "../../../agents/auth-profiles.js"; +import { upsertAuthProfile } from "../../../agents/auth-profiles.js"; import { normalizeProviderId } from "../../../agents/model-selection.js"; import { parseDurationMs } from "../../../cli/parse-duration.js"; import type { ClawdbotConfig } from "../../../config/config.js"; @@ -36,7 +31,6 @@ import { setZaiApiKey, } from "../../onboard-auth.js"; import type { AuthChoice, OnboardOptions } from "../../onboard-types.js"; -import { applyOpenAICodexModelDefault } from "../../openai-codex-model-default.js"; import { resolveNonInteractiveApiKey } from "../api-keys.js"; import { shortenHomePath } from "../../../utils.js"; @@ -50,6 +44,28 @@ export async function applyNonInteractiveAuthChoice(params: { const { authChoice, opts, runtime, baseConfig } = params; let nextConfig = params.nextConfig; + if (authChoice === "claude-cli" || authChoice === "codex-cli") { + runtime.error( + [ + `Auth choice "${authChoice}" is deprecated.`, + 'Use "--auth-choice token" (Anthropic setup-token) or "--auth-choice openai-codex".', + ].join("\n"), + ); + runtime.exit(1); + return null; + } + + if (authChoice === "setup-token") { + runtime.error( + [ + 'Auth choice "setup-token" requires interactive mode.', + 'Use "--auth-choice token" with --token and --token-provider anthropic.', + ].join("\n"), + ); + runtime.exit(1); + return null; + } + if (authChoice === "apiKey") { const resolved = await resolveNonInteractiveApiKey({ provider: "anthropic", @@ -318,41 +334,6 @@ export async function applyNonInteractiveAuthChoice(params: { return applyMinimaxApiConfig(nextConfig, modelId); } - if (authChoice === "claude-cli") { - const store = ensureAuthProfileStore(undefined, { - allowKeychainPrompt: false, - }); - if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) { - runtime.error( - process.platform === "darwin" - ? 'No Claude Code CLI credentials found. Run interactive onboarding to approve Keychain access for "Claude Code-credentials".' - : "No Claude Code CLI credentials found at ~/.claude/.credentials.json", - ); - runtime.exit(1); - return null; - } - return applyAuthProfileConfig(nextConfig, { - profileId: CLAUDE_CLI_PROFILE_ID, - provider: "anthropic", - mode: "oauth", - }); - } - - if (authChoice === "codex-cli") { - const store = ensureAuthProfileStore(); - if (!store.profiles[CODEX_CLI_PROFILE_ID]) { - runtime.error("No Codex CLI credentials found at ~/.codex/auth.json"); - runtime.exit(1); - return null; - } - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId: CODEX_CLI_PROFILE_ID, - provider: "openai-codex", - mode: "oauth", - }); - return applyOpenAICodexModelDefault(nextConfig).next; - } - if (authChoice === "minimax") return applyMinimaxConfig(nextConfig); if (authChoice === "opencode-zen") { diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index d8618a871..348aca613 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -12,9 +12,33 @@ import type { OnboardOptions } from "./onboard-types.js"; export async function onboardCommand(opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime) { assertSupportedRuntime(runtime); const authChoice = opts.authChoice === "oauth" ? ("setup-token" as const) : opts.authChoice; + const normalizedAuthChoice = + authChoice === "claude-cli" + ? ("setup-token" as const) + : authChoice === "codex-cli" + ? ("openai-codex" as const) + : authChoice; + if (opts.nonInteractive && (authChoice === "claude-cli" || authChoice === "codex-cli")) { + runtime.error( + [ + `Auth choice "${authChoice}" is deprecated.`, + 'Use "--auth-choice token" (Anthropic setup-token) or "--auth-choice openai-codex".', + ].join("\n"), + ); + runtime.exit(1); + return; + } + if (authChoice === "claude-cli") { + runtime.log('Auth choice "claude-cli" is deprecated; using setup-token flow instead.'); + } + if (authChoice === "codex-cli") { + runtime.log('Auth choice "codex-cli" is deprecated; using OpenAI Codex OAuth instead.'); + } const flow = opts.flow === "manual" ? ("advanced" as const) : opts.flow; const normalizedOpts = - authChoice === opts.authChoice && flow === opts.flow ? opts : { ...opts, authChoice, flow }; + normalizedAuthChoice === opts.authChoice && flow === opts.flow + ? opts + : { ...opts, authChoice: normalizedAuthChoice, flow }; if (normalizedOpts.nonInteractive && normalizedOpts.acceptRisk !== true) { runtime.error( diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 43e4c10c9..90d73bb59 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -3,7 +3,6 @@ import os from "node:os"; import path from "node:path"; import { - CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore, listProfilesForProvider, resolveApiKeyForProfile, @@ -111,9 +110,7 @@ async function resolveOAuthToken(params: { provider: params.provider, }); - // Claude Code CLI creds are the only Anthropic tokens that reliably include the - // `user:profile` scope required for the OAuth usage endpoint. - const candidates = params.provider === "anthropic" ? [CLAUDE_CLI_PROFILE_ID, ...order] : order; + const candidates = order; const deduped: string[] = []; for (const entry of candidates) { if (!deduped.includes(entry)) deduped.push(entry); diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index 77b7f770d..39d17befa 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -360,7 +360,6 @@ export async function runOnboardingWizard( prompter, store: authStore, includeSkip: true, - includeClaudeCliIfMissing: true, })); const authResult = await applyAuthChoice({ From aa2a1a17e3b672fff073e994d22c9387dc8a2e73 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 19:04:42 +0000 Subject: [PATCH 37/40] test(auth): update auth profile coverage --- ...th-profiles.ensureauthprofilestore.test.ts | 79 +-------- ...verwrite-api-keys-syncing-external.test.ts | 102 ----------- ...verwrite-fresher-store-oauth-older.test.ts | 106 ----------- ...edentials-exist-in-another-profile.test.ts | 166 ------------------ ...i-oauth-credentials-into-anthropic.test.ts | 96 ---------- ...odex-cli-profile-codex-cli-refresh.test.ts | 56 ------ ...oauth-claude-cli-gets-refreshtoken.test.ts | 103 ----------- src/agents/model-fallback.test.ts | 2 +- ...mbedded-helpers.isautherrormessage.test.ts | 2 +- src/commands/auth-choice-options.test.ts | 62 +------ ....adds-non-default-telegram-account.test.ts | 8 +- ...octor-auth.deprecated-cli-profiles.test.ts | 109 ++++++++++++ src/commands/onboard-auth.test.ts | 4 +- src/infra/provider-usage.test.ts | 75 -------- 14 files changed, 121 insertions(+), 849 deletions(-) delete mode 100644 src/agents/auth-profiles.external-cli-credential-sync.does-not-overwrite-api-keys-syncing-external.test.ts delete mode 100644 src/agents/auth-profiles.external-cli-credential-sync.does-not-overwrite-fresher-store-oauth-older.test.ts delete mode 100644 src/agents/auth-profiles.external-cli-credential-sync.skips-codex-sync-when-credentials-exist-in-another-profile.test.ts delete mode 100644 src/agents/auth-profiles.external-cli-credential-sync.syncs-claude-cli-oauth-credentials-into-anthropic.test.ts delete mode 100644 src/agents/auth-profiles.external-cli-credential-sync.updates-codex-cli-profile-codex-cli-refresh.test.ts delete mode 100644 src/agents/auth-profiles.external-cli-credential-sync.upgrades-token-oauth-claude-cli-gets-refreshtoken.test.ts create mode 100644 src/commands/doctor-auth.deprecated-cli-profiles.test.ts diff --git a/src/agents/auth-profiles.ensureauthprofilestore.test.ts b/src/agents/auth-profiles.ensureauthprofilestore.test.ts index 3eadb6c5b..db7d6f031 100644 --- a/src/agents/auth-profiles.ensureauthprofilestore.test.ts +++ b/src/agents/auth-profiles.ensureauthprofilestore.test.ts @@ -3,8 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { ensureAuthProfileStore } from "./auth-profiles.js"; -import { AUTH_STORE_VERSION, CODEX_CLI_PROFILE_ID } from "./auth-profiles/constants.js"; -import { withTempHome } from "../../test/helpers/temp-home.js"; +import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; describe("ensureAuthProfileStore", () => { it("migrates legacy auth.json and deletes it (PR #368)", () => { @@ -123,80 +122,4 @@ describe("ensureAuthProfileStore", () => { fs.rmSync(root, { recursive: true, force: true }); } }); - - it("drops codex-cli from merged store when a custom openai-codex profile matches", async () => { - await withTempHome(async (tempHome) => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-dedup-merge-")); - const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; - const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - try { - const mainDir = path.join(root, "main-agent"); - const agentDir = path.join(root, "agent-x"); - fs.mkdirSync(mainDir, { recursive: true }); - fs.mkdirSync(agentDir, { recursive: true }); - - process.env.CLAWDBOT_AGENT_DIR = mainDir; - process.env.PI_CODING_AGENT_DIR = mainDir; - process.env.HOME = tempHome; - - fs.writeFileSync( - path.join(mainDir, "auth-profiles.json"), - `${JSON.stringify( - { - version: AUTH_STORE_VERSION, - profiles: { - [CODEX_CLI_PROFILE_ID]: { - type: "oauth", - provider: "openai-codex", - access: "shared-access-token", - refresh: "shared-refresh-token", - expires: Date.now() + 3600000, - }, - }, - }, - null, - 2, - )}\n`, - "utf8", - ); - - fs.writeFileSync( - path.join(agentDir, "auth-profiles.json"), - `${JSON.stringify( - { - version: AUTH_STORE_VERSION, - profiles: { - "openai-codex:my-custom-profile": { - type: "oauth", - provider: "openai-codex", - access: "shared-access-token", - refresh: "shared-refresh-token", - expires: Date.now() + 3600000, - }, - }, - }, - null, - 2, - )}\n`, - "utf8", - ); - - const store = ensureAuthProfileStore(agentDir); - expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined(); - expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined(); - } finally { - if (previousAgentDir === undefined) { - delete process.env.CLAWDBOT_AGENT_DIR; - } else { - process.env.CLAWDBOT_AGENT_DIR = previousAgentDir; - } - if (previousPiAgentDir === undefined) { - delete process.env.PI_CODING_AGENT_DIR; - } else { - process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - } - fs.rmSync(root, { recursive: true, force: true }); - } - }); - }); }); diff --git a/src/agents/auth-profiles.external-cli-credential-sync.does-not-overwrite-api-keys-syncing-external.test.ts b/src/agents/auth-profiles.external-cli-credential-sync.does-not-overwrite-api-keys-syncing-external.test.ts deleted file mode 100644 index 1109d3452..000000000 --- a/src/agents/auth-profiles.external-cli-credential-sync.does-not-overwrite-api-keys-syncing-external.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { withTempHome } from "../../test/helpers/temp-home.js"; -import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js"; - -describe("external CLI credential sync", () => { - it("does not overwrite API keys when syncing external CLI creds", async () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-no-overwrite-")); - try { - await withTempHome( - async (tempHome) => { - // Create Claude Code CLI credentials - const claudeDir = path.join(tempHome, ".claude"); - fs.mkdirSync(claudeDir, { recursive: true }); - const claudeCreds = { - claudeAiOauth: { - accessToken: "cli-access", - refreshToken: "cli-refresh", - expiresAt: Date.now() + 30 * 60 * 1000, - }, - }; - fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(claudeCreds)); - - // Create auth-profiles.json with an API key - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-store", - }, - }, - }), - ); - - const store = ensureAuthProfileStore(agentDir); - - // Should keep the store's API key and still add the CLI profile. - expect((store.profiles["anthropic:default"] as { key: string }).key).toBe("sk-store"); - expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined(); - }, - { prefix: "clawdbot-home-" }, - ); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } - }); - it("prefers oauth over token even if token has later expiry (oauth enables auto-refresh)", async () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-oauth-preferred-")); - try { - await withTempHome( - async (tempHome) => { - const claudeDir = path.join(tempHome, ".claude"); - fs.mkdirSync(claudeDir, { recursive: true }); - // CLI has OAuth credentials (with refresh token) expiring in 30 min - fs.writeFileSync( - path.join(claudeDir, ".credentials.json"), - JSON.stringify({ - claudeAiOauth: { - accessToken: "cli-oauth-access", - refreshToken: "cli-refresh", - expiresAt: Date.now() + 30 * 60 * 1000, - }, - }), - ); - - const authPath = path.join(agentDir, "auth-profiles.json"); - // Store has token credentials expiring in 60 min (later than CLI) - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - [CLAUDE_CLI_PROFILE_ID]: { - type: "token", - provider: "anthropic", - token: "store-token-access", - expires: Date.now() + 60 * 60 * 1000, - }, - }, - }), - ); - - const store = ensureAuthProfileStore(agentDir); - // OAuth should be preferred over token because it can auto-refresh - const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID]; - expect(cliProfile.type).toBe("oauth"); - expect((cliProfile as { access: string }).access).toBe("cli-oauth-access"); - }, - { prefix: "clawdbot-home-" }, - ); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } - }); -}); diff --git a/src/agents/auth-profiles.external-cli-credential-sync.does-not-overwrite-fresher-store-oauth-older.test.ts b/src/agents/auth-profiles.external-cli-credential-sync.does-not-overwrite-fresher-store-oauth-older.test.ts deleted file mode 100644 index 3ca83a576..000000000 --- a/src/agents/auth-profiles.external-cli-credential-sync.does-not-overwrite-fresher-store-oauth-older.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { withTempHome } from "../../test/helpers/temp-home.js"; -import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js"; - -describe("external CLI credential sync", () => { - it("does not overwrite fresher store oauth with older CLI oauth", async () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-oauth-no-downgrade-")); - try { - await withTempHome( - async (tempHome) => { - const claudeDir = path.join(tempHome, ".claude"); - fs.mkdirSync(claudeDir, { recursive: true }); - // CLI has OAuth credentials expiring in 30 min - fs.writeFileSync( - path.join(claudeDir, ".credentials.json"), - JSON.stringify({ - claudeAiOauth: { - accessToken: "cli-oauth-access", - refreshToken: "cli-refresh", - expiresAt: Date.now() + 30 * 60 * 1000, - }, - }), - ); - - const authPath = path.join(agentDir, "auth-profiles.json"); - // Store has OAuth credentials expiring in 60 min (later than CLI) - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - [CLAUDE_CLI_PROFILE_ID]: { - type: "oauth", - provider: "anthropic", - access: "store-oauth-access", - refresh: "store-refresh", - expires: Date.now() + 60 * 60 * 1000, - }, - }, - }), - ); - - const store = ensureAuthProfileStore(agentDir); - // Fresher store oauth should be kept - const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID]; - expect(cliProfile.type).toBe("oauth"); - expect((cliProfile as { access: string }).access).toBe("store-oauth-access"); - }, - { prefix: "clawdbot-home-" }, - ); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } - }); - it("does not downgrade store oauth to token when CLI lacks refresh token", async () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-no-downgrade-oauth-")); - try { - await withTempHome( - async (tempHome) => { - const claudeDir = path.join(tempHome, ".claude"); - fs.mkdirSync(claudeDir, { recursive: true }); - // CLI has token-only credentials (no refresh token) - fs.writeFileSync( - path.join(claudeDir, ".credentials.json"), - JSON.stringify({ - claudeAiOauth: { - accessToken: "cli-token-access", - expiresAt: Date.now() + 30 * 60 * 1000, - }, - }), - ); - - const authPath = path.join(agentDir, "auth-profiles.json"); - // Store already has OAuth credentials with refresh token - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - [CLAUDE_CLI_PROFILE_ID]: { - type: "oauth", - provider: "anthropic", - access: "store-oauth-access", - refresh: "store-refresh", - expires: Date.now() + 60 * 60 * 1000, - }, - }, - }), - ); - - const store = ensureAuthProfileStore(agentDir); - // Keep oauth to preserve auto-refresh capability - const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID]; - expect(cliProfile.type).toBe("oauth"); - expect((cliProfile as { access: string }).access).toBe("store-oauth-access"); - }, - { prefix: "clawdbot-home-" }, - ); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } - }); -}); diff --git a/src/agents/auth-profiles.external-cli-credential-sync.skips-codex-sync-when-credentials-exist-in-another-profile.test.ts b/src/agents/auth-profiles.external-cli-credential-sync.skips-codex-sync-when-credentials-exist-in-another-profile.test.ts deleted file mode 100644 index 6fa6734d7..000000000 --- a/src/agents/auth-profiles.external-cli-credential-sync.skips-codex-sync-when-credentials-exist-in-another-profile.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { withTempHome } from "../../test/helpers/temp-home.js"; -import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js"; - -describe("external CLI credential sync", () => { - it("skips codex-cli sync when credentials already exist in another openai-codex profile", async () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-skip-")); - try { - await withTempHome( - async (tempHome) => { - const codexDir = path.join(tempHome, ".codex"); - fs.mkdirSync(codexDir, { recursive: true }); - const codexAuthPath = path.join(codexDir, "auth.json"); - fs.writeFileSync( - codexAuthPath, - JSON.stringify({ - tokens: { - access_token: "shared-access-token", - refresh_token: "shared-refresh-token", - }, - }), - ); - fs.utimesSync(codexAuthPath, new Date(), new Date()); - - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - "openai-codex:my-custom-profile": { - type: "oauth", - provider: "openai-codex", - access: "shared-access-token", - refresh: "shared-refresh-token", - expires: Date.now() + 3600000, - }, - }, - }), - ); - - const store = ensureAuthProfileStore(agentDir); - - expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined(); - expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined(); - }, - { prefix: "clawdbot-home-" }, - ); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } - }); - - it("creates codex-cli profile when credentials differ from existing openai-codex profiles", async () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-create-")); - try { - await withTempHome( - async (tempHome) => { - const codexDir = path.join(tempHome, ".codex"); - fs.mkdirSync(codexDir, { recursive: true }); - const codexAuthPath = path.join(codexDir, "auth.json"); - fs.writeFileSync( - codexAuthPath, - JSON.stringify({ - tokens: { - access_token: "unique-access-token", - refresh_token: "unique-refresh-token", - }, - }), - ); - fs.utimesSync(codexAuthPath, new Date(), new Date()); - - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - "openai-codex:my-custom-profile": { - type: "oauth", - provider: "openai-codex", - access: "different-access-token", - refresh: "different-refresh-token", - expires: Date.now() + 3600000, - }, - }, - }), - ); - - const store = ensureAuthProfileStore(agentDir); - - expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined(); - expect((store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access).toBe( - "unique-access-token", - ); - expect(store.profiles["openai-codex:my-custom-profile"]).toBeDefined(); - }, - { prefix: "clawdbot-home-" }, - ); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } - }); - - it("removes codex-cli profile when it duplicates another openai-codex profile", async () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-dedup-remove-")); - try { - await withTempHome( - async (tempHome) => { - const codexDir = path.join(tempHome, ".codex"); - fs.mkdirSync(codexDir, { recursive: true }); - const codexAuthPath = path.join(codexDir, "auth.json"); - fs.writeFileSync( - codexAuthPath, - JSON.stringify({ - tokens: { - access_token: "shared-access-token", - refresh_token: "shared-refresh-token", - }, - }), - ); - fs.utimesSync(codexAuthPath, new Date(), new Date()); - - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - [CODEX_CLI_PROFILE_ID]: { - type: "oauth", - provider: "openai-codex", - access: "shared-access-token", - refresh: "shared-refresh-token", - expires: Date.now() + 3600000, - }, - "openai-codex:my-custom-profile": { - type: "oauth", - provider: "openai-codex", - access: "shared-access-token", - refresh: "shared-refresh-token", - expires: Date.now() + 3600000, - }, - }, - }), - ); - - const store = ensureAuthProfileStore(agentDir); - - expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined(); - const saved = JSON.parse(fs.readFileSync(authPath, "utf8")) as { - profiles?: Record; - }; - expect(saved.profiles?.[CODEX_CLI_PROFILE_ID]).toBeUndefined(); - expect(saved.profiles?.["openai-codex:my-custom-profile"]).toBeDefined(); - }, - { prefix: "clawdbot-home-" }, - ); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } - }); -}); diff --git a/src/agents/auth-profiles.external-cli-credential-sync.syncs-claude-cli-oauth-credentials-into-anthropic.test.ts b/src/agents/auth-profiles.external-cli-credential-sync.syncs-claude-cli-oauth-credentials-into-anthropic.test.ts deleted file mode 100644 index 1295552ba..000000000 --- a/src/agents/auth-profiles.external-cli-credential-sync.syncs-claude-cli-oauth-credentials-into-anthropic.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { withTempHome } from "../../test/helpers/temp-home.js"; -import { CLAUDE_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js"; - -describe("external CLI credential sync", () => { - it("syncs Claude Code CLI OAuth credentials into anthropic:claude-cli", async () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-sync-")); - try { - // Create a temp home with Claude Code CLI credentials - await withTempHome( - async (tempHome) => { - // Create Claude Code CLI credentials with refreshToken (OAuth) - const claudeDir = path.join(tempHome, ".claude"); - fs.mkdirSync(claudeDir, { recursive: true }); - const claudeCreds = { - claudeAiOauth: { - accessToken: "fresh-access-token", - refreshToken: "fresh-refresh-token", - expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now - }, - }; - fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(claudeCreds)); - - // Create empty auth-profiles.json - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - "anthropic:default": { - type: "api_key", - provider: "anthropic", - key: "sk-default", - }, - }, - }), - ); - - // Load the store - should sync from CLI as OAuth credential - const store = ensureAuthProfileStore(agentDir); - - expect(store.profiles["anthropic:default"]).toBeDefined(); - expect((store.profiles["anthropic:default"] as { key: string }).key).toBe("sk-default"); - expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined(); - // Should be stored as OAuth credential (type: "oauth") for auto-refresh - const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID]; - expect(cliProfile.type).toBe("oauth"); - expect((cliProfile as { access: string }).access).toBe("fresh-access-token"); - expect((cliProfile as { refresh: string }).refresh).toBe("fresh-refresh-token"); - expect((cliProfile as { expires: number }).expires).toBeGreaterThan(Date.now()); - }, - { prefix: "clawdbot-home-" }, - ); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } - }); - it("syncs Claude Code CLI credentials without refreshToken as token type", async () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-token-sync-")); - try { - await withTempHome( - async (tempHome) => { - // Create Claude Code CLI credentials WITHOUT refreshToken (fallback to token type) - const claudeDir = path.join(tempHome, ".claude"); - fs.mkdirSync(claudeDir, { recursive: true }); - const claudeCreds = { - claudeAiOauth: { - accessToken: "access-only-token", - // No refreshToken - backward compatibility scenario - expiresAt: Date.now() + 60 * 60 * 1000, - }, - }; - fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(claudeCreds)); - - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync(authPath, JSON.stringify({ version: 1, profiles: {} })); - - const store = ensureAuthProfileStore(agentDir); - - expect(store.profiles[CLAUDE_CLI_PROFILE_ID]).toBeDefined(); - // Should be stored as token type (no refresh capability) - const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID]; - expect(cliProfile.type).toBe("token"); - expect((cliProfile as { token: string }).token).toBe("access-only-token"); - }, - { prefix: "clawdbot-home-" }, - ); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } - }); -}); diff --git a/src/agents/auth-profiles.external-cli-credential-sync.updates-codex-cli-profile-codex-cli-refresh.test.ts b/src/agents/auth-profiles.external-cli-credential-sync.updates-codex-cli-profile-codex-cli-refresh.test.ts deleted file mode 100644 index 16fe775ab..000000000 --- a/src/agents/auth-profiles.external-cli-credential-sync.updates-codex-cli-profile-codex-cli-refresh.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { withTempHome } from "../../test/helpers/temp-home.js"; -import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore } from "./auth-profiles.js"; - -describe("external CLI credential sync", () => { - it("updates codex-cli profile when Codex CLI refresh token changes", async () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-refresh-sync-")); - try { - await withTempHome( - async (tempHome) => { - const codexDir = path.join(tempHome, ".codex"); - fs.mkdirSync(codexDir, { recursive: true }); - const codexAuthPath = path.join(codexDir, "auth.json"); - fs.writeFileSync( - codexAuthPath, - JSON.stringify({ - tokens: { - access_token: "same-access", - refresh_token: "new-refresh", - }, - }), - ); - fs.utimesSync(codexAuthPath, new Date(), new Date()); - - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - [CODEX_CLI_PROFILE_ID]: { - type: "oauth", - provider: "openai-codex", - access: "same-access", - refresh: "old-refresh", - expires: Date.now() - 1000, - }, - }, - }), - ); - - const store = ensureAuthProfileStore(agentDir); - expect((store.profiles[CODEX_CLI_PROFILE_ID] as { refresh: string }).refresh).toBe( - "new-refresh", - ); - }, - { prefix: "clawdbot-home-" }, - ); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } - }); -}); diff --git a/src/agents/auth-profiles.external-cli-credential-sync.upgrades-token-oauth-claude-cli-gets-refreshtoken.test.ts b/src/agents/auth-profiles.external-cli-credential-sync.upgrades-token-oauth-claude-cli-gets-refreshtoken.test.ts deleted file mode 100644 index 2957215f6..000000000 --- a/src/agents/auth-profiles.external-cli-credential-sync.upgrades-token-oauth-claude-cli-gets-refreshtoken.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { withTempHome } from "../../test/helpers/temp-home.js"; -import { - CLAUDE_CLI_PROFILE_ID, - CODEX_CLI_PROFILE_ID, - ensureAuthProfileStore, -} from "./auth-profiles.js"; - -describe("external CLI credential sync", () => { - it("upgrades token to oauth when Claude Code CLI gets refreshToken", async () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-cli-upgrade-")); - try { - await withTempHome( - async (tempHome) => { - // Create Claude Code CLI credentials with refreshToken - const claudeDir = path.join(tempHome, ".claude"); - fs.mkdirSync(claudeDir, { recursive: true }); - fs.writeFileSync( - path.join(claudeDir, ".credentials.json"), - JSON.stringify({ - claudeAiOauth: { - accessToken: "new-oauth-access", - refreshToken: "new-refresh-token", - expiresAt: Date.now() + 60 * 60 * 1000, - }, - }), - ); - - // Create auth-profiles.json with existing token type credential - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: { - [CLAUDE_CLI_PROFILE_ID]: { - type: "token", - provider: "anthropic", - token: "old-token", - expires: Date.now() + 30 * 60 * 1000, - }, - }, - }), - ); - - const store = ensureAuthProfileStore(agentDir); - - // Should upgrade from token to oauth - const cliProfile = store.profiles[CLAUDE_CLI_PROFILE_ID]; - expect(cliProfile.type).toBe("oauth"); - expect((cliProfile as { access: string }).access).toBe("new-oauth-access"); - expect((cliProfile as { refresh: string }).refresh).toBe("new-refresh-token"); - }, - { prefix: "clawdbot-home-" }, - ); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } - }); - it("syncs Codex CLI credentials into openai-codex:codex-cli", async () => { - const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-codex-sync-")); - try { - await withTempHome( - async (tempHome) => { - // Create Codex CLI credentials - const codexDir = path.join(tempHome, ".codex"); - fs.mkdirSync(codexDir, { recursive: true }); - const codexCreds = { - tokens: { - access_token: "codex-access-token", - refresh_token: "codex-refresh-token", - }, - }; - const codexAuthPath = path.join(codexDir, "auth.json"); - fs.writeFileSync(codexAuthPath, JSON.stringify(codexCreds)); - - // Create empty auth-profiles.json - const authPath = path.join(agentDir, "auth-profiles.json"); - fs.writeFileSync( - authPath, - JSON.stringify({ - version: 1, - profiles: {}, - }), - ); - - const store = ensureAuthProfileStore(agentDir); - - expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeDefined(); - expect((store.profiles[CODEX_CLI_PROFILE_ID] as { access: string }).access).toBe( - "codex-access-token", - ); - }, - { prefix: "clawdbot-home-" }, - ); - } finally { - fs.rmSync(agentDir, { recursive: true, force: true }); - } - }); -}); diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index c3febd289..8662b0101 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -101,7 +101,7 @@ describe("runWithModelFallback", () => { const cfg = makeCfg(); const run = vi .fn() - .mockRejectedValueOnce(new Error('No credentials found for profile "anthropic:claude-cli".')) + .mockRejectedValueOnce(new Error('No credentials found for profile "anthropic:default".')) .mockResolvedValueOnce("ok"); const result = await runWithModelFallback({ diff --git a/src/agents/pi-embedded-helpers.isautherrormessage.test.ts b/src/agents/pi-embedded-helpers.isautherrormessage.test.ts index 160054b11..2c8fd65d0 100644 --- a/src/agents/pi-embedded-helpers.isautherrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isautherrormessage.test.ts @@ -12,7 +12,7 @@ const _makeFile = (overrides: Partial): WorkspaceBootstr describe("isAuthErrorMessage", () => { it("matches credential validation errors", () => { const samples = [ - 'No credentials found for profile "anthropic:claude-cli".', + 'No credentials found for profile "anthropic:default".', "No API key found for profile openai.", ]; for (const sample of samples) { diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index db529761f..7bf917a27 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { type AuthProfileStore, CLAUDE_CLI_PROFILE_ID } from "../agents/auth-profiles.js"; +import type { AuthProfileStore } from "../agents/auth-profiles.js"; import { buildAuthChoiceOptions } from "./auth-choice-options.js"; describe("buildAuthChoiceOptions", () => { @@ -9,60 +9,18 @@ describe("buildAuthChoiceOptions", () => { const options = buildAuthChoiceOptions({ store, includeSkip: false, - includeClaudeCliIfMissing: false, - platform: "linux", }); expect(options.find((opt) => opt.value === "github-copilot")).toBeDefined(); }); - it("includes Claude Code CLI option on macOS even when missing", () => { + it("includes setup-token option for Anthropic", () => { const store: AuthProfileStore = { version: 1, profiles: {} }; const options = buildAuthChoiceOptions({ store, includeSkip: false, - includeClaudeCliIfMissing: true, - platform: "darwin", }); - const claudeCli = options.find((opt) => opt.value === "claude-cli"); - expect(claudeCli).toBeDefined(); - expect(claudeCli?.hint).toBe("reuses existing Claude Code auth · requires Keychain access"); - }); - - it("skips missing Claude Code CLI option off macOS", () => { - const store: AuthProfileStore = { version: 1, profiles: {} }; - const options = buildAuthChoiceOptions({ - store, - includeSkip: false, - includeClaudeCliIfMissing: true, - platform: "linux", - }); - - expect(options.find((opt) => opt.value === "claude-cli")).toBeUndefined(); - }); - - it("uses token hint when Claude Code CLI credentials exist", () => { - const store: AuthProfileStore = { - version: 1, - profiles: { - [CLAUDE_CLI_PROFILE_ID]: { - type: "token", - provider: "anthropic", - token: "token", - expires: Date.now() + 60 * 60 * 1000, - }, - }, - }; - - const options = buildAuthChoiceOptions({ - store, - includeSkip: false, - includeClaudeCliIfMissing: true, - platform: "darwin", - }); - - const claudeCli = options.find((opt) => opt.value === "claude-cli"); - expect(claudeCli?.hint).toContain("token ok"); + expect(options.some((opt) => opt.value === "token")).toBe(true); }); it("includes Z.AI (GLM) auth choice", () => { @@ -70,8 +28,6 @@ describe("buildAuthChoiceOptions", () => { const options = buildAuthChoiceOptions({ store, includeSkip: false, - includeClaudeCliIfMissing: true, - platform: "darwin", }); expect(options.some((opt) => opt.value === "zai-api-key")).toBe(true); @@ -82,8 +38,6 @@ describe("buildAuthChoiceOptions", () => { const options = buildAuthChoiceOptions({ store, includeSkip: false, - includeClaudeCliIfMissing: true, - platform: "darwin", }); expect(options.some((opt) => opt.value === "minimax-api")).toBe(true); @@ -95,8 +49,6 @@ describe("buildAuthChoiceOptions", () => { const options = buildAuthChoiceOptions({ store, includeSkip: false, - includeClaudeCliIfMissing: true, - platform: "darwin", }); expect(options.some((opt) => opt.value === "moonshot-api-key")).toBe(true); @@ -108,8 +60,6 @@ describe("buildAuthChoiceOptions", () => { const options = buildAuthChoiceOptions({ store, includeSkip: false, - includeClaudeCliIfMissing: true, - platform: "darwin", }); expect(options.some((opt) => opt.value === "ai-gateway-api-key")).toBe(true); @@ -120,8 +70,6 @@ describe("buildAuthChoiceOptions", () => { const options = buildAuthChoiceOptions({ store, includeSkip: false, - includeClaudeCliIfMissing: true, - platform: "darwin", }); expect(options.some((opt) => opt.value === "synthetic-api-key")).toBe(true); @@ -132,8 +80,6 @@ describe("buildAuthChoiceOptions", () => { const options = buildAuthChoiceOptions({ store, includeSkip: false, - includeClaudeCliIfMissing: true, - platform: "darwin", }); expect(options.some((opt) => opt.value === "chutes")).toBe(true); @@ -144,8 +90,6 @@ describe("buildAuthChoiceOptions", () => { const options = buildAuthChoiceOptions({ store, includeSkip: false, - includeClaudeCliIfMissing: true, - platform: "darwin", }); expect(options.some((opt) => opt.value === "qwen-portal")).toBe(true); diff --git a/src/commands/channels.adds-non-default-telegram-account.test.ts b/src/commands/channels.adds-non-default-telegram-account.test.ts index d03be6a51..3b1204c3b 100644 --- a/src/commands/channels.adds-non-default-telegram-account.test.ts +++ b/src/commands/channels.adds-non-default-telegram-account.test.ts @@ -244,7 +244,7 @@ describe("channels command", () => { authMocks.loadAuthProfileStore.mockReturnValue({ version: 1, profiles: { - "anthropic:claude-cli": { + "anthropic:default": { type: "oauth", provider: "anthropic", access: "token", @@ -252,7 +252,7 @@ describe("channels command", () => { expires: 0, created: 0, }, - "openai-codex:codex-cli": { + "openai-codex:default": { type: "oauth", provider: "openai", access: "token", @@ -268,8 +268,8 @@ describe("channels command", () => { auth?: Array<{ id: string }>; }; const ids = payload.auth?.map((entry) => entry.id) ?? []; - expect(ids).toContain("anthropic:claude-cli"); - expect(ids).toContain("openai-codex:codex-cli"); + expect(ids).toContain("anthropic:default"); + expect(ids).toContain("openai-codex:default"); }); it("stores default account names in accounts when multiple accounts exist", async () => { diff --git a/src/commands/doctor-auth.deprecated-cli-profiles.test.ts b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts new file mode 100644 index 000000000..b7a50374b --- /dev/null +++ b/src/commands/doctor-auth.deprecated-cli-profiles.test.ts @@ -0,0 +1,109 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { maybeRemoveDeprecatedCliAuthProfiles } from "./doctor-auth.js"; +import type { DoctorPrompter } from "./doctor-prompter.js"; + +let originalAgentDir: string | undefined; +let originalPiAgentDir: string | undefined; +let tempAgentDir: string | undefined; + +function makePrompter(confirmValue: boolean): DoctorPrompter { + return { + confirm: vi.fn().mockResolvedValue(confirmValue), + confirmRepair: vi.fn().mockResolvedValue(confirmValue), + confirmAggressive: vi.fn().mockResolvedValue(confirmValue), + confirmSkipInNonInteractive: vi.fn().mockResolvedValue(confirmValue), + select: vi.fn().mockResolvedValue(""), + shouldRepair: confirmValue, + shouldForce: false, + }; +} + +beforeEach(() => { + originalAgentDir = process.env.CLAWDBOT_AGENT_DIR; + originalPiAgentDir = process.env.PI_CODING_AGENT_DIR; + tempAgentDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-")); + process.env.CLAWDBOT_AGENT_DIR = tempAgentDir; + process.env.PI_CODING_AGENT_DIR = tempAgentDir; +}); + +afterEach(() => { + if (originalAgentDir === undefined) { + delete process.env.CLAWDBOT_AGENT_DIR; + } else { + process.env.CLAWDBOT_AGENT_DIR = originalAgentDir; + } + if (originalPiAgentDir === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = originalPiAgentDir; + } + if (tempAgentDir) { + fs.rmSync(tempAgentDir, { recursive: true, force: true }); + tempAgentDir = undefined; + } +}); + +describe("maybeRemoveDeprecatedCliAuthProfiles", () => { + it("removes deprecated CLI auth profiles from store + config", async () => { + if (!tempAgentDir) throw new Error("Missing temp agent dir"); + const authPath = path.join(tempAgentDir, "auth-profiles.json"); + fs.writeFileSync( + authPath, + `${JSON.stringify( + { + version: 1, + profiles: { + "anthropic:claude-cli": { + type: "oauth", + provider: "anthropic", + access: "token-a", + refresh: "token-r", + expires: Date.now() + 60_000, + }, + "openai-codex:codex-cli": { + type: "oauth", + provider: "openai-codex", + access: "token-b", + refresh: "token-r2", + expires: Date.now() + 60_000, + }, + }, + }, + null, + 2, + )}\n`, + "utf8", + ); + + const cfg = { + auth: { + profiles: { + "anthropic:claude-cli": { provider: "anthropic", mode: "oauth" }, + "openai-codex:codex-cli": { provider: "openai-codex", mode: "oauth" }, + }, + order: { + anthropic: ["anthropic:claude-cli"], + "openai-codex": ["openai-codex:codex-cli"], + }, + }, + } as const; + + const next = await maybeRemoveDeprecatedCliAuthProfiles(cfg, makePrompter(true)); + + const raw = JSON.parse(fs.readFileSync(authPath, "utf8")) as { + profiles?: Record; + }; + expect(raw.profiles?.["anthropic:claude-cli"]).toBeUndefined(); + expect(raw.profiles?.["openai-codex:codex-cli"]).toBeUndefined(); + + expect(next.auth?.profiles?.["anthropic:claude-cli"]).toBeUndefined(); + expect(next.auth?.profiles?.["openai-codex:codex-cli"]).toBeUndefined(); + expect(next.auth?.order?.anthropic).toBeUndefined(); + expect(next.auth?.order?.["openai-codex"]).toBeUndefined(); + }); +}); diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index c87f4efeb..35e69fd45 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -154,13 +154,13 @@ describe("applyAuthProfileConfig", () => { }, }, { - profileId: "anthropic:claude-cli", + profileId: "anthropic:work", provider: "anthropic", mode: "oauth", }, ); - expect(next.auth?.order?.anthropic).toEqual(["anthropic:claude-cli", "anthropic:default"]); + expect(next.auth?.order?.anthropic).toEqual(["anthropic:work", "anthropic:default"]); }); }); diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index 7172c2ce9..bf082d559 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -335,81 +335,6 @@ describe("provider usage loading", () => { ); }); - it("prefers claude-cli token for Anthropic usage snapshots", async () => { - await withTempHome( - async () => { - const stateDir = process.env.CLAWDBOT_STATE_DIR; - if (!stateDir) throw new Error("Missing CLAWDBOT_STATE_DIR"); - const agentDir = path.join(stateDir, "agents", "main", "agent"); - fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 }); - fs.writeFileSync( - path.join(agentDir, "auth-profiles.json"), - `${JSON.stringify( - { - version: 1, - profiles: { - "anthropic:default": { - type: "token", - provider: "anthropic", - token: "token-default", - expires: Date.UTC(2100, 0, 1, 0, 0, 0), - }, - "anthropic:claude-cli": { - type: "token", - provider: "anthropic", - token: "token-cli", - expires: Date.UTC(2100, 0, 1, 0, 0, 0), - }, - }, - }, - null, - 2, - )}\n`, - "utf8", - ); - - const makeResponse = (status: number, body: unknown): Response => { - const payload = typeof body === "string" ? body : JSON.stringify(body); - const headers = - typeof body === "string" ? undefined : { "Content-Type": "application/json" }; - return new Response(payload, { status, headers }); - }; - - const mockFetch = vi.fn, ReturnType>( - async (input, init) => { - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.toString() - : input.url; - if (url.includes("api.anthropic.com/api/oauth/usage")) { - const headers = (init?.headers ?? {}) as Record; - expect(headers.Authorization).toBe("Bearer token-cli"); - return makeResponse(200, { - five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" }, - }); - } - return makeResponse(404, "not found"); - }, - ); - - const summary = await loadProviderUsageSummary({ - now: Date.UTC(2026, 0, 7, 0, 0, 0), - providers: ["anthropic"], - agentDir, - fetch: mockFetch, - }); - - expect(summary.providers).toHaveLength(1); - expect(summary.providers[0]?.provider).toBe("anthropic"); - expect(summary.providers[0]?.windows[0]?.label).toBe("5h"); - expect(mockFetch).toHaveBeenCalled(); - }, - { prefix: "clawdbot-provider-usage-" }, - ); - }); - it("falls back to claude.ai web usage when OAuth scope is missing", async () => { const cookieSnapshot = process.env.CLAUDE_AI_SESSION_KEY; process.env.CLAUDE_AI_SESSION_KEY = "sk-ant-web-1"; From 000d5508aa64d7fdda25e8b902772ab879e5a237 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 19:04:46 +0000 Subject: [PATCH 38/40] docs(auth): remove external CLI OAuth reuse --- docs/cli/index.md | 13 ++--- docs/cli/models.md | 4 +- docs/concepts/model-providers.md | 4 +- docs/concepts/oauth.md | 69 ++++++++------------------ docs/gateway/authentication.md | 54 ++++++-------------- docs/gateway/configuration.md | 10 ---- docs/gateway/troubleshooting.md | 9 +--- docs/help/faq.md | 49 ++++++++---------- docs/providers/anthropic.md | 24 ++++----- docs/providers/claude-max-api-proxy.md | 2 +- docs/providers/openai.md | 14 ++---- docs/tools/slash-commands.md | 2 +- 12 files changed, 83 insertions(+), 171 deletions(-) diff --git a/docs/cli/index.md b/docs/cli/index.md index 9a72322e2..c49677cbf 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -297,7 +297,7 @@ Options: - `--non-interactive` - `--mode ` - `--flow ` (manual is an alias for advanced) -- `--auth-choice ` +- `--auth-choice ` - `--token-provider ` (non-interactive; used with `--auth-choice token`) - `--token ` (non-interactive; used with `--auth-choice token`) - `--token-profile-id ` (non-interactive; default: `:manual`) @@ -358,7 +358,7 @@ Options: Manage chat channel accounts (WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams). Subcommands: -- `channels list`: show configured channels and auth profiles (Claude Code + Codex CLI OAuth sync included). +- `channels list`: show configured channels and auth profiles. - `channels status`: check gateway reachability and channel health (`--probe` runs extra checks; use `clawdbot health` or `clawdbot status --deep` for gateway health probes). - Tip: `channels status` prints warnings with suggested fixes when it can detect common misconfigurations (then points you to `clawdbot doctor`). - `channels logs`: show recent channel logs from the gateway log file. @@ -390,12 +390,6 @@ Common options: - `--lines ` (default `200`) - `--json` -OAuth sync sources: -- Claude Code → `anthropic:claude-cli` - - macOS: Keychain item "Claude Code-credentials" (choose "Always Allow" to avoid launchd prompts) - - Linux/Windows: `~/.claude/.credentials.json` -- `~/.codex/auth.json` → `openai-codex:codex-cli` - More detail: [/concepts/oauth](/concepts/oauth) Examples: @@ -676,10 +670,11 @@ Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `bas See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy. -Preferred Anthropic auth (CLI token, not API key): +Preferred Anthropic auth (setup-token): ```bash claude setup-token +clawdbot models auth setup-token --provider anthropic clawdbot models status ``` diff --git a/docs/cli/models.md b/docs/cli/models.md index ba4600ce4..cb0992121 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -64,5 +64,5 @@ clawdbot models auth paste-token `clawdbot plugins list` to see which providers are installed. Notes: -- `setup-token` runs `claude setup-token` on the current machine (requires the Claude Code CLI). -- `paste-token` accepts a token string generated elsewhere. +- `setup-token` prompts for a setup-token value (generate it with `claude setup-token` on any machine). +- `paste-token` accepts a token string generated elsewhere or from automation. diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index acbca6461..46dc4f749 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -49,9 +49,9 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no** ### OpenAI Code (Codex) - Provider: `openai-codex` -- Auth: OAuth or Codex CLI (`~/.codex/auth.json`) +- Auth: OAuth (ChatGPT) - Example model: `openai-codex/gpt-5.2` -- CLI: `clawdbot onboard --auth-choice openai-codex` or `codex-cli` +- CLI: `clawdbot onboard --auth-choice openai-codex` or `clawdbot models auth login --provider openai-codex` ```json5 { diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index 8b2f54d1d..00fe3d656 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -1,18 +1,17 @@ --- -summary: "OAuth in Clawdbot: token exchange, storage, CLI sync, and multi-account patterns" +summary: "OAuth in Clawdbot: token exchange, storage, and multi-account patterns" read_when: - You want to understand Clawdbot OAuth end-to-end - You hit token invalidation / logout issues - - You want to reuse Claude Code / Codex CLI OAuth tokens + - You want setup-token or OAuth auth flows - You want multiple accounts or profile routing --- # OAuth -Clawdbot supports “subscription auth” via OAuth for providers that offer it (notably **Anthropic (Claude Pro/Max)** and **OpenAI Codex (ChatGPT OAuth)**). This page explains: +Clawdbot supports “subscription auth” via OAuth for providers that offer it (notably **OpenAI Codex (ChatGPT OAuth)**). For Anthropic subscriptions, use the **setup-token** flow. This page explains: - how the OAuth **token exchange** works (PKCE) - where tokens are **stored** (and why) -- how we **reuse external CLI tokens** (Claude Code / Codex CLI) - how to handle **multiple accounts** (profiles + per-session overrides) Clawdbot also supports **provider plugins** that ship their own OAuth or API‑key @@ -31,7 +30,6 @@ Practical symptom: To reduce that, Clawdbot treats `auth-profiles.json` as a **token sink**: - the runtime reads credentials from **one place** -- we can **sync in** credentials from external CLIs instead of doing a second login - we can keep multiple profiles and route them deterministically ## Storage (where tokens live) @@ -46,47 +44,39 @@ Legacy import-only file (still supported, but not the main store): All of the above also respect `$CLAWDBOT_STATE_DIR` (state dir override). Full reference: [/gateway/configuration](/gateway/configuration#auth-storage-oauth--api-keys) -## Reusing Claude Code / Codex CLI OAuth tokens (recommended) +## Anthropic setup-token (subscription auth) -If you already signed in with the external CLIs *on the gateway host*, Clawdbot can reuse those tokens without starting a separate OAuth flow: +Run `claude setup-token` on any machine, then paste it into Clawdbot: -- Claude Code: `anthropic:claude-cli` - - macOS: Keychain item "Claude Code-credentials" (choose "Always Allow" to avoid launchd prompts) - - Linux/Windows: `~/.claude/.credentials.json` -- Codex CLI: reads `~/.codex/auth.json` → profile `openai-codex:codex-cli` +```bash +clawdbot models auth setup-token --provider anthropic +``` -Sync happens when Clawdbot loads the auth store (so it stays up-to-date when the CLIs refresh tokens). -On macOS, the first read may trigger a Keychain prompt; run `clawdbot models status` -in a terminal once if the Gateway runs headless and can’t access the entry. +If you generated the token elsewhere, paste it manually: -How to verify: +```bash +clawdbot models auth paste-token --provider anthropic +``` + +Verify: ```bash clawdbot models status -clawdbot channels list -``` - -Or JSON: - -```bash -clawdbot channels list --json ``` ## OAuth exchange (how login works) Clawdbot’s interactive login flows are implemented in `@mariozechner/pi-ai` and wired into the wizards/commands. -### Anthropic (Claude Pro/Max) +### Anthropic (Claude Pro/Max) setup-token -Flow shape (PKCE): +Flow shape: -1) generate PKCE verifier/challenge -2) open `https://claude.ai/oauth/authorize?...` -3) user pastes `code#state` -4) exchange at `https://console.anthropic.com/v1/oauth/token` -5) store `{ access, refresh, expires }` under an auth profile +1) run `claude setup-token` +2) paste the token into Clawdbot +3) store as a token auth profile (no refresh) -The wizard path is `clawdbot onboard` → auth choice `oauth` (Anthropic). +The wizard path is `clawdbot onboard` → auth choice `setup-token` (Anthropic). ### OpenAI Codex (ChatGPT OAuth) @@ -99,7 +89,7 @@ Flow shape (PKCE): 5) exchange at `https://auth.openai.com/oauth/token` 6) extract `accountId` from the access token and store `{ access, refresh, expires, accountId }` -Wizard path is `clawdbot onboard` → auth choice `openai-codex` (or `codex-cli` to reuse an existing Codex CLI login). +Wizard path is `clawdbot onboard` → auth choice `openai-codex`. ## Refresh + expiry @@ -111,23 +101,6 @@ At runtime: The refresh flow is automatic; you generally don't need to manage tokens manually. -### Bidirectional sync with Claude Code - -When Clawdbot refreshes an Anthropic OAuth token (profile `anthropic:claude-cli`), it **writes the new credentials back** to Claude Code's storage: - -- **Linux/Windows**: updates `~/.claude/.credentials.json` -- **macOS**: updates Keychain item "Claude Code-credentials" - -This ensures both tools stay in sync and neither gets "logged out" after the other refreshes. - -**Why this matters for long-running agents:** - -Anthropic OAuth tokens expire after a few hours. Without bidirectional sync: -1. Clawdbot refreshes the token → gets new access token -2. Claude Code still has the old token → gets logged out - -With bidirectional sync, both tools always have the latest valid token, enabling autonomous operation for days or weeks without manual intervention. - ## Multiple accounts (profiles) + routing Two patterns: diff --git a/docs/gateway/authentication.md b/docs/gateway/authentication.md index 5f6aa3723..e350242d4 100644 --- a/docs/gateway/authentication.md +++ b/docs/gateway/authentication.md @@ -1,5 +1,5 @@ --- -summary: "Model authentication: OAuth, API keys, and Claude Code token reuse" +summary: "Model authentication: OAuth, API keys, and setup-token" read_when: - Debugging model auth or OAuth expiry - Documenting authentication or credential storage @@ -7,8 +7,8 @@ read_when: # Authentication Clawdbot supports OAuth and API keys for model providers. For Anthropic -accounts, we recommend using an **API key**. Clawdbot can also reuse Claude Code -credentials, including the long‑lived token created by `claude setup-token`. +accounts, we recommend using an **API key**. For Claude subscription access, +use the long‑lived token created by `claude setup-token`. See [/concepts/oauth](/concepts/oauth) for the full OAuth flow and storage layout. @@ -47,29 +47,26 @@ API keys for daemon use: `clawdbot onboard`. See [Help](/help) for details on env inheritance (`env.shellEnv`, `~/.clawdbot/.env`, systemd/launchd). -## Anthropic: Claude Code CLI setup-token (supported) +## Anthropic: setup-token (subscription auth) -For Anthropic, the recommended path is an **API key**. If you’re already using -Claude Code CLI, the setup-token flow is also supported. -Run it on the **gateway host**: +For Anthropic, the recommended path is an **API key**. If you’re using a Claude +subscription, the setup-token flow is also supported. Run it on the **gateway host**: ```bash claude setup-token ``` -Then verify and sync into Clawdbot: +Then paste it into Clawdbot: ```bash -clawdbot models status -clawdbot doctor +clawdbot models auth setup-token --provider anthropic ``` -This should create (or refresh) an auth profile like `anthropic:claude-cli` in -the agent auth store. +If the token was created on another machine, paste it manually: -Clawdbot config sets `auth.profiles["anthropic:claude-cli"].mode` to `"oauth"` so -the profile accepts both OAuth and setup-token credentials. Older configs that -used `"token"` are auto-migrated on load. +```bash +clawdbot models auth paste-token --provider anthropic +``` If you see an Anthropic error like: @@ -79,12 +76,6 @@ This credential is only authorized for use with Claude Code and cannot be used f …use an Anthropic API key instead. -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 @@ -101,10 +92,6 @@ clawdbot models status --check Optional ops scripts (systemd/Termux) are documented here: [/automation/auth-monitoring](/automation/auth-monitoring) -`clawdbot models status` loads Claude Code credentials into Clawdbot’s -`auth-profiles.json` and shows expiry (warns within 24h by default). -`clawdbot doctor` also performs the sync when it runs. - > `claude setup-token` requires an interactive TTY. ## Checking model auth status @@ -118,7 +105,7 @@ clawdbot doctor ### Per-session (chat command) -Use `/model @` to pin a specific provider credential for the current session (example profile ids: `anthropic:claude-cli`, `anthropic:default`). +Use `/model @` to pin a specific provider credential for the current session (example profile ids: `anthropic:default`, `anthropic:work`). Use `/model` (or `/model list`) for a compact picker; use `/model status` for the full view (candidates + next auth profile, plus provider endpoint details when configured). @@ -128,23 +115,12 @@ Set an explicit auth profile order override for an agent (stored in that agent ```bash clawdbot models auth order get --provider anthropic -clawdbot models auth order set --provider anthropic anthropic:claude-cli +clawdbot models auth order set --provider anthropic anthropic:default clawdbot models auth order clear --provider anthropic ``` Use `--agent ` to target a specific agent; omit it to use the configured default agent. -## How sync works - -1. **Claude Code** stores credentials in `~/.claude/.credentials.json` (or - Keychain on macOS). -2. **Clawdbot** syncs those into - `~/.clawdbot/agents//agent/auth-profiles.json` when the auth store is - loaded. -3. Refreshable OAuth profiles can be refreshed automatically on use. Static - token profiles (including Claude Code CLI setup-token) are not refreshable by - Clawdbot. - ## Troubleshooting ### “No credentials found” @@ -159,7 +135,7 @@ clawdbot models status ### Token expiring/expired Run `clawdbot models status` to confirm which profile is expiring. If the profile -is `anthropic:claude-cli`, rerun `claude setup-token`. +is missing, rerun `claude setup-token` and paste the token again. ## Requirements diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 8db2844fd..eaba866b1 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -374,12 +374,6 @@ Overrides: On first use, Clawdbot imports `oauth.json` entries into `auth-profiles.json`. -Clawdbot also auto-syncs OAuth tokens from external CLIs into `auth-profiles.json` (when present on the gateway host): -- Claude Code → `anthropic:claude-cli` - - macOS: Keychain item "Claude Code-credentials" (choose "Always Allow" to avoid launchd prompts) - - Linux/Windows: `~/.claude/.credentials.json` -- `~/.codex/auth.json` (Codex CLI) → `openai-codex:codex-cli` - ### `auth` Optional metadata for auth profiles. This does **not** store secrets; it maps @@ -400,10 +394,6 @@ rotation order used for failover. } ``` -Note: `anthropic:claude-cli` should use `mode: "oauth"` even when the stored -credential is a setup-token. Clawdbot auto-migrates older configs that used -`mode: "token"`. - ### `agents.list[].identity` Optional per-agent identity used for defaults and UX. This is written by the macOS onboarding assistant. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 5cbffd815..697654b80 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -53,13 +53,12 @@ clawdbot models status This means the stored Anthropic OAuth token expired and the refresh failed. If you’re on a Claude subscription (no API key), the most reliable fix is to -switch to a **Claude Code setup-token** or re-sync Claude Code CLI OAuth on the -**gateway host**. +switch to a **Claude Code setup-token** and paste it on the **gateway host**. **Recommended (setup-token):** ```bash -# Run on the gateway host (runs Claude Code CLI) +# Run on the gateway host (paste the setup-token) clawdbot models auth setup-token --provider anthropic clawdbot models status ``` @@ -71,10 +70,6 @@ clawdbot models auth paste-token --provider anthropic clawdbot models status ``` -**If you want to keep OAuth reuse:** -log in with Claude Code CLI on the gateway host, then run `clawdbot models status` -to sync the refreshed token into Clawdbot’s auth store. - More detail: [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth). ### Control UI fails on HTTP ("device identity required" / "connect failed") diff --git a/docs/help/faq.md b/docs/help/faq.md index aadbda9de..f4e177f8d 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -630,7 +630,7 @@ Docs: [Anthropic](/providers/anthropic), [OpenAI](/providers/openai), ### Can I use Claude Max subscription without an API key -Yes. You can authenticate with **Claude Code CLI OAuth** or a **setup-token** +Yes. You can authenticate with a **setup-token** instead of an API key. This is the subscription path. Claude Pro/Max subscriptions **do not include an API key**, so this is the @@ -640,11 +640,7 @@ If you want the most explicit, supported path, use an Anthropic API key. ### How does Anthropic setuptoken auth work -`claude setup-token` generates a **token string** via the Claude Code CLI (it is not available in the web console). You can run it on **any machine**. If Claude Code CLI credentials are present on the gateway host, Clawdbot can reuse them; otherwise choose **Anthropic token (paste setup-token)** and paste the string. The token is stored as an auth profile for the **anthropic** provider and used like an API key or OAuth profile. More detail: [OAuth](/concepts/oauth). - -Clawdbot keeps `auth.profiles["anthropic:claude-cli"].mode` set to `"oauth"` so -the profile accepts both OAuth and setup-token credentials; older `"token"` mode -entries auto-migrate. +`claude setup-token` generates a **token string** via the Claude Code CLI (it is not available in the web console). You can run it on **any machine**. Choose **Anthropic token (paste setup-token)** in the wizard or paste it with `clawdbot models auth paste-token --provider anthropic`. The token is stored as an auth profile for the **anthropic** provider and used like an API key (no auto-refresh). More detail: [OAuth](/concepts/oauth). ### Where do I find an Anthropic setuptoken @@ -656,9 +652,9 @@ claude setup-token Copy the token it prints, then choose **Anthropic token (paste setup-token)** in the wizard. If you want to run it on the gateway host, use `clawdbot models auth setup-token --provider anthropic`. If you ran `claude setup-token` elsewhere, paste it on the gateway host with `clawdbot models auth paste-token --provider anthropic`. See [Anthropic](/providers/anthropic). -### Do you support Claude subscription auth Claude Code OAuth +### Do you support Claude subscription auth (Claude Pro/Max) -Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also supports **setup-token**. If you have a Claude subscription, we recommend **setup-token** for long‑running setups (requires Claude Pro/Max + the `claude` CLI). You can generate it anywhere and paste it on the gateway host. OAuth reuse is supported, but avoid logging in separately via Clawdbot and Claude Code to prevent token conflicts. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth). +Yes — via **setup-token**. Clawdbot no longer reuses Claude Code CLI OAuth tokens; use a setup-token or an Anthropic API key. Generate the token anywhere and paste it on the gateway host. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth). Note: Claude subscription access is governed by Anthropic’s terms. For production or multi‑user workloads, API keys are usually the safer choice. @@ -678,13 +674,12 @@ Yes - via pi‑ai’s **Amazon Bedrock (Converse)** provider with **manual confi ### How does Codex auth work -Clawdbot supports **OpenAI Code (Codex)** via OAuth or by reusing your Codex CLI login (`~/.codex/auth.json`). The wizard can import the CLI login or run the OAuth flow and will set the default model to `openai-codex/gpt-5.2` when appropriate. See [Model providers](/concepts/model-providers) and [Wizard](/start/wizard). +Clawdbot supports **OpenAI Code (Codex)** via OAuth (ChatGPT sign-in). The wizard can run the OAuth flow and will set the default model to `openai-codex/gpt-5.2` when appropriate. See [Model providers](/concepts/model-providers) and [Wizard](/start/wizard). ### Do you support OpenAI subscription auth Codex OAuth -Yes. Clawdbot fully supports **OpenAI Code (Codex) subscription OAuth** and can also reuse an -existing Codex CLI login (`~/.codex/auth.json`) on the gateway host. The onboarding wizard -can import the CLI login or run the OAuth flow for you. +Yes. Clawdbot fully supports **OpenAI Code (Codex) subscription OAuth**. The onboarding wizard +can run the OAuth flow for you. See [OAuth](/concepts/oauth), [Model providers](/concepts/model-providers), and [Wizard](/start/wizard). @@ -1940,8 +1935,8 @@ You can list available models with `/model`, `/model list`, or `/model status`. You can also force a specific auth profile for the provider (per session): ``` -/model opus@anthropic:claude-cli /model opus@anthropic:default +/model opus@anthropic:work ``` Tip: `/model status` shows which agent is active, which `auth-profiles.json` file is being used, and which auth profile will be tried next. @@ -2145,21 +2140,17 @@ It means the system attempted to use the auth profile ID `anthropic:default`, bu - **Sanity‑check model/auth status** - Use `clawdbot models status` to see configured models and whether providers are authenticated. -**Fix checklist for No credentials found for profile anthropic claude cli** +**Fix checklist for No credentials found for profile anthropic** -This means the run is pinned to the **Claude Code CLI** profile, but the Gateway -can’t find that profile in its auth store. +This means the run is pinned to an Anthropic auth profile, but the Gateway +can’t find it in its auth store. -- **Sync the Claude Code CLI token on the gateway host** - - Run `clawdbot models status` (it loads + syncs Claude Code CLI credentials). - - If it still says missing: run `claude setup-token` (or `clawdbot models auth setup-token --provider anthropic`) and retry. -- **If the token was created on another machine** - - Paste it into the gateway host with `clawdbot models auth paste-token --provider anthropic`. -- **Check the profile mode** - - `auth.profiles["anthropic:claude-cli"].mode` must be `"oauth"` (token mode rejects OAuth credentials). +- **Use a setup-token** + - Run `claude setup-token`, then paste it with `clawdbot models auth setup-token --provider anthropic`. + - If the token was created on another machine, use `clawdbot models auth paste-token --provider anthropic`. - **If you want to use an API key instead** - Put `ANTHROPIC_API_KEY` in `~/.clawdbot/.env` on the **gateway host**. - - Clear any pinned order that forces `anthropic:claude-cli`: + - Clear any pinned order that forces a missing profile: ```bash clawdbot models auth order clear --provider anthropic ``` @@ -2181,7 +2172,7 @@ Fix: Clawdbot now strips unsigned thinking blocks for Google Antigravity Claude. ## Auth profiles: what they are and how to manage them -Related: [/concepts/oauth](/concepts/oauth) (OAuth flows, token storage, multi-account patterns, CLI sync) +Related: [/concepts/oauth](/concepts/oauth) (OAuth flows, token storage, multi-account patterns) ### What is an auth profile @@ -2212,10 +2203,10 @@ You can also set a **per-agent** order override (stored in that agent’s `auth- clawdbot models auth order get --provider anthropic # Lock rotation to a single profile (only try this one) -clawdbot models auth order set --provider anthropic anthropic:claude-cli +clawdbot models auth order set --provider anthropic anthropic:default # Or set an explicit order (fallback within provider) -clawdbot models auth order set --provider anthropic anthropic:claude-cli anthropic:default +clawdbot models auth order set --provider anthropic anthropic:work anthropic:default # Clear override (fall back to config auth.order / round-robin) clawdbot models auth order clear --provider anthropic @@ -2224,7 +2215,7 @@ clawdbot models auth order clear --provider anthropic To target a specific agent: ```bash -clawdbot models auth order set --provider anthropic --agent main anthropic:claude-cli +clawdbot models auth order set --provider anthropic --agent main anthropic:default ``` ### OAuth vs API key whats the difference @@ -2234,7 +2225,7 @@ Clawdbot supports both: - **OAuth** often leverages subscription access (where applicable). - **API keys** use pay‑per‑token billing. -The wizard explicitly supports Anthropic OAuth and OpenAI Codex OAuth and can store API keys for you. +The wizard explicitly supports Anthropic setup-token and OpenAI Codex OAuth and can store API keys for you. ## Gateway: ports, “already running”, and remote mode diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index 7876c4ae9..018e130dd 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -1,14 +1,13 @@ --- -summary: "Use Anthropic Claude via API keys or Claude Code CLI auth in Clawdbot" +summary: "Use Anthropic Claude via API keys or setup-token in Clawdbot" read_when: - You want to use Anthropic models in Clawdbot - - You want setup-token or Claude Code CLI auth instead of API keys + - You want setup-token instead of API keys --- # Anthropic (Claude) Anthropic builds the **Claude** model family and provides access via an API. -In Clawdbot you can authenticate with an API key or reuse **Claude Code CLI** credentials -(setup-token or OAuth). +In Clawdbot you can authenticate with an API key or a **setup-token**. ## Option A: Anthropic API key @@ -37,7 +36,7 @@ clawdbot onboard --anthropic-api-key "$ANTHROPIC_API_KEY" ## Prompt caching (Anthropic API) Clawdbot does **not** override Anthropic’s default cache TTL unless you set it. -This is **API-only**; Claude Code CLI OAuth ignores TTL settings. +This is **API-only**; subscription auth does not honor TTL settings. To set the TTL per model, use `cacheControlTtl` in the model `params`: @@ -58,9 +57,9 @@ To set the TTL per model, use `cacheControlTtl` in the model `params`: Clawdbot includes the `extended-cache-ttl-2025-04-11` beta flag for Anthropic API requests; keep it if you override provider headers (see [/gateway/configuration](/gateway/configuration)). -## Option B: Claude Code CLI (setup-token or OAuth) +## Option B: Claude setup-token -**Best for:** using your Claude subscription or existing Claude Code CLI login. +**Best for:** using your Claude subscription. ### Where to get a setup-token @@ -85,8 +84,8 @@ clawdbot models auth paste-token --provider anthropic ### CLI setup ```bash -# Reuse Claude Code CLI OAuth credentials if already logged in -clawdbot onboard --auth-choice claude-cli +# Paste a setup-token during onboarding +clawdbot onboard --auth-choice setup-token ``` ### Config snippet @@ -100,10 +99,7 @@ clawdbot onboard --auth-choice claude-cli ## Notes - Generate the setup-token with `claude setup-token` and paste it, or run `clawdbot models auth setup-token` on the gateway host. -- If you see “OAuth token refresh failed …” on a Claude subscription, re-auth with a setup-token or resync Claude Code CLI OAuth on the gateway host. See [/gateway/troubleshooting#oauth-token-refresh-failed-anthropic-claude-subscription](/gateway/troubleshooting#oauth-token-refresh-failed-anthropic-claude-subscription). -- Clawdbot writes `auth.profiles["anthropic:claude-cli"].mode` as `"oauth"` so the profile - accepts both OAuth and setup-token credentials. Older configs using `"token"` are - auto-migrated on load. +- If you see “OAuth token refresh failed …” on a Claude subscription, re-auth with a setup-token. See [/gateway/troubleshooting#oauth-token-refresh-failed-anthropic-claude-subscription](/gateway/troubleshooting#oauth-token-refresh-failed-anthropic-claude-subscription). - Auth details + reuse rules are in [/concepts/oauth](/concepts/oauth). ## Troubleshooting @@ -119,7 +115,7 @@ clawdbot onboard --auth-choice claude-cli - Re-run onboarding for that agent, or paste a setup-token / API key on the gateway host, then verify with `clawdbot models status`. -**No credentials found for profile `anthropic:default` or `anthropic:claude-cli`** +**No credentials found for profile `anthropic:default`** - Run `clawdbot models status` to see which auth profile is active. - Re-run onboarding, or paste a setup-token / API key for that profile. diff --git a/docs/providers/claude-max-api-proxy.md b/docs/providers/claude-max-api-proxy.md index 255be62fc..d2bb6cde8 100644 --- a/docs/providers/claude-max-api-proxy.md +++ b/docs/providers/claude-max-api-proxy.md @@ -141,5 +141,5 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.claude-max-api.plist ## See Also -- [Anthropic provider](/providers/anthropic) - Native Clawdbot integration with Claude Code CLI OAuth +- [Anthropic provider](/providers/anthropic) - Native Clawdbot integration with Claude setup-token or API keys - [OpenAI provider](/providers/openai) - For OpenAI/Codex subscriptions diff --git a/docs/providers/openai.md b/docs/providers/openai.md index 442d7f3ae..c877d59ff 100644 --- a/docs/providers/openai.md +++ b/docs/providers/openai.md @@ -7,9 +7,7 @@ read_when: # OpenAI OpenAI provides developer APIs for GPT models. Codex supports **ChatGPT sign-in** for subscription -access or **API key** sign-in for usage-based access. Codex cloud requires ChatGPT sign-in, while -the Codex CLI supports either sign-in method. The Codex CLI caches login details in -`~/.codex/auth.json` (or your OS credential store), which Clawdbot can reuse. +access or **API key** sign-in for usage-based access. Codex cloud requires ChatGPT sign-in. ## Option A: OpenAI API key (OpenAI Platform) @@ -38,16 +36,14 @@ clawdbot onboard --openai-api-key "$OPENAI_API_KEY" **Best for:** using ChatGPT/Codex subscription access instead of an API key. Codex cloud requires ChatGPT sign-in, while the Codex CLI supports ChatGPT or API key sign-in. -Clawdbot can reuse your **Codex CLI** login (`~/.codex/auth.json`) or run the OAuth flow. - ### CLI setup ```bash -# Reuse existing Codex CLI login -clawdbot onboard --auth-choice codex-cli - -# Or run Codex OAuth in the wizard +# Run Codex OAuth in the wizard clawdbot onboard --auth-choice openai-codex + +# Or run OAuth directly +clawdbot models auth login --provider openai-codex ``` ### Config snippet diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 84a087dba..93b51d5ae 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -132,7 +132,7 @@ Examples: /model list /model 3 /model openai/gpt-5.2 -/model opus@anthropic:claude-cli +/model opus@anthropic:default /model status ``` From fba7afaa123544e86a3e91646d26a663452ccfb4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 19:04:49 +0000 Subject: [PATCH 39/40] chore(scripts): update claude auth status hints --- scripts/claude-auth-status.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/claude-auth-status.sh b/scripts/claude-auth-status.sh index cf10b197d..d0294d58d 100755 --- a/scripts/claude-auth-status.sh +++ b/scripts/claude-auth-status.sh @@ -54,7 +54,7 @@ calc_status_from_expires() { json_expires_for_claude_cli() { echo "$STATUS_JSON" | jq -r ' [.auth.oauth.profiles[] - | select(.provider == "anthropic" and .type == "oauth" and .source == "claude-cli") + | select(.provider == "anthropic" and (.type == "oauth" or .type == "token")) | .expiresAt // 0] | max // 0 ' 2>/dev/null || echo "0" From 10d5ea5de6acacfe0169072cd9689203b1218c1e Mon Sep 17 00:00:00 2001 From: Frank Harris Date: Mon, 26 Jan 2026 14:23:11 -0500 Subject: [PATCH 40/40] docs: Add Oracle Cloud (OCI) platform guide (#2333) * docs: Add Oracle Cloud (OCI) platform guide - Add comprehensive guide for Oracle Cloud Always Free tier (ARM) - Cover VCN security, Tailscale Serve setup, and why traditional hardening is unnecessary - Update vps.md to list Oracle as top provider option - Update digitalocean.md to link to official Oracle guide instead of community gist Co-Authored-By: Claude Opus 4.5 * Keep community gist link, remove unzip * Fix step order: lock down VCN after Tailscale is running * Move VCN lockdown to final step (after verifying everything works) * docs: make Oracle/Tailscale guide safer + tone down DO copy * docs: fix Oracle guide step numbering * docs: tone down VPS hub Oracle blurb * docs: add Oracle Cloud guide (#2333) (thanks @hirefrank) --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Pocket Clawd --- CHANGELOG.md | 1 + docs/platforms/digitalocean.md | 34 +-- docs/platforms/oracle.md | 291 +++++++++++++++++++++ docs/vps.md | 3 +- src/discord/monitor/presence-cache.test.ts | 7 +- 5 files changed, 308 insertions(+), 28 deletions(-) create mode 100644 docs/platforms/oracle.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c1a05ff..ffcd26721 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Status: unreleased. - Docs: add Render deployment guide. (#1975) Thanks @anurag. - Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou. - Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto. +- Docs: add Oracle Cloud (OCI) platform guide + cross-links. (#2333) Thanks @hirefrank. - Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto. - Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev. - Docs: add LINE channel guide. Thanks @thewilloftheshadow. diff --git a/docs/platforms/digitalocean.md b/docs/platforms/digitalocean.md index 632057c84..afefe3676 100644 --- a/docs/platforms/digitalocean.md +++ b/docs/platforms/digitalocean.md @@ -1,5 +1,5 @@ --- -summary: "Clawdbot on DigitalOcean (cheapest paid VPS option)" +summary: "Clawdbot on DigitalOcean (simple paid VPS option)" read_when: - Setting up Clawdbot on DigitalOcean - Looking for cheap VPS hosting for Clawdbot @@ -11,22 +11,22 @@ read_when: Run a persistent Clawdbot Gateway on DigitalOcean for **$6/month** (or $4/mo with reserved pricing). -If you want something even cheaper, see [Oracle Cloud (Free Tier)](#oracle-cloud-free-alternative) at the bottom — it's **actually free forever**. +If you want a $0/month option and don’t mind ARM + provider-specific setup, see the [Oracle Cloud guide](/platforms/oracle). ## Cost Comparison (2026) | Provider | Plan | Specs | Price/mo | Notes | |----------|------|-------|----------|-------| -| **Oracle Cloud** | Always Free ARM | 4 OCPU, 24GB RAM | **$0** | Best value, requires ARM-compatible setup | -| **Hetzner** | CX22 | 2 vCPU, 4GB RAM | €3.79 (~$4) | Cheapest paid, EU datacenters | -| **DigitalOcean** | Basic | 1 vCPU, 1GB RAM | $6 | Easy UI, good docs | -| **Vultr** | Cloud Compute | 1 vCPU, 1GB RAM | $6 | Many locations | -| **Linode** | Nanode | 1 vCPU, 1GB RAM | $5 | Now part of Akamai | +| Oracle Cloud | Always Free ARM | up to 4 OCPU, 24GB RAM | $0 | ARM, limited capacity / signup quirks | +| Hetzner | CX22 | 2 vCPU, 4GB RAM | €3.79 (~$4) | Cheapest paid option | +| DigitalOcean | Basic | 1 vCPU, 1GB RAM | $6 | Easy UI, good docs | +| Vultr | Cloud Compute | 1 vCPU, 1GB RAM | $6 | Many locations | +| Linode | Nanode | 1 vCPU, 1GB RAM | $5 | Now part of Akamai | -**Recommendation:** -- **Free:** Oracle Cloud ARM (if you can handle the signup process) -- **Paid:** Hetzner CX22 (best specs per dollar) — see [Hetzner guide](/platforms/hetzner) -- **Easy:** DigitalOcean (this guide) — beginner-friendly UI +**Picking a provider:** +- DigitalOcean: simplest UX + predictable setup (this guide) +- Hetzner: good price/perf (see [Hetzner guide](/platforms/hetzner)) +- Oracle Cloud: can be $0/month, but is more finicky and ARM-only (see [Oracle guide](/platforms/oracle)) --- @@ -192,7 +192,7 @@ tar -czvf clawdbot-backup.tar.gz ~/.clawdbot ~/clawd ## Oracle Cloud Free Alternative -Oracle Cloud offers **Always Free** ARM instances that are significantly more powerful: +Oracle Cloud offers **Always Free** ARM instances that are significantly more powerful than any paid option here — for $0/month. | What you get | Specs | |--------------|-------| @@ -201,19 +201,11 @@ Oracle Cloud offers **Always Free** ARM instances that are significantly more po | **200GB storage** | Block volume | | **Forever free** | No credit card charges | -### Quick setup: -1. Sign up at [oracle.com/cloud/free](https://www.oracle.com/cloud/free/) -2. Create a VM.Standard.A1.Flex instance (ARM) -3. Choose Oracle Linux or Ubuntu -4. Allocate up to 4 OCPU / 24GB RAM within free tier -5. Follow the same Clawdbot install steps above - **Caveats:** - Signup can be finicky (retry if it fails) - ARM architecture — most things work, but some binaries need ARM builds -- Oracle may reclaim idle instances (keep them active) -For the full Oracle guide, see the [community docs](https://gist.github.com/rssnyder/51e3cfedd730e7dd5f4a816143b25dbd). +For the full setup guide, see [Oracle Cloud](/platforms/oracle). For signup tips and troubleshooting the enrollment process, see this [community guide](https://gist.github.com/rssnyder/51e3cfedd730e7dd5f4a816143b25dbd). --- diff --git a/docs/platforms/oracle.md b/docs/platforms/oracle.md new file mode 100644 index 000000000..d8006754b --- /dev/null +++ b/docs/platforms/oracle.md @@ -0,0 +1,291 @@ +--- +summary: "Clawdbot on Oracle Cloud (Always Free ARM)" +read_when: + - Setting up Clawdbot on Oracle Cloud + - Looking for low-cost VPS hosting for Clawdbot + - Want 24/7 Clawdbot on a small server +--- + +# Clawdbot on Oracle Cloud (OCI) + +## Goal + +Run a persistent Clawdbot Gateway on Oracle Cloud's **Always Free** ARM tier. + +Oracle’s free tier can be a great fit for Clawdbot (especially if you already have an OCI account), but it comes with tradeoffs: + +- ARM architecture (most things work, but some binaries may be x86-only) +- Capacity and signup can be finicky + +## Cost Comparison (2026) + +| Provider | Plan | Specs | Price/mo | Notes | +|----------|------|-------|----------|-------| +| Oracle Cloud | Always Free ARM | up to 4 OCPU, 24GB RAM | $0 | ARM, limited capacity | +| Hetzner | CX22 | 2 vCPU, 4GB RAM | ~ $4 | Cheapest paid option | +| DigitalOcean | Basic | 1 vCPU, 1GB RAM | $6 | Easy UI, good docs | +| Vultr | Cloud Compute | 1 vCPU, 1GB RAM | $6 | Many locations | +| Linode | Nanode | 1 vCPU, 1GB RAM | $5 | Now part of Akamai | + +--- + +## Prerequisites + +- Oracle Cloud account ([signup](https://www.oracle.com/cloud/free/)) — see [community signup guide](https://gist.github.com/rssnyder/51e3cfedd730e7dd5f4a816143b25dbd) if you hit issues +- Tailscale account (free at [tailscale.com](https://tailscale.com)) +- ~30 minutes + +## 1) Create an OCI Instance + +1. Log into [Oracle Cloud Console](https://cloud.oracle.com/) +2. Navigate to **Compute → Instances → Create Instance** +3. Configure: + - **Name:** `clawdbot` + - **Image:** Ubuntu 24.04 (aarch64) + - **Shape:** `VM.Standard.A1.Flex` (Ampere ARM) + - **OCPUs:** 2 (or up to 4) + - **Memory:** 12 GB (or up to 24 GB) + - **Boot volume:** 50 GB (up to 200 GB free) + - **SSH key:** Add your public key +4. Click **Create** +5. Note the public IP address + +**Tip:** If instance creation fails with "Out of capacity", try a different availability domain or retry later. Free tier capacity is limited. + +## 2) Connect and Update + +```bash +# Connect via public IP +ssh ubuntu@YOUR_PUBLIC_IP + +# Update system +sudo apt update && sudo apt upgrade -y +sudo apt install -y build-essential +``` + +**Note:** `build-essential` is required for ARM compilation of some dependencies. + +## 3) Configure User and Hostname + +```bash +# Set hostname +sudo hostnamectl set-hostname clawdbot + +# Set password for ubuntu user +sudo passwd ubuntu + +# Enable lingering (keeps user services running after logout) +sudo loginctl enable-linger ubuntu +``` + +## 4) Install Tailscale + +```bash +curl -fsSL https://tailscale.com/install.sh | sh +sudo tailscale up --ssh --hostname=clawdbot +``` + +This enables Tailscale SSH, so you can connect via `ssh clawdbot` from any device on your tailnet — no public IP needed. + +Verify: +```bash +tailscale status +``` + +**From now on, connect via Tailscale:** `ssh ubuntu@clawdbot` (or use the Tailscale IP). + +## 5) Install Clawdbot + +```bash +curl -fsSL https://clawd.bot/install.sh | bash +source ~/.bashrc +``` + +When prompted "How do you want to hatch your bot?", select **"Do this later"**. + +> Note: If you hit ARM-native build issues, start with system packages (e.g. `sudo apt install -y build-essential`) before reaching for Homebrew. + +## 6) Configure Gateway (loopback + token auth) and enable Tailscale Serve + +Use token auth as the default. It’s predictable and avoids needing any “insecure auth” Control UI flags. + +```bash +# Keep the Gateway private on the VM +clawdbot config set gateway.bind loopback + +# Require auth for the Gateway + Control UI +clawdbot config set gateway.auth.mode token +clawdbot doctor --generate-gateway-token + +# Expose over Tailscale Serve (HTTPS + tailnet access) +clawdbot config set gateway.tailscale.mode serve +clawdbot config set gateway.trustedProxies '["127.0.0.1"]' + +systemctl --user restart clawdbot-gateway +``` + +## 7) Verify + +```bash +# Check version +clawdbot --version + +# Check daemon status +systemctl --user status clawdbot-gateway + +# Check Tailscale Serve +tailscale serve status + +# Test local response +curl http://localhost:18789 +``` + +## 8) Lock Down VCN Security + +Now that everything is working, lock down the VCN to block all traffic except Tailscale. OCI's Virtual Cloud Network acts as a firewall at the network edge — traffic is blocked before it reaches your instance. + +1. Go to **Networking → Virtual Cloud Networks** in the OCI Console +2. Click your VCN → **Security Lists** → Default Security List +3. **Remove** all ingress rules except: + - `0.0.0.0/0 UDP 41641` (Tailscale) +4. Keep default egress rules (allow all outbound) + +This blocks SSH on port 22, HTTP, HTTPS, and everything else at the network edge. From now on, you can only connect via Tailscale. + +--- + +## Access the Control UI + +From any device on your Tailscale network: + +``` +https://clawdbot..ts.net/ +``` + +Replace `` with your tailnet name (visible in `tailscale status`). + +No SSH tunnel needed. Tailscale provides: +- HTTPS encryption (automatic certs) +- Authentication via Tailscale identity +- Access from any device on your tailnet (laptop, phone, etc.) + +--- + +## Security: VCN + Tailscale (recommended baseline) + +With the VCN locked down (only UDP 41641 open) and the Gateway bound to loopback, you get strong defense-in-depth: public traffic is blocked at the network edge, and admin access happens over your tailnet. + +This setup often removes the *need* for extra host-based firewall rules purely to stop Internet-wide SSH brute force — but you should still keep the OS updated, run `clawdbot security audit`, and verify you aren’t accidentally listening on public interfaces. + +### What's Already Protected + +| Traditional Step | Needed? | Why | +|------------------|---------|-----| +| UFW firewall | No | VCN blocks before traffic reaches instance | +| fail2ban | No | No brute force if port 22 blocked at VCN | +| sshd hardening | No | Tailscale SSH doesn't use sshd | +| Disable root login | No | Tailscale uses Tailscale identity, not system users | +| SSH key-only auth | No | Tailscale authenticates via your tailnet | +| IPv6 hardening | Usually not | Depends on your VCN/subnet settings; verify what’s actually assigned/exposed | + +### Still Recommended + +- **Credential permissions:** `chmod 700 ~/.clawdbot` +- **Security audit:** `clawdbot security audit` +- **System updates:** `sudo apt update && sudo apt upgrade` regularly +- **Monitor Tailscale:** Review devices in [Tailscale admin console](https://login.tailscale.com/admin) + +### Verify Security Posture + +```bash +# Confirm no public ports listening +sudo ss -tlnp | grep -v '127.0.0.1\|::1' + +# Verify Tailscale SSH is active +tailscale status | grep -q 'offers: ssh' && echo "Tailscale SSH active" + +# Optional: disable sshd entirely +sudo systemctl disable --now ssh +``` + +--- + +## Fallback: SSH Tunnel + +If Tailscale Serve isn't working, use an SSH tunnel: + +```bash +# From your local machine (via Tailscale) +ssh -L 18789:127.0.0.1:18789 ubuntu@clawdbot +``` + +Then open `http://localhost:18789`. + +--- + +## Troubleshooting + +### Instance creation fails ("Out of capacity") +Free tier ARM instances are popular. Try: +- Different availability domain +- Retry during off-peak hours (early morning) +- Use the "Always Free" filter when selecting shape + +### Tailscale won't connect +```bash +# Check status +sudo tailscale status + +# Re-authenticate +sudo tailscale up --ssh --hostname=clawdbot --reset +``` + +### Gateway won't start +```bash +clawdbot gateway status +clawdbot doctor --non-interactive +journalctl --user -u clawdbot-gateway -n 50 +``` + +### Can't reach Control UI +```bash +# Verify Tailscale Serve is running +tailscale serve status + +# Check gateway is listening +curl http://localhost:18789 + +# Restart if needed +systemctl --user restart clawdbot-gateway +``` + +### ARM binary issues +Some tools may not have ARM builds. Check: +```bash +uname -m # Should show aarch64 +``` + +Most npm packages work fine. For binaries, look for `linux-arm64` or `aarch64` releases. + +--- + +## Persistence + +All state lives in: +- `~/.clawdbot/` — config, credentials, session data +- `~/clawd/` — workspace (SOUL.md, memory, artifacts) + +Back up periodically: +```bash +tar -czvf clawdbot-backup.tar.gz ~/.clawdbot ~/clawd +``` + +--- + +## See Also + +- [Gateway remote access](/gateway/remote) — other remote access patterns +- [Tailscale integration](/gateway/tailscale) — full Tailscale docs +- [Gateway configuration](/gateway/configuration) — all config options +- [DigitalOcean guide](/platforms/digitalocean) — if you want paid + easier signup +- [Hetzner guide](/platforms/hetzner) — Docker-based alternative diff --git a/docs/vps.md b/docs/vps.md index d57205922..192ab830e 100644 --- a/docs/vps.md +++ b/docs/vps.md @@ -1,5 +1,5 @@ --- -summary: "VPS hosting hub for Clawdbot (Fly/Hetzner/GCP/exe.dev)" +summary: "VPS hosting hub for Clawdbot (Oracle/Fly/Hetzner/GCP/exe.dev)" read_when: - You want to run the Gateway in the cloud - You need a quick map of VPS/hosting guides @@ -11,6 +11,7 @@ deployments work at a high level. ## Pick a provider +- **Oracle Cloud (Always Free)**: [Oracle](/platforms/oracle) — $0/month (Always Free, ARM; capacity/signup can be finicky) - **Fly.io**: [Fly.io](/platforms/fly) - **Hetzner (Docker)**: [Hetzner](/platforms/hetzner) - **GCP (Compute Engine)**: [GCP](/platforms/gcp) diff --git a/src/discord/monitor/presence-cache.test.ts b/src/discord/monitor/presence-cache.test.ts index 8cdf8cefa..007d0548a 100644 --- a/src/discord/monitor/presence-cache.test.ts +++ b/src/discord/monitor/presence-cache.test.ts @@ -1,11 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import type { GatewayPresenceUpdate } from "discord-api-types/v10"; -import { - clearPresences, - getPresence, - presenceCacheSize, - setPresence, -} from "./presence-cache.js"; +import { clearPresences, getPresence, presenceCacheSize, setPresence } from "./presence-cache.js"; describe("presence-cache", () => { beforeEach(() => {