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"