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: {