From 4efb5cc18efa487625ec121193dd2625213babf9 Mon Sep 17 00:00:00 2001 From: Friederike Seiler Date: Sun, 11 Jan 2026 15:06:54 +0100 Subject: [PATCH 1/5] Auth: add Chutes OAuth --- src/agents/auth-profiles.chutes.test.ts | 97 +++++++++++ src/agents/auth-profiles.ts | 12 +- src/agents/chutes-oauth.test.ts | 99 +++++++++++ src/agents/chutes-oauth.ts | 204 +++++++++++++++++++++++ src/agents/model-auth.ts | 4 + src/cli/program.ts | 3 +- src/commands/auth-choice-options.test.ts | 12 ++ src/commands/auth-choice-options.ts | 1 + src/commands/auth-choice.test.ts | 183 ++++++++++++++++++++ src/commands/auth-choice.ts | 107 ++++++++++++ src/commands/chutes-oauth.test.ts | 113 +++++++++++++ src/commands/chutes-oauth.ts | 186 +++++++++++++++++++++ src/commands/onboard-non-interactive.ts | 1 + src/commands/onboard-types.ts | 1 + 14 files changed, 1021 insertions(+), 2 deletions(-) create mode 100644 src/agents/auth-profiles.chutes.test.ts create mode 100644 src/agents/chutes-oauth.test.ts create mode 100644 src/agents/chutes-oauth.ts create mode 100644 src/commands/chutes-oauth.test.ts create mode 100644 src/commands/chutes-oauth.ts diff --git a/src/agents/auth-profiles.chutes.test.ts b/src/agents/auth-profiles.chutes.test.ts new file mode 100644 index 000000000..c5bf3c7b5 --- /dev/null +++ b/src/agents/auth-profiles.chutes.test.ts @@ -0,0 +1,97 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + CHUTES_TOKEN_ENDPOINT, + type ChutesStoredOAuth, +} from "./chutes-oauth.js"; +import { + ensureAuthProfileStore, + resolveApiKeyForProfile, + type AuthProfileStore, +} from "./auth-profiles.js"; + +describe("auth-profiles (chutes)", () => { + const previousStateDir = process.env.CLAWDBOT_STATE_DIR; + const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; + const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const previousChutesClientId = process.env.CHUTES_CLIENT_ID; + let tempDir: string | null = null; + + afterEach(async () => { + vi.unstubAllGlobals(); + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR; + else process.env.CLAWDBOT_STATE_DIR = previousStateDir; + 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; + if (previousChutesClientId === undefined) delete process.env.CHUTES_CLIENT_ID; + else process.env.CHUTES_CLIENT_ID = previousChutesClientId; + }); + + it("refreshes expired Chutes OAuth credentials", async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-chutes-")); + process.env.CLAWDBOT_STATE_DIR = tempDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + + const authProfilePath = path.join( + tempDir, + "agents", + "main", + "agent", + "auth-profiles.json", + ); + await fs.mkdir(path.dirname(authProfilePath), { recursive: true }); + + const store: AuthProfileStore = { + version: 1, + profiles: { + "chutes:default": { + type: "oauth", + provider: "chutes", + access: "at_old", + refresh: "rt_old", + expires: Date.now() - 60_000, + clientId: "cid_test", + } as unknown as ChutesStoredOAuth, + }, + }; + await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`); + + const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 }); + return new Response( + JSON.stringify({ + access_token: "at_new", + expires_in: 3600, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + vi.stubGlobal("fetch", fetchSpy); + + const loaded = ensureAuthProfileStore(); + const resolved = await resolveApiKeyForProfile({ + store: loaded, + profileId: "chutes:default", + }); + + expect(resolved?.apiKey).toBe("at_new"); + expect(fetchSpy).toHaveBeenCalled(); + + const persisted = JSON.parse(await fs.readFile(authProfilePath, "utf8")) as { + profiles?: Record; + }; + expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new"); + }); +}); diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 4016cde2f..4c19e17f4 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -20,6 +20,7 @@ import { readCodexCliCredentialsCached, writeClaudeCliCredentials, } from "./cli-credentials.js"; +import { refreshChutesTokens, type ChutesStoredOAuth } from "./chutes-oauth.js"; import { normalizeProviderId } from "./model-selection.js"; const AUTH_STORE_VERSION = 1; @@ -212,7 +213,16 @@ async function refreshOAuthTokenWithLock(params: { const oauthCreds: Record = { [cred.provider]: cred, }; - const result = await getOAuthApiKey(cred.provider, oauthCreds); + + const result = + String(cred.provider) === "chutes" + ? await (async () => { + const newCredentials = await refreshChutesTokens({ + credential: cred as unknown as ChutesStoredOAuth, + }); + return { apiKey: newCredentials.access, newCredentials }; + })() + : await getOAuthApiKey(cred.provider, oauthCreds); if (!result) return null; store.profiles[params.profileId] = { ...cred, diff --git a/src/agents/chutes-oauth.test.ts b/src/agents/chutes-oauth.test.ts new file mode 100644 index 000000000..7ae7217bc --- /dev/null +++ b/src/agents/chutes-oauth.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { + CHUTES_TOKEN_ENDPOINT, + CHUTES_USERINFO_ENDPOINT, + exchangeChutesCodeForTokens, + refreshChutesTokens, +} from "./chutes-oauth.js"; + +describe("chutes-oauth", () => { + it("exchanges code for tokens and stores username as email", async () => { + const fetchFn: typeof fetch = async (input, init) => { + const url = String(input); + if (url === CHUTES_TOKEN_ENDPOINT) { + expect(init?.method).toBe("POST"); + expect(String(init?.headers && (init.headers as Record)["Content-Type"])).toContain( + "application/x-www-form-urlencoded", + ); + return new Response( + JSON.stringify({ + access_token: "at_123", + refresh_token: "rt_123", + expires_in: 3600, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === CHUTES_USERINFO_ENDPOINT) { + expect( + String( + init?.headers && (init.headers as Record).Authorization, + ), + ).toBe("Bearer at_123"); + return new Response(JSON.stringify({ username: "fred", sub: "sub_1" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }; + + const now = 1_000_000; + const creds = await exchangeChutesCodeForTokens({ + app: { + clientId: "cid_test", + redirectUri: "http://127.0.0.1:1456/oauth-callback", + scopes: ["openid"], + }, + code: "code_123", + codeVerifier: "verifier_123", + fetchFn, + now, + }); + + expect(creds.access).toBe("at_123"); + expect(creds.refresh).toBe("rt_123"); + expect(creds.email).toBe("fred"); + expect((creds as unknown as { accountId?: string }).accountId).toBe("sub_1"); + expect((creds as unknown as { clientId?: string }).clientId).toBe("cid_test"); + expect(creds.expires).toBe(now + 3600 * 1000 - 5 * 60 * 1000); + }); + + it("refreshes tokens using stored client id and falls back to old refresh token", async () => { + const fetchFn: typeof fetch = async (input, init) => { + const url = String(input); + if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 }); + expect(init?.method).toBe("POST"); + const body = init?.body as URLSearchParams; + expect(String(body.get("grant_type"))).toBe("refresh_token"); + expect(String(body.get("client_id"))).toBe("cid_test"); + expect(String(body.get("refresh_token"))).toBe("rt_old"); + return new Response( + JSON.stringify({ + access_token: "at_new", + expires_in: 1800, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }; + + const now = 2_000_000; + const refreshed = await refreshChutesTokens({ + credential: { + access: "at_old", + refresh: "rt_old", + expires: now - 10_000, + email: "fred", + clientId: "cid_test", + } as unknown as Parameters[0]["credential"], + fetchFn, + now, + }); + + expect(refreshed.access).toBe("at_new"); + expect(refreshed.refresh).toBe("rt_old"); + expect(refreshed.expires).toBe(now + 1800 * 1000 - 5 * 60 * 1000); + }); +}); + diff --git a/src/agents/chutes-oauth.ts b/src/agents/chutes-oauth.ts new file mode 100644 index 000000000..57037f54c --- /dev/null +++ b/src/agents/chutes-oauth.ts @@ -0,0 +1,204 @@ +import { createHash, randomBytes } from "node:crypto"; + +import type { OAuthCredentials } from "@mariozechner/pi-ai"; + +export const CHUTES_OAUTH_ISSUER = "https://api.chutes.ai"; +export const CHUTES_AUTHORIZE_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/authorize`; +export const CHUTES_TOKEN_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/token`; +export const CHUTES_USERINFO_ENDPOINT = `${CHUTES_OAUTH_ISSUER}/idp/userinfo`; + +const DEFAULT_EXPIRES_BUFFER_MS = 5 * 60 * 1000; + +export type ChutesPkce = { verifier: string; challenge: string }; + +export type ChutesUserInfo = { + sub?: string; + username?: string; + created_at?: string; +}; + +export type ChutesOAuthAppConfig = { + clientId: string; + clientSecret?: string; + redirectUri: string; + scopes: string[]; +}; + +export type ChutesStoredOAuth = OAuthCredentials & { + clientId?: string; + clientSecret?: string; +}; + +export function generateChutesPkce(): ChutesPkce { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +export function parseOAuthCallbackInput( + input: string, + expectedState: string, +): { code: string; state: string } | { error: string } { + const trimmed = input.trim(); + if (!trimmed) return { error: "No input provided" }; + + try { + const url = new URL(trimmed); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state") ?? expectedState; + if (!code) return { error: "Missing 'code' parameter in URL" }; + if (!state) { + return { error: "Missing 'state' parameter. Paste the full URL." }; + } + return { code, state }; + } catch { + if (!expectedState) { + return { error: "Paste the full redirect URL, not just the code." }; + } + return { code: trimmed, state: expectedState }; + } +} + +function coerceExpiresAt(expiresInSeconds: number, now: number): number { + const value = + now + Math.max(0, Math.floor(expiresInSeconds)) * 1000 - DEFAULT_EXPIRES_BUFFER_MS; + return Math.max(value, now + 30_000); +} + +export async function fetchChutesUserInfo(params: { + accessToken: string; + fetchFn?: typeof fetch; +}): Promise { + const fetchFn = params.fetchFn ?? fetch; + const response = await fetchFn(CHUTES_USERINFO_ENDPOINT, { + headers: { Authorization: `Bearer ${params.accessToken}` }, + }); + if (!response.ok) return null; + const data = (await response.json()) as unknown; + if (!data || typeof data !== "object") return null; + const typed = data as ChutesUserInfo; + return typed; +} + +export async function exchangeChutesCodeForTokens(params: { + app: ChutesOAuthAppConfig; + code: string; + codeVerifier: string; + fetchFn?: typeof fetch; + now?: number; +}): Promise { + const fetchFn = params.fetchFn ?? fetch; + const now = params.now ?? Date.now(); + + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: params.app.clientId, + code: params.code, + redirect_uri: params.app.redirectUri, + code_verifier: params.codeVerifier, + }); + if (params.app.clientSecret) { + body.set("client_secret", params.app.clientSecret); + } + + const response = await fetchFn(CHUTES_TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Chutes token exchange failed: ${text}`); + } + + const data = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + + const access = data.access_token?.trim(); + const refresh = data.refresh_token?.trim(); + const expiresIn = data.expires_in ?? 0; + + if (!access) throw new Error("Chutes token exchange returned no access_token"); + if (!refresh) { + throw new Error("Chutes token exchange returned no refresh_token"); + } + + const info = await fetchChutesUserInfo({ accessToken: access, fetchFn }); + + return { + access, + refresh, + expires: coerceExpiresAt(expiresIn, now), + email: info?.username, + accountId: info?.sub, + clientId: params.app.clientId, + clientSecret: params.app.clientSecret, + } as unknown as ChutesStoredOAuth; +} + +export async function refreshChutesTokens(params: { + credential: ChutesStoredOAuth; + fetchFn?: typeof fetch; + now?: number; +}): Promise { + const fetchFn = params.fetchFn ?? fetch; + const now = params.now ?? Date.now(); + + const refreshToken = params.credential.refresh?.trim(); + if (!refreshToken) { + throw new Error("Chutes OAuth credential is missing refresh token"); + } + + const clientId = + params.credential.clientId?.trim() ?? process.env.CHUTES_CLIENT_ID?.trim(); + if (!clientId) { + throw new Error( + "Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).", + ); + } + const clientSecret = + params.credential.clientSecret?.trim() ?? + process.env.CHUTES_CLIENT_SECRET?.trim() ?? + undefined; + + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: refreshToken, + }); + if (clientSecret) body.set("client_secret", clientSecret); + + const response = await fetchFn(CHUTES_TOKEN_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(`Chutes token refresh failed: ${text}`); + } + + const data = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + const access = data.access_token?.trim(); + const newRefresh = data.refresh_token?.trim(); + const expiresIn = data.expires_in ?? 0; + + if (!access) throw new Error("Chutes token refresh returned no access_token"); + + return { + ...params.credential, + access, + refresh: newRefresh || refreshToken, + expires: coerceExpiresAt(expiresIn, now), + clientId, + clientSecret, + } as unknown as ChutesStoredOAuth; +} + diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 414446b12..d1c71ff01 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -125,6 +125,10 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null { return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY"); } + if (normalized === "chutes") { + return pick("CHUTES_OAUTH_TOKEN") ?? pick("CHUTES_API_KEY"); + } + if (normalized === "zai") { return pick("ZAI_API_KEY") ?? pick("Z_AI_API_KEY"); } diff --git a/src/cli/program.ts b/src/cli/program.ts index 1b864a63b..436857ade 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -264,7 +264,7 @@ export function buildProgram() { .option("--mode ", "Wizard mode: local|remote") .option( "--auth-choice ", - "Auth: setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|moonshot-api-key|synthetic-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", + "Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|moonshot-api-key|synthetic-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip", ) .option( "--token-provider ", @@ -331,6 +331,7 @@ export function buildProgram() { | "setup-token" | "claude-cli" | "token" + | "chutes" | "openai-codex" | "openai-api-key" | "openrouter-api-key" diff --git a/src/commands/auth-choice-options.test.ts b/src/commands/auth-choice-options.test.ts index a7090e620..70731c408 100644 --- a/src/commands/auth-choice-options.test.ts +++ b/src/commands/auth-choice-options.test.ts @@ -118,4 +118,16 @@ describe("buildAuthChoiceOptions", () => { expect(options.some((opt) => opt.value === "synthetic-api-key")).toBe(true); }); + + it("includes Chutes OAuth auth choice", () => { + const store: AuthProfileStore = { version: 1, profiles: {} }; + const options = buildAuthChoiceOptions({ + store, + includeSkip: false, + includeClaudeCliIfMissing: true, + platform: "darwin", + }); + + expect(options.some((opt) => opt.value === "chutes")).toBe(true); + }); }); diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 571e9d6be..79d52e04e 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -171,6 +171,7 @@ export function buildAuthChoiceOptions(params: { value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)", }); + options.push({ value: "chutes", label: "Chutes (OAuth)" }); options.push({ value: "openai-api-key", label: "OpenAI API key" }); options.push({ value: "openrouter-api-key", label: "OpenRouter API key" }); options.push({ value: "moonshot-api-key", label: "Moonshot AI API key" }); diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index f271353ed..5705e75b9 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -19,9 +19,13 @@ describe("applyAuthChoice", () => { const previousStateDir = process.env.CLAWDBOT_STATE_DIR; const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; + const previousOpenrouterKey = process.env.OPENROUTER_API_KEY; + const previousSshTty = process.env.SSH_TTY; + const previousChutesClientId = process.env.CHUTES_CLIENT_ID; let tempStateDir: string | null = null; afterEach(async () => { + vi.unstubAllGlobals(); if (tempStateDir) { await fs.rm(tempStateDir, { recursive: true, force: true }); tempStateDir = null; @@ -41,6 +45,21 @@ describe("applyAuthChoice", () => { } else { process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; } + if (previousOpenrouterKey === undefined) { + delete process.env.OPENROUTER_API_KEY; + } else { + process.env.OPENROUTER_API_KEY = previousOpenrouterKey; + } + if (previousSshTty === undefined) { + delete process.env.SSH_TTY; + } else { + process.env.SSH_TTY = previousSshTty; + } + if (previousChutesClientId === undefined) { + delete process.env.CHUTES_CLIENT_ID; + } else { + process.env.CHUTES_CLIENT_ID = previousChutesClientId; + } }); it("prompts and writes MiniMax API key when selecting minimax-api", async () => { @@ -260,4 +279,168 @@ describe("applyAuthChoice", () => { expect(result.config.models?.providers?.["opencode-zen"]).toBeUndefined(); expect(result.agentModelOverride).toBe("opencode/claude-opus-4-5"); }); + + it("uses existing OPENROUTER_API_KEY when selecting openrouter-api-key", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + process.env.OPENROUTER_API_KEY = "sk-openrouter-test"; + + const text = vi.fn(); + const select: WizardPrompter["select"] = vi.fn( + async (params) => params.options[0]?.value as never, + ); + const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); + const confirm = vi.fn(async () => true); + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select, + multiselect, + text, + confirm, + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "openrouter-api-key", + config: {}, + prompter, + runtime, + setDefaultModel: true, + }); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("OPENROUTER_API_KEY"), + }), + ); + expect(text).not.toHaveBeenCalled(); + expect(result.config.auth?.profiles?.["openrouter:default"]).toMatchObject({ + provider: "openrouter", + mode: "api_key", + }); + expect(result.config.agents?.defaults?.model?.primary).toBe( + "openrouter/auto", + ); + + const authProfilePath = path.join( + tempStateDir, + "agents", + "main", + "agent", + "auth-profiles.json", + ); + const raw = await fs.readFile(authProfilePath, "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record; + }; + expect(parsed.profiles?.["openrouter:default"]?.key).toBe( + "sk-openrouter-test", + ); + + delete process.env.OPENROUTER_API_KEY; + }); + + it("writes Chutes OAuth credentials when selecting chutes (remote/manual)", async () => { + tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-")); + process.env.CLAWDBOT_STATE_DIR = tempStateDir; + process.env.CLAWDBOT_AGENT_DIR = path.join(tempStateDir, "agent"); + process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; + process.env.SSH_TTY = "1"; + process.env.CHUTES_CLIENT_ID = "cid_test"; + + const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "https://api.chutes.ai/idp/token") { + return new Response( + JSON.stringify({ + access_token: "at_test", + refresh_token: "rt_test", + expires_in: 3600, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === "https://api.chutes.ai/idp/userinfo") { + return new Response(JSON.stringify({ username: "remote-user" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }); + vi.stubGlobal("fetch", fetchSpy); + + const text = vi.fn().mockResolvedValue("code_manual"); + const select: WizardPrompter["select"] = vi.fn( + async (params) => params.options[0]?.value as never, + ); + const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []); + const prompter: WizardPrompter = { + intro: vi.fn(noopAsync), + outro: vi.fn(noopAsync), + note: vi.fn(noopAsync), + select, + multiselect, + text, + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: noop, stop: noop })), + }; + const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn((code: number) => { + throw new Error(`exit:${code}`); + }), + }; + + const result = await applyAuthChoice({ + authChoice: "chutes", + config: {}, + prompter, + runtime, + setDefaultModel: false, + }); + + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Paste the redirect URL (or authorization code)", + }), + ); + expect(result.config.auth?.profiles?.["chutes:remote-user"]).toMatchObject({ + provider: "chutes", + mode: "oauth", + }); + + const authProfilePath = path.join( + tempStateDir, + "agents", + "main", + "agent", + "auth-profiles.json", + ); + const raw = await fs.readFile(authProfilePath, "utf8"); + const parsed = JSON.parse(raw) as { + profiles?: Record< + string, + { provider?: string; access?: string; refresh?: string; email?: string } + >; + }; + expect(parsed.profiles?.["chutes:remote-user"]).toMatchObject({ + provider: "chutes", + access: "at_test", + refresh: "rt_test", + email: "remote-user", + }); + }); }); diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index be224c39d..704950197 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -68,6 +68,7 @@ import { } from "./onboard-auth.js"; import { openUrl } from "./onboard-helpers.js"; import type { AuthChoice } from "./onboard-types.js"; +import { loginChutes } from "./chutes-oauth.js"; import { applyOpenAICodexModelDefault, OPENAI_CODEX_DEFAULT_MODEL, @@ -536,6 +537,110 @@ export async function applyAuthChoice(params: { agentModelOverride = MOONSHOT_DEFAULT_MODEL_REF; await noteAgentModel(MOONSHOT_DEFAULT_MODEL_REF); } + } else if (params.authChoice === "chutes") { + const isRemote = isRemoteEnvironment(); + const redirectUri = + process.env.CHUTES_OAUTH_REDIRECT_URI?.trim() || + "http://127.0.0.1:1456/oauth-callback"; + const scopes = + process.env.CHUTES_OAUTH_SCOPES?.trim() || "openid profile chutes:invoke"; + const clientId = + process.env.CHUTES_CLIENT_ID?.trim() || + String( + await params.prompter.text({ + message: "Enter Chutes OAuth client id", + placeholder: "cid_xxx", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined; + + await params.prompter.note( + isRemote + ? [ + "You are running in a remote/VPS environment.", + "A URL will be shown for you to open in your LOCAL browser.", + "After signing in, paste the redirect URL back here.", + "", + `Redirect URI: ${redirectUri}`, + ].join("\n") + : [ + "Browser will open for Chutes authentication.", + "If the callback doesn't auto-complete, paste the redirect URL.", + "", + `Redirect URI: ${redirectUri}`, + ].join("\n"), + "Chutes OAuth", + ); + + const spin = params.prompter.progress("Starting OAuth flow…"); + let manualCodePromise: Promise | undefined; + try { + const creds = await loginChutes({ + app: { + clientId, + clientSecret, + redirectUri, + scopes: scopes.split(/\\s+/).filter(Boolean), + }, + manual: isRemote, + onAuth: async ({ url }) => { + if (isRemote) { + spin.stop("OAuth URL ready"); + params.runtime.log( + `\\nOpen this URL in your LOCAL browser:\\n\\n${url}\\n`, + ); + manualCodePromise = params.prompter + .text({ + message: "Paste the redirect URL (or authorization code)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }) + .then((value) => String(value)); + } else { + spin.update("Complete sign-in in browser…"); + await openUrl(url); + params.runtime.log(`Open: ${url}`); + } + }, + onPrompt: async (prompt) => { + if (manualCodePromise) return manualCodePromise; + const code = await params.prompter.text({ + message: prompt.message, + placeholder: prompt.placeholder, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + return String(code); + }, + onProgress: (msg) => spin.update(msg), + }); + + spin.stop("Chutes OAuth complete"); + const email = creds.email?.trim() || "default"; + const profileId = `chutes:${email}`; + + await writeOAuthCredentials( + "chutes" as unknown as OAuthProvider, + creds, + params.agentDir, + ); + nextConfig = applyAuthProfileConfig(nextConfig, { + profileId, + provider: "chutes", + mode: "oauth", + }); + } catch (err) { + spin.stop("Chutes OAuth failed"); + params.runtime.error(String(err)); + await params.prompter.note( + [ + "Trouble with OAuth?", + "Verify CHUTES_CLIENT_ID (and CHUTES_CLIENT_SECRET if required).", + `Verify the OAuth app redirect URI includes: ${redirectUri}`, + "Chutes docs: https://chutes.ai/docs/sign-in-with-chutes/overview", + ].join("\\n"), + "OAuth help", + ); + } } else if (params.authChoice === "openai-codex") { const isRemote = isRemoteEnvironment(); await params.prompter.note( @@ -1060,6 +1165,8 @@ export function resolvePreferredProviderForAuthChoice( case "openai-codex": case "codex-cli": return "openai-codex"; + case "chutes": + return "chutes"; case "openai-api-key": return "openai"; case "openrouter-api-key": diff --git a/src/commands/chutes-oauth.test.ts b/src/commands/chutes-oauth.test.ts new file mode 100644 index 000000000..5e29f683c --- /dev/null +++ b/src/commands/chutes-oauth.test.ts @@ -0,0 +1,113 @@ +import net from "node:net"; + +import { describe, expect, it, vi } from "vitest"; + +import { + CHUTES_TOKEN_ENDPOINT, + CHUTES_USERINFO_ENDPOINT, +} from "../agents/chutes-oauth.js"; +import { loginChutes } from "./chutes-oauth.js"; + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("No TCP address"))); + return; + } + const port = address.port; + server.close((err) => (err ? reject(err) : resolve(port))); + }); + }); +} + +describe("loginChutes", () => { + it("captures local redirect and exchanges code for tokens", async () => { + const port = await getFreePort(); + const redirectUri = `http://127.0.0.1:${port}/oauth-callback`; + + const fetchFn: typeof fetch = async (input, init) => { + const url = String(input); + if (url === CHUTES_TOKEN_ENDPOINT) { + return new Response( + JSON.stringify({ + access_token: "at_local", + refresh_token: "rt_local", + expires_in: 3600, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === CHUTES_USERINFO_ENDPOINT) { + return new Response(JSON.stringify({ username: "local-user" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return fetch(input, init); + }; + + const onPrompt = vi.fn(async () => { + throw new Error("onPrompt should not be called for local callback"); + }); + + const creds = await loginChutes({ + app: { clientId: "cid_test", redirectUri, scopes: ["openid"] }, + onAuth: async ({ url }) => { + const state = new URL(url).searchParams.get("state"); + expect(state).toBeTruthy(); + await fetch(`${redirectUri}?code=code_local&state=${state}`); + }, + onPrompt, + fetchFn, + }); + + expect(onPrompt).not.toHaveBeenCalled(); + expect(creds.access).toBe("at_local"); + expect(creds.refresh).toBe("rt_local"); + expect(creds.email).toBe("local-user"); + }); + + it("supports manual flow with pasted code", async () => { + const fetchFn: typeof fetch = async (input) => { + const url = String(input); + if (url === CHUTES_TOKEN_ENDPOINT) { + return new Response( + JSON.stringify({ + access_token: "at_manual", + refresh_token: "rt_manual", + expires_in: 3600, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } + if (url === CHUTES_USERINFO_ENDPOINT) { + return new Response(JSON.stringify({ username: "manual-user" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("not found", { status: 404 }); + }; + + const creds = await loginChutes({ + app: { + clientId: "cid_test", + redirectUri: "http://127.0.0.1:1456/oauth-callback", + scopes: ["openid"], + }, + manual: true, + onAuth: async () => {}, + onPrompt: async () => "code_manual", + fetchFn, + }); + + expect(creds.access).toBe("at_manual"); + expect(creds.refresh).toBe("rt_manual"); + expect(creds.email).toBe("manual-user"); + }); +}); + diff --git a/src/commands/chutes-oauth.ts b/src/commands/chutes-oauth.ts new file mode 100644 index 000000000..d6edf14c5 --- /dev/null +++ b/src/commands/chutes-oauth.ts @@ -0,0 +1,186 @@ +import { createServer } from "node:http"; + +import type { OAuthCredentials } from "@mariozechner/pi-ai"; + +import type { ChutesOAuthAppConfig } from "../agents/chutes-oauth.js"; +import { + exchangeChutesCodeForTokens, + generateChutesPkce, + parseOAuthCallbackInput, +} from "../agents/chutes-oauth.js"; + +type OAuthPrompt = { + message: string; + placeholder?: string; +}; + +function buildAuthorizeUrl(params: { + clientId: string; + redirectUri: string; + scopes: string[]; + state: string; + challenge: string; +}): string { + const qs = new URLSearchParams({ + client_id: params.clientId, + redirect_uri: params.redirectUri, + response_type: "code", + scope: params.scopes.join(" "), + state: params.state, + code_challenge: params.challenge, + code_challenge_method: "S256", + }); + return `https://api.chutes.ai/idp/authorize?${qs.toString()}`; +} + +async function waitForLocalCallback(params: { + redirectUri: string; + expectedState: string; + timeoutMs: number; + onProgress?: (message: string) => void; +}): Promise<{ code: string; state: string }> { + const redirectUrl = new URL(params.redirectUri); + if (redirectUrl.protocol !== "http:") { + throw new Error(`Chutes OAuth redirect URI must be http:// (got ${params.redirectUri})`); + } + const hostname = redirectUrl.hostname || "127.0.0.1"; + const port = redirectUrl.port ? Number.parseInt(redirectUrl.port, 10) : 80; + const expectedPath = redirectUrl.pathname || "/"; + + let server: ReturnType | null = null; + let timeout: NodeJS.Timeout | null = null; + + try { + const resultPromise = new Promise<{ code: string; state: string }>( + (resolve, reject) => { + server = createServer((req, res) => { + try { + const requestUrl = new URL(req.url ?? "/", redirectUrl.origin); + if (requestUrl.pathname !== expectedPath) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Not found"); + return; + } + + const code = requestUrl.searchParams.get("code")?.trim(); + const state = requestUrl.searchParams.get("state")?.trim(); + + if (!code) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Missing code"); + return; + } + if (!state || state !== params.expectedState) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Invalid state"); + return; + } + + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end( + [ + "", + "", + "

Chutes OAuth complete

", + "

You can close this window and return to clawdbot.

", + ].join(""), + ); + resolve({ code, state }); + } catch (err) { + reject(err); + } + }); + + server.once("error", reject); + server.listen(port, hostname, () => { + params.onProgress?.( + `Waiting for OAuth callback on ${redirectUrl.origin}${expectedPath}…`, + ); + }); + }, + ); + + timeout = setTimeout(() => { + try { + server?.close(); + } catch {} + }, params.timeoutMs); + + return await resultPromise; + } finally { + if (timeout) clearTimeout(timeout); + if (server) { + try { + server.close(); + } catch {} + } + } +} + +export async function loginChutes(params: { + app: ChutesOAuthAppConfig; + manual?: boolean; + timeoutMs?: number; + onAuth: (event: { url: string }) => Promise; + onPrompt: (prompt: OAuthPrompt) => Promise; + onProgress?: (message: string) => void; + fetchFn?: typeof fetch; +}): Promise { + const { verifier, challenge } = generateChutesPkce(); + const state = verifier; + const timeoutMs = params.timeoutMs ?? 3 * 60 * 1000; + + const url = buildAuthorizeUrl({ + clientId: params.app.clientId, + redirectUri: params.app.redirectUri, + scopes: params.app.scopes, + state, + challenge, + }); + + let codeAndState: { code: string; state: string }; + if (params.manual) { + await params.onAuth({ url }); + params.onProgress?.("Waiting for redirect URL…"); + const input = await params.onPrompt({ + message: "Paste the redirect URL (or authorization code)", + placeholder: `${params.app.redirectUri}?code=...&state=...`, + }); + const parsed = parseOAuthCallbackInput(String(input), state); + if ("error" in parsed) throw new Error(parsed.error); + if (parsed.state !== state) throw new Error("Invalid OAuth state"); + codeAndState = parsed; + } else { + const callback = waitForLocalCallback({ + redirectUri: params.app.redirectUri, + expectedState: state, + timeoutMs, + onProgress: params.onProgress, + }).catch(async () => { + params.onProgress?.("OAuth callback not detected; paste redirect URL…"); + const input = await params.onPrompt({ + message: "Paste the redirect URL (or authorization code)", + placeholder: `${params.app.redirectUri}?code=...&state=...`, + }); + const parsed = parseOAuthCallbackInput(String(input), state); + if ("error" in parsed) throw new Error(parsed.error); + if (parsed.state !== state) throw new Error("Invalid OAuth state"); + return parsed; + }); + + await params.onAuth({ url }); + codeAndState = await callback; + } + + params.onProgress?.("Exchanging code for tokens…"); + return await exchangeChutesCodeForTokens({ + app: params.app, + code: codeAndState.code, + codeVerifier: verifier, + fetchFn: params.fetchFn, + }); +} diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 9cb64a332..ebfd6b958 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -419,6 +419,7 @@ export async function runNonInteractiveOnboarding( } else if ( authChoice === "token" || authChoice === "oauth" || + authChoice === "chutes" || authChoice === "openai-codex" || authChoice === "antigravity" ) { diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 5028da947..d6388a076 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -8,6 +8,7 @@ export type AuthChoice = | "setup-token" | "claude-cli" | "token" + | "chutes" | "openai-codex" | "openai-api-key" | "openrouter-api-key" From 0aba91191200075d1ab807d8a4a7c1a5f044e0eb Mon Sep 17 00:00:00 2001 From: Friederike Seiler Date: Sun, 11 Jan 2026 15:51:03 +0100 Subject: [PATCH 2/5] Chores: format chutes oauth --- src/commands/chutes-oauth.test.ts | 1 - src/commands/chutes-oauth.ts | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commands/chutes-oauth.test.ts b/src/commands/chutes-oauth.test.ts index 5e29f683c..ff1108894 100644 --- a/src/commands/chutes-oauth.test.ts +++ b/src/commands/chutes-oauth.test.ts @@ -110,4 +110,3 @@ describe("loginChutes", () => { expect(creds.email).toBe("manual-user"); }); }); - diff --git a/src/commands/chutes-oauth.ts b/src/commands/chutes-oauth.ts index d6edf14c5..75fc0fd67 100644 --- a/src/commands/chutes-oauth.ts +++ b/src/commands/chutes-oauth.ts @@ -41,7 +41,9 @@ async function waitForLocalCallback(params: { }): Promise<{ code: string; state: string }> { const redirectUrl = new URL(params.redirectUri); if (redirectUrl.protocol !== "http:") { - throw new Error(`Chutes OAuth redirect URI must be http:// (got ${params.redirectUri})`); + throw new Error( + `Chutes OAuth redirect URI must be http:// (got ${params.redirectUri})`, + ); } const hostname = redirectUrl.hostname || "127.0.0.1"; const port = redirectUrl.port ? Number.parseInt(redirectUrl.port, 10) : 80; From 0efcfc0864fc16efe5cf35c82402db0a705336c5 Mon Sep 17 00:00:00 2001 From: Friederike Seiler Date: Sun, 11 Jan 2026 16:01:53 +0100 Subject: [PATCH 3/5] Chores: fix chutes oauth build --- src/agents/auth-profiles.chutes.test.ts | 31 ++++-- src/agents/auth-profiles.ts | 2 +- src/agents/chutes-oauth.test.ts | 35 ++++--- src/agents/chutes-oauth.ts | 8 +- src/commands/auth-choice.ts | 2 +- src/commands/chutes-oauth.ts | 130 ++++++++++++------------ 6 files changed, 113 insertions(+), 95 deletions(-) diff --git a/src/agents/auth-profiles.chutes.test.ts b/src/agents/auth-profiles.chutes.test.ts index c5bf3c7b5..f15d7fab5 100644 --- a/src/agents/auth-profiles.chutes.test.ts +++ b/src/agents/auth-profiles.chutes.test.ts @@ -3,16 +3,15 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; - +import { + type AuthProfileStore, + ensureAuthProfileStore, + resolveApiKeyForProfile, +} from "./auth-profiles.js"; import { CHUTES_TOKEN_ENDPOINT, type ChutesStoredOAuth, } from "./chutes-oauth.js"; -import { - ensureAuthProfileStore, - resolveApiKeyForProfile, - type AuthProfileStore, -} from "./auth-profiles.js"; describe("auth-profiles (chutes)", () => { const previousStateDir = process.env.CLAWDBOT_STATE_DIR; @@ -31,16 +30,23 @@ describe("auth-profiles (chutes)", () => { else process.env.CLAWDBOT_STATE_DIR = previousStateDir; 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; + if (previousPiAgentDir === undefined) + delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; - if (previousChutesClientId === undefined) delete process.env.CHUTES_CLIENT_ID; + if (previousChutesClientId === undefined) + delete process.env.CHUTES_CLIENT_ID; else process.env.CHUTES_CLIENT_ID = previousChutesClientId; }); it("refreshes expired Chutes OAuth credentials", async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-chutes-")); process.env.CLAWDBOT_STATE_DIR = tempDir; - process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent"); + process.env.CLAWDBOT_AGENT_DIR = path.join( + tempDir, + "agents", + "main", + "agent", + ); process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR; const authProfilePath = path.join( @@ -69,7 +75,8 @@ describe("auth-profiles (chutes)", () => { const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); - if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 }); + if (url !== CHUTES_TOKEN_ENDPOINT) + return new Response("not found", { status: 404 }); return new Response( JSON.stringify({ access_token: "at_new", @@ -89,7 +96,9 @@ describe("auth-profiles (chutes)", () => { expect(resolved?.apiKey).toBe("at_new"); expect(fetchSpy).toHaveBeenCalled(); - const persisted = JSON.parse(await fs.readFile(authProfilePath, "utf8")) as { + const persisted = JSON.parse( + await fs.readFile(authProfilePath, "utf8"), + ) as { profiles?: Record; }; expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new"); diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 4c19e17f4..8a7524844 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -15,12 +15,12 @@ import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; import { createSubsystemLogger } from "../logging.js"; import { resolveUserPath } from "../utils.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; +import { type ChutesStoredOAuth, refreshChutesTokens } from "./chutes-oauth.js"; import { readClaudeCliCredentialsCached, readCodexCliCredentialsCached, writeClaudeCliCredentials, } from "./cli-credentials.js"; -import { refreshChutesTokens, type ChutesStoredOAuth } from "./chutes-oauth.js"; import { normalizeProviderId } from "./model-selection.js"; const AUTH_STORE_VERSION = 1; diff --git a/src/agents/chutes-oauth.test.ts b/src/agents/chutes-oauth.test.ts index 7ae7217bc..b1e92ce7d 100644 --- a/src/agents/chutes-oauth.test.ts +++ b/src/agents/chutes-oauth.test.ts @@ -13,9 +13,12 @@ describe("chutes-oauth", () => { const url = String(input); if (url === CHUTES_TOKEN_ENDPOINT) { expect(init?.method).toBe("POST"); - expect(String(init?.headers && (init.headers as Record)["Content-Type"])).toContain( - "application/x-www-form-urlencoded", - ); + expect( + String( + init?.headers && + (init.headers as Record)["Content-Type"], + ), + ).toContain("application/x-www-form-urlencoded"); return new Response( JSON.stringify({ access_token: "at_123", @@ -28,13 +31,17 @@ describe("chutes-oauth", () => { if (url === CHUTES_USERINFO_ENDPOINT) { expect( String( - init?.headers && (init.headers as Record).Authorization, + init?.headers && + (init.headers as Record).Authorization, ), ).toBe("Bearer at_123"); - return new Response(JSON.stringify({ username: "fred", sub: "sub_1" }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ username: "fred", sub: "sub_1" }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); } return new Response("not found", { status: 404 }); }; @@ -55,15 +62,20 @@ describe("chutes-oauth", () => { expect(creds.access).toBe("at_123"); expect(creds.refresh).toBe("rt_123"); expect(creds.email).toBe("fred"); - expect((creds as unknown as { accountId?: string }).accountId).toBe("sub_1"); - expect((creds as unknown as { clientId?: string }).clientId).toBe("cid_test"); + expect((creds as unknown as { accountId?: string }).accountId).toBe( + "sub_1", + ); + expect((creds as unknown as { clientId?: string }).clientId).toBe( + "cid_test", + ); expect(creds.expires).toBe(now + 3600 * 1000 - 5 * 60 * 1000); }); it("refreshes tokens using stored client id and falls back to old refresh token", async () => { const fetchFn: typeof fetch = async (input, init) => { const url = String(input); - if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 }); + if (url !== CHUTES_TOKEN_ENDPOINT) + return new Response("not found", { status: 404 }); expect(init?.method).toBe("POST"); const body = init?.body as URLSearchParams; expect(String(body.get("grant_type"))).toBe("refresh_token"); @@ -96,4 +108,3 @@ describe("chutes-oauth", () => { expect(refreshed.expires).toBe(now + 1800 * 1000 - 5 * 60 * 1000); }); }); - diff --git a/src/agents/chutes-oauth.ts b/src/agents/chutes-oauth.ts index 57037f54c..bf31b3808 100644 --- a/src/agents/chutes-oauth.ts +++ b/src/agents/chutes-oauth.ts @@ -61,7 +61,9 @@ export function parseOAuthCallbackInput( function coerceExpiresAt(expiresInSeconds: number, now: number): number { const value = - now + Math.max(0, Math.floor(expiresInSeconds)) * 1000 - DEFAULT_EXPIRES_BUFFER_MS; + now + + Math.max(0, Math.floor(expiresInSeconds)) * 1000 - + DEFAULT_EXPIRES_BUFFER_MS; return Math.max(value, now + 30_000); } @@ -121,7 +123,8 @@ export async function exchangeChutesCodeForTokens(params: { const refresh = data.refresh_token?.trim(); const expiresIn = data.expires_in ?? 0; - if (!access) throw new Error("Chutes token exchange returned no access_token"); + if (!access) + throw new Error("Chutes token exchange returned no access_token"); if (!refresh) { throw new Error("Chutes token exchange returned no refresh_token"); } @@ -201,4 +204,3 @@ export async function refreshChutesTokens(params: { clientSecret, } as unknown as ChutesStoredOAuth; } - diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index 704950197..bc54dd038 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -32,6 +32,7 @@ import { buildTokenProfileId, validateAnthropicSetupToken, } from "./auth-token.js"; +import { loginChutes } from "./chutes-oauth.js"; import { applyGoogleGeminiModelDefault, GOOGLE_GEMINI_DEFAULT_MODEL, @@ -68,7 +69,6 @@ import { } from "./onboard-auth.js"; import { openUrl } from "./onboard-helpers.js"; import type { AuthChoice } from "./onboard-types.js"; -import { loginChutes } from "./chutes-oauth.js"; import { applyOpenAICodexModelDefault, OPENAI_CODEX_DEFAULT_MODEL, diff --git a/src/commands/chutes-oauth.ts b/src/commands/chutes-oauth.ts index 75fc0fd67..526bbb654 100644 --- a/src/commands/chutes-oauth.ts +++ b/src/commands/chutes-oauth.ts @@ -49,78 +49,74 @@ async function waitForLocalCallback(params: { const port = redirectUrl.port ? Number.parseInt(redirectUrl.port, 10) : 80; const expectedPath = redirectUrl.pathname || "/"; - let server: ReturnType | null = null; - let timeout: NodeJS.Timeout | null = null; - - try { - const resultPromise = new Promise<{ code: string; state: string }>( - (resolve, reject) => { - server = createServer((req, res) => { - try { - const requestUrl = new URL(req.url ?? "/", redirectUrl.origin); - if (requestUrl.pathname !== expectedPath) { - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Not found"); - return; - } - - const code = requestUrl.searchParams.get("code")?.trim(); - const state = requestUrl.searchParams.get("state")?.trim(); - - if (!code) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Missing code"); - return; - } - if (!state || state !== params.expectedState) { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Invalid state"); - return; - } - - res.statusCode = 200; - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end( - [ - "", - "", - "

Chutes OAuth complete

", - "

You can close this window and return to clawdbot.

", - ].join(""), - ); - resolve({ code, state }); - } catch (err) { - reject(err); + return await new Promise<{ code: string; state: string }>( + (resolve, reject) => { + let timeout: NodeJS.Timeout | null = null; + const server = createServer((req, res) => { + try { + const requestUrl = new URL(req.url ?? "/", redirectUrl.origin); + if (requestUrl.pathname !== expectedPath) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Not found"); + return; } - }); - server.once("error", reject); - server.listen(port, hostname, () => { - params.onProgress?.( - `Waiting for OAuth callback on ${redirectUrl.origin}${expectedPath}…`, + const code = requestUrl.searchParams.get("code")?.trim(); + const state = requestUrl.searchParams.get("state")?.trim(); + + if (!code) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Missing code"); + return; + } + if (!state || state !== params.expectedState) { + res.statusCode = 400; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Invalid state"); + return; + } + + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end( + [ + "", + "", + "

Chutes OAuth complete

", + "

You can close this window and return to clawdbot.

", + ].join(""), ); - }); - }, - ); + if (timeout) clearTimeout(timeout); + server.close(); + resolve({ code, state }); + } catch (err) { + if (timeout) clearTimeout(timeout); + server.close(); + reject(err); + } + }); - timeout = setTimeout(() => { - try { - server?.close(); - } catch {} - }, params.timeoutMs); - - return await resultPromise; - } finally { - if (timeout) clearTimeout(timeout); - if (server) { - try { + server.once("error", (err) => { + if (timeout) clearTimeout(timeout); server.close(); - } catch {} - } - } + reject(err); + }); + server.listen(port, hostname, () => { + params.onProgress?.( + `Waiting for OAuth callback on ${redirectUrl.origin}${expectedPath}…`, + ); + }); + + timeout = setTimeout(() => { + try { + server.close(); + } catch {} + reject(new Error("OAuth callback timeout")); + }, params.timeoutMs); + }, + ); } export async function loginChutes(params: { From 3271ff1d6e26d6799250eb6b02297ec4da6fc0f8 Mon Sep 17 00:00:00 2001 From: Friederike Seiler Date: Mon, 12 Jan 2026 01:08:52 +0100 Subject: [PATCH 4/5] Tests: clean chutes fetch spies --- src/agents/auth-profiles.chutes.test.ts | 4 ++-- src/commands/auth-choice.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/agents/auth-profiles.chutes.test.ts b/src/agents/auth-profiles.chutes.test.ts index f15d7fab5..6d48b2b43 100644 --- a/src/agents/auth-profiles.chutes.test.ts +++ b/src/agents/auth-profiles.chutes.test.ts @@ -73,8 +73,8 @@ describe("auth-profiles (chutes)", () => { }; await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`); - const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); + const fetchSpy = vi.fn(async (input: string | URL) => { + const url = typeof input === "string" ? input : input.toString(); if (url !== CHUTES_TOKEN_ENDPOINT) return new Response("not found", { status: 404 }); return new Response( diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 5705e75b9..14da2bef6 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -359,8 +359,8 @@ describe("applyAuthChoice", () => { process.env.SSH_TTY = "1"; process.env.CHUTES_CLIENT_ID = "cid_test"; - const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); + const fetchSpy = vi.fn(async (input: string | URL) => { + const url = typeof input === "string" ? input : input.toString(); if (url === "https://api.chutes.ai/idp/token") { return new Response( JSON.stringify({ From f566e6451f7edb1595d643f87fbfa1e1b9e66e90 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 13 Jan 2026 05:01:08 +0000 Subject: [PATCH 5/5] fix: harden Chutes OAuth flow (#726) (thanks @FrieSei) --- CHANGELOG.md | 1 + src/agents/auth-profiles.ts | 34 +++--- src/agents/chutes-oauth.ts | 10 +- src/agents/pi-embedded-helpers.test.ts | 4 +- src/commands/auth-choice.ts | 151 ++++++++++++------------- src/commands/chutes-oauth.ts | 6 +- src/commands/onboard-auth.ts | 4 +- 7 files changed, 101 insertions(+), 109 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f2f17b3..1b5142a4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Changes - Models/Moonshot: add Kimi K2 0905 + turbo/thinking variants to the preset + docs. (#818 — thanks @mickahouan) - Memory: allow custom OpenAI-compatible embedding endpoints for memory search (remote baseUrl/apiKey/headers). (#819 — thanks @mukhtharcm) +- Auth: add Chutes OAuth (PKCE + refresh + onboarding choice). (#726 — thanks @FrieSei) - Agents: make workspace bootstrap truncation configurable (default 20k) and warn when files are truncated. ### Fixes diff --git a/src/agents/auth-profiles.ts b/src/agents/auth-profiles.ts index 8a7524844..6306132c9 100644 --- a/src/agents/auth-profiles.ts +++ b/src/agents/auth-profiles.ts @@ -15,7 +15,7 @@ import { loadJsonFile, saveJsonFile } from "../infra/json-file.js"; import { createSubsystemLogger } from "../logging.js"; import { resolveUserPath } from "../utils.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js"; -import { type ChutesStoredOAuth, refreshChutesTokens } from "./chutes-oauth.js"; +import { refreshChutesTokens } from "./chutes-oauth.js"; import { readClaudeCliCredentialsCached, readCodexCliCredentialsCached, @@ -68,7 +68,8 @@ export type TokenCredential = { export type OAuthCredential = OAuthCredentials & { type: "oauth"; - provider: OAuthProvider; + provider: string; + clientId?: string; email?: string; }; @@ -172,7 +173,7 @@ async function updateAuthProfileStoreWithLock(params: { } function buildOAuthApiKey( - provider: OAuthProvider, + provider: string, credentials: OAuthCredentials, ): string { const needsProjectId = @@ -187,7 +188,6 @@ function buildOAuthApiKey( async function refreshOAuthTokenWithLock(params: { profileId: string; - provider: OAuthProvider; agentDir?: string; }): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { const authPath = resolveAuthStorePath(params.agentDir); @@ -218,11 +218,11 @@ async function refreshOAuthTokenWithLock(params: { String(cred.provider) === "chutes" ? await (async () => { const newCredentials = await refreshChutesTokens({ - credential: cred as unknown as ChutesStoredOAuth, + credential: cred, }); return { apiKey: newCredentials.access, newCredentials }; })() - : await getOAuthApiKey(cred.provider, oauthCreds); + : await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds); if (!result) return null; store.profiles[params.profileId] = { ...cred, @@ -269,7 +269,7 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null { } entries[key] = { ...typed, - provider: typed.provider ?? (key as OAuthProvider), + provider: String(typed.provider ?? key), } as AuthProfileCredential; } return Object.keys(entries).length > 0 ? entries : null; @@ -336,7 +336,7 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean { if (store.profiles[profileId]) continue; store.profiles[profileId] = { type: "oauth", - provider: provider as OAuthProvider, + provider, ...creds, }; mutated = true; @@ -478,7 +478,7 @@ function syncExternalCliCredentials( const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID]; const shouldSyncCodex = !existingCodex || - existingCodex.provider !== ("openai-codex" as OAuthProvider) || + existingCodex.provider !== "openai-codex" || !isExternalProfileFresh(existingCodex, now); const codexCreds = shouldSyncCodex ? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }) @@ -490,7 +490,7 @@ function syncExternalCliCredentials( // Codex creds don't carry expiry; use file mtime heuristic for freshness. const shouldUpdate = !existingOAuth || - existingOAuth.provider !== ("openai-codex" as unknown as OAuthProvider) || + existingOAuth.provider !== "openai-codex" || existingOAuth.expires <= now || codexCreds.expires > existingOAuth.expires; @@ -535,14 +535,14 @@ export function loadAuthProfileStore(): AuthProfileStore { if (cred.type === "api_key") { store.profiles[profileId] = { type: "api_key", - provider: cred.provider ?? (provider as OAuthProvider), + provider: String(cred.provider ?? provider), key: cred.key, ...(cred.email ? { email: cred.email } : {}), }; } else if (cred.type === "token") { store.profiles[profileId] = { type: "token", - provider: cred.provider ?? (provider as OAuthProvider), + provider: String(cred.provider ?? provider), token: cred.token, ...(typeof cred.expires === "number" ? { expires: cred.expires } @@ -552,7 +552,7 @@ export function loadAuthProfileStore(): AuthProfileStore { } else { store.profiles[profileId] = { type: "oauth", - provider: cred.provider ?? (provider as OAuthProvider), + provider: String(cred.provider ?? provider), access: cred.access, refresh: cred.refresh, expires: cred.expires, @@ -600,14 +600,14 @@ export function ensureAuthProfileStore( if (cred.type === "api_key") { store.profiles[profileId] = { type: "api_key", - provider: cred.provider ?? (provider as OAuthProvider), + provider: String(cred.provider ?? provider), key: cred.key, ...(cred.email ? { email: cred.email } : {}), }; } else if (cred.type === "token") { store.profiles[profileId] = { type: "token", - provider: cred.provider ?? (provider as OAuthProvider), + provider: String(cred.provider ?? provider), token: cred.token, ...(typeof cred.expires === "number" ? { expires: cred.expires } @@ -617,7 +617,7 @@ export function ensureAuthProfileStore( } else { store.profiles[profileId] = { type: "oauth", - provider: cred.provider ?? (provider as OAuthProvider), + provider: String(cred.provider ?? provider), access: cred.access, refresh: cred.refresh, expires: cred.expires, @@ -1231,7 +1231,6 @@ export async function resolveApiKeyForProfile(params: { try { const result = await refreshOAuthTokenWithLock({ profileId, - provider: cred.provider, agentDir: params.agentDir, }); if (!result) return null; @@ -1351,7 +1350,6 @@ async function tryResolveOAuthProfile(params: { const refreshed = await refreshOAuthTokenWithLock({ profileId, - provider: cred.provider, agentDir: params.agentDir, }); if (!refreshed) return null; diff --git a/src/agents/chutes-oauth.ts b/src/agents/chutes-oauth.ts index bf31b3808..b10a6669b 100644 --- a/src/agents/chutes-oauth.ts +++ b/src/agents/chutes-oauth.ts @@ -26,7 +26,6 @@ export type ChutesOAuthAppConfig = { export type ChutesStoredOAuth = OAuthCredentials & { clientId?: string; - clientSecret?: string; }; export function generateChutesPkce(): ChutesPkce { @@ -45,7 +44,7 @@ export function parseOAuthCallbackInput( try { const url = new URL(trimmed); const code = url.searchParams.get("code"); - const state = url.searchParams.get("state") ?? expectedState; + const state = url.searchParams.get("state"); if (!code) return { error: "Missing 'code' parameter in URL" }; if (!state) { return { error: "Missing 'state' parameter. Paste the full URL." }; @@ -138,7 +137,6 @@ export async function exchangeChutesCodeForTokens(params: { email: info?.username, accountId: info?.sub, clientId: params.app.clientId, - clientSecret: params.app.clientSecret, } as unknown as ChutesStoredOAuth; } @@ -162,10 +160,7 @@ export async function refreshChutesTokens(params: { "Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).", ); } - const clientSecret = - params.credential.clientSecret?.trim() ?? - process.env.CHUTES_CLIENT_SECRET?.trim() ?? - undefined; + const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined; const body = new URLSearchParams({ grant_type: "refresh_token", @@ -201,6 +196,5 @@ export async function refreshChutesTokens(params: { refresh: newRefresh || refreshToken, expires: coerceExpiresAt(expiresIn, now), clientId, - clientSecret, } as unknown as ChutesStoredOAuth; } diff --git a/src/agents/pi-embedded-helpers.test.ts b/src/agents/pi-embedded-helpers.test.ts index 40d350ae0..bdf2a7db0 100644 --- a/src/agents/pi-embedded-helpers.test.ts +++ b/src/agents/pi-embedded-helpers.test.ts @@ -68,9 +68,7 @@ describe("buildBootstrapContextFiles", () => { ); expect(result?.content.length).toBeLessThan(long.length); expect(result?.content.startsWith(long.slice(0, 120))).toBe(true); - expect(result?.content.endsWith(long.slice(-expectedTailChars))).toBe( - true, - ); + expect(result?.content.endsWith(long.slice(-expectedTailChars))).toBe(true); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain("TOOLS.md"); expect(warnings[0]).toContain("limit 200"); diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index bc54dd038..56df52234 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -1,8 +1,4 @@ -import { - loginOpenAICodex, - type OAuthCredentials, - type OAuthProvider, -} from "@mariozechner/pi-ai"; +import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai"; import { resolveAgentConfig } from "../agents/agent-scope.js"; import { CLAUDE_CLI_PROFILE_ID, @@ -52,7 +48,6 @@ import { applySyntheticConfig, applySyntheticProviderConfig, applyZaiConfig, - MINIMAX_HOSTED_MODEL_REF, MOONSHOT_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF, SYNTHETIC_DEFAULT_MODEL_REF, @@ -105,6 +100,56 @@ function normalizeApiKeyInput(raw: string): string { const validateApiKeyInput = (value: unknown) => normalizeApiKeyInput(String(value ?? "")).length > 0 ? undefined : "Required"; +const validateRequiredInput = (value: string) => + value.trim().length > 0 ? undefined : "Required"; + +function createVpsAwareOAuthHandlers(params: { + isRemote: boolean; + prompter: WizardPrompter; + runtime: RuntimeEnv; + spin: ReturnType; + localBrowserMessage: string; +}): { + onAuth: (event: { url: string }) => Promise; + onPrompt: (prompt: { + message: string; + placeholder?: string; + }) => Promise; +} { + let manualCodePromise: Promise | undefined; + + return { + onAuth: async ({ url }) => { + if (params.isRemote) { + params.spin.stop("OAuth URL ready"); + params.runtime.log( + `\nOpen this URL in your LOCAL browser:\n\n${url}\n`, + ); + manualCodePromise = params.prompter + .text({ + message: "Paste the redirect URL (or authorization code)", + validate: validateRequiredInput, + }) + .then((value) => String(value)); + return; + } + + params.spin.update(params.localBrowserMessage); + await openUrl(url); + params.runtime.log(`Open: ${url}`); + }, + onPrompt: async (prompt) => { + if (manualCodePromise) return manualCodePromise; + const code = await params.prompter.text({ + message: prompt.message, + placeholder: prompt.placeholder, + validate: validateRequiredInput, + }); + return String(code); + }, + }; +} + function formatApiKeyPreview( raw: string, opts: { head?: number; tail?: number } = {}, @@ -574,43 +619,25 @@ export async function applyAuthChoice(params: { ); const spin = params.prompter.progress("Starting OAuth flow…"); - let manualCodePromise: Promise | undefined; try { + const { onAuth, onPrompt } = createVpsAwareOAuthHandlers({ + isRemote, + prompter: params.prompter, + runtime: params.runtime, + spin, + localBrowserMessage: "Complete sign-in in browser…", + }); + const creds = await loginChutes({ app: { clientId, clientSecret, redirectUri, - scopes: scopes.split(/\\s+/).filter(Boolean), + scopes: scopes.split(/\s+/).filter(Boolean), }, manual: isRemote, - onAuth: async ({ url }) => { - if (isRemote) { - spin.stop("OAuth URL ready"); - params.runtime.log( - `\\nOpen this URL in your LOCAL browser:\\n\\n${url}\\n`, - ); - manualCodePromise = params.prompter - .text({ - message: "Paste the redirect URL (or authorization code)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }) - .then((value) => String(value)); - } else { - spin.update("Complete sign-in in browser…"); - await openUrl(url); - params.runtime.log(`Open: ${url}`); - } - }, - onPrompt: async (prompt) => { - if (manualCodePromise) return manualCodePromise; - const code = await params.prompter.text({ - message: prompt.message, - placeholder: prompt.placeholder, - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - return String(code); - }, + onAuth, + onPrompt, onProgress: (msg) => spin.update(msg), }); @@ -618,11 +645,7 @@ export async function applyAuthChoice(params: { const email = creds.email?.trim() || "default"; const profileId = `chutes:${email}`; - await writeOAuthCredentials( - "chutes" as unknown as OAuthProvider, - creds, - params.agentDir, - ); + await writeOAuthCredentials("chutes", creds, params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId, provider: "chutes", @@ -637,7 +660,7 @@ export async function applyAuthChoice(params: { "Verify CHUTES_CLIENT_ID (and CHUTES_CLIENT_SECRET if required).", `Verify the OAuth app redirect URI includes: ${redirectUri}`, "Chutes docs: https://chutes.ai/docs/sign-in-with-chutes/overview", - ].join("\\n"), + ].join("\n"), "OAuth help", ); } @@ -658,47 +681,23 @@ export async function applyAuthChoice(params: { "OpenAI Codex OAuth", ); const spin = params.prompter.progress("Starting OAuth flow…"); - let manualCodePromise: Promise | undefined; try { + const { onAuth, onPrompt } = createVpsAwareOAuthHandlers({ + isRemote, + prompter: params.prompter, + runtime: params.runtime, + spin, + localBrowserMessage: "Complete sign-in in browser…", + }); + const creds = await loginOpenAICodex({ - onAuth: async ({ url }) => { - if (isRemote) { - spin.stop("OAuth URL ready"); - params.runtime.log( - `\nOpen this URL in your LOCAL browser:\n\n${url}\n`, - ); - manualCodePromise = params.prompter - .text({ - message: "Paste the redirect URL (or authorization code)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }) - .then((value) => String(value)); - } else { - spin.update("Complete sign-in in browser…"); - await openUrl(url); - params.runtime.log(`Open: ${url}`); - } - }, - onPrompt: async (prompt) => { - if (manualCodePromise) { - return manualCodePromise; - } - const code = await params.prompter.text({ - message: prompt.message, - placeholder: prompt.placeholder, - validate: (value) => (value?.trim() ? undefined : "Required"), - }); - return String(code); - }, + onAuth, + onPrompt, onProgress: (msg) => spin.update(msg), }); spin.stop("OpenAI OAuth complete"); if (creds) { - await writeOAuthCredentials( - "openai-codex" as unknown as OAuthProvider, - creds, - params.agentDir, - ); + await writeOAuthCredentials("openai-codex", creds, params.agentDir); nextConfig = applyAuthProfileConfig(nextConfig, { profileId: "openai-codex:default", provider: "openai-codex", diff --git a/src/commands/chutes-oauth.ts b/src/commands/chutes-oauth.ts index 526bbb654..8dcea1532 100644 --- a/src/commands/chutes-oauth.ts +++ b/src/commands/chutes-oauth.ts @@ -1,9 +1,11 @@ +import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import type { OAuthCredentials } from "@mariozechner/pi-ai"; import type { ChutesOAuthAppConfig } from "../agents/chutes-oauth.js"; import { + CHUTES_AUTHORIZE_ENDPOINT, exchangeChutesCodeForTokens, generateChutesPkce, parseOAuthCallbackInput, @@ -30,7 +32,7 @@ function buildAuthorizeUrl(params: { code_challenge: params.challenge, code_challenge_method: "S256", }); - return `https://api.chutes.ai/idp/authorize?${qs.toString()}`; + return `${CHUTES_AUTHORIZE_ENDPOINT}?${qs.toString()}`; } async function waitForLocalCallback(params: { @@ -129,7 +131,7 @@ export async function loginChutes(params: { fetchFn?: typeof fetch; }): Promise { const { verifier, challenge } = generateChutesPkce(); - const state = verifier; + const state = randomBytes(16).toString("hex"); const timeoutMs = params.timeoutMs ?? 3 * 60 * 1000; const url = buildAuthorizeUrl({ diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 0e593d988..39664fec4 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -1,4 +1,4 @@ -import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai"; +import type { OAuthCredentials } from "@mariozechner/pi-ai"; import { resolveDefaultAgentDir } from "../agents/agent-scope.js"; import { upsertAuthProfile } from "../agents/auth-profiles.js"; import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js"; @@ -103,7 +103,7 @@ function buildMoonshotModelDefinition(): ModelDefinitionConfig { } export async function writeOAuthCredentials( - provider: OAuthProvider, + provider: string, creds: OAuthCredentials, agentDir?: string, ): Promise {