Chores: fix chutes oauth build

This commit is contained in:
Friederike Seiler
2026-01-11 16:01:53 +01:00
committed by Peter Steinberger
parent 0aba911912
commit 0efcfc0864
6 changed files with 113 additions and 95 deletions

View File

@@ -3,16 +3,15 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import {
type AuthProfileStore,
ensureAuthProfileStore,
resolveApiKeyForProfile,
} from "./auth-profiles.js";
import { import {
CHUTES_TOKEN_ENDPOINT, CHUTES_TOKEN_ENDPOINT,
type ChutesStoredOAuth, type ChutesStoredOAuth,
} from "./chutes-oauth.js"; } from "./chutes-oauth.js";
import {
ensureAuthProfileStore,
resolveApiKeyForProfile,
type AuthProfileStore,
} from "./auth-profiles.js";
describe("auth-profiles (chutes)", () => { describe("auth-profiles (chutes)", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR; const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
@@ -31,16 +30,23 @@ describe("auth-profiles (chutes)", () => {
else process.env.CLAWDBOT_STATE_DIR = previousStateDir; else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR; if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir; 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; 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; else process.env.CHUTES_CLIENT_ID = previousChutesClientId;
}); });
it("refreshes expired Chutes OAuth credentials", async () => { it("refreshes expired Chutes OAuth credentials", async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-chutes-")); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-chutes-"));
process.env.CLAWDBOT_STATE_DIR = tempDir; 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; process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
const authProfilePath = path.join( const authProfilePath = path.join(
@@ -69,7 +75,8 @@ describe("auth-profiles (chutes)", () => {
const fetchSpy = vi.fn(async (input: RequestInfo | URL) => { const fetchSpy = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input); 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( return new Response(
JSON.stringify({ JSON.stringify({
access_token: "at_new", access_token: "at_new",
@@ -89,7 +96,9 @@ describe("auth-profiles (chutes)", () => {
expect(resolved?.apiKey).toBe("at_new"); expect(resolved?.apiKey).toBe("at_new");
expect(fetchSpy).toHaveBeenCalled(); 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<string, { access?: string }>; profiles?: Record<string, { access?: string }>;
}; };
expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new"); expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new");

View File

@@ -15,12 +15,12 @@ import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
import { createSubsystemLogger } from "../logging.js"; import { createSubsystemLogger } from "../logging.js";
import { resolveUserPath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { resolveClawdbotAgentDir } from "./agent-paths.js"; import { resolveClawdbotAgentDir } from "./agent-paths.js";
import { type ChutesStoredOAuth, refreshChutesTokens } from "./chutes-oauth.js";
import { import {
readClaudeCliCredentialsCached, readClaudeCliCredentialsCached,
readCodexCliCredentialsCached, readCodexCliCredentialsCached,
writeClaudeCliCredentials, writeClaudeCliCredentials,
} from "./cli-credentials.js"; } from "./cli-credentials.js";
import { refreshChutesTokens, type ChutesStoredOAuth } from "./chutes-oauth.js";
import { normalizeProviderId } from "./model-selection.js"; import { normalizeProviderId } from "./model-selection.js";
const AUTH_STORE_VERSION = 1; const AUTH_STORE_VERSION = 1;

View File

@@ -13,9 +13,12 @@ describe("chutes-oauth", () => {
const url = String(input); const url = String(input);
if (url === CHUTES_TOKEN_ENDPOINT) { if (url === CHUTES_TOKEN_ENDPOINT) {
expect(init?.method).toBe("POST"); expect(init?.method).toBe("POST");
expect(String(init?.headers && (init.headers as Record<string, string>)["Content-Type"])).toContain( expect(
"application/x-www-form-urlencoded", String(
); init?.headers &&
(init.headers as Record<string, string>)["Content-Type"],
),
).toContain("application/x-www-form-urlencoded");
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
access_token: "at_123", access_token: "at_123",
@@ -28,13 +31,17 @@ describe("chutes-oauth", () => {
if (url === CHUTES_USERINFO_ENDPOINT) { if (url === CHUTES_USERINFO_ENDPOINT) {
expect( expect(
String( String(
init?.headers && (init.headers as Record<string, string>).Authorization, init?.headers &&
(init.headers as Record<string, string>).Authorization,
), ),
).toBe("Bearer at_123"); ).toBe("Bearer at_123");
return new Response(JSON.stringify({ username: "fred", sub: "sub_1" }), { return new Response(
status: 200, JSON.stringify({ username: "fred", sub: "sub_1" }),
headers: { "Content-Type": "application/json" }, {
}); status: 200,
headers: { "Content-Type": "application/json" },
},
);
} }
return new Response("not found", { status: 404 }); return new Response("not found", { status: 404 });
}; };
@@ -55,15 +62,20 @@ describe("chutes-oauth", () => {
expect(creds.access).toBe("at_123"); expect(creds.access).toBe("at_123");
expect(creds.refresh).toBe("rt_123"); expect(creds.refresh).toBe("rt_123");
expect(creds.email).toBe("fred"); expect(creds.email).toBe("fred");
expect((creds as unknown as { accountId?: string }).accountId).toBe("sub_1"); expect((creds as unknown as { accountId?: string }).accountId).toBe(
expect((creds as unknown as { clientId?: string }).clientId).toBe("cid_test"); "sub_1",
);
expect((creds as unknown as { clientId?: string }).clientId).toBe(
"cid_test",
);
expect(creds.expires).toBe(now + 3600 * 1000 - 5 * 60 * 1000); 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 () => { it("refreshes tokens using stored client id and falls back to old refresh token", async () => {
const fetchFn: typeof fetch = async (input, init) => { const fetchFn: typeof fetch = async (input, init) => {
const url = String(input); 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"); expect(init?.method).toBe("POST");
const body = init?.body as URLSearchParams; const body = init?.body as URLSearchParams;
expect(String(body.get("grant_type"))).toBe("refresh_token"); 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); expect(refreshed.expires).toBe(now + 1800 * 1000 - 5 * 60 * 1000);
}); });
}); });

View File

@@ -61,7 +61,9 @@ export function parseOAuthCallbackInput(
function coerceExpiresAt(expiresInSeconds: number, now: number): number { function coerceExpiresAt(expiresInSeconds: number, now: number): number {
const value = 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); return Math.max(value, now + 30_000);
} }
@@ -121,7 +123,8 @@ export async function exchangeChutesCodeForTokens(params: {
const refresh = data.refresh_token?.trim(); const refresh = data.refresh_token?.trim();
const expiresIn = data.expires_in ?? 0; 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) { if (!refresh) {
throw new Error("Chutes token exchange returned no refresh_token"); throw new Error("Chutes token exchange returned no refresh_token");
} }
@@ -201,4 +204,3 @@ export async function refreshChutesTokens(params: {
clientSecret, clientSecret,
} as unknown as ChutesStoredOAuth; } as unknown as ChutesStoredOAuth;
} }

View File

@@ -32,6 +32,7 @@ import {
buildTokenProfileId, buildTokenProfileId,
validateAnthropicSetupToken, validateAnthropicSetupToken,
} from "./auth-token.js"; } from "./auth-token.js";
import { loginChutes } from "./chutes-oauth.js";
import { import {
applyGoogleGeminiModelDefault, applyGoogleGeminiModelDefault,
GOOGLE_GEMINI_DEFAULT_MODEL, GOOGLE_GEMINI_DEFAULT_MODEL,
@@ -68,7 +69,6 @@ import {
} from "./onboard-auth.js"; } from "./onboard-auth.js";
import { openUrl } from "./onboard-helpers.js"; import { openUrl } from "./onboard-helpers.js";
import type { AuthChoice } from "./onboard-types.js"; import type { AuthChoice } from "./onboard-types.js";
import { loginChutes } from "./chutes-oauth.js";
import { import {
applyOpenAICodexModelDefault, applyOpenAICodexModelDefault,
OPENAI_CODEX_DEFAULT_MODEL, OPENAI_CODEX_DEFAULT_MODEL,

View File

@@ -49,78 +49,74 @@ async function waitForLocalCallback(params: {
const port = redirectUrl.port ? Number.parseInt(redirectUrl.port, 10) : 80; const port = redirectUrl.port ? Number.parseInt(redirectUrl.port, 10) : 80;
const expectedPath = redirectUrl.pathname || "/"; const expectedPath = redirectUrl.pathname || "/";
let server: ReturnType<typeof createServer> | null = null; return await new Promise<{ code: string; state: string }>(
let timeout: NodeJS.Timeout | null = null; (resolve, reject) => {
let timeout: NodeJS.Timeout | null = null;
try { const server = createServer((req, res) => {
const resultPromise = new Promise<{ code: string; state: string }>( try {
(resolve, reject) => { const requestUrl = new URL(req.url ?? "/", redirectUrl.origin);
server = createServer((req, res) => { if (requestUrl.pathname !== expectedPath) {
try { res.statusCode = 404;
const requestUrl = new URL(req.url ?? "/", redirectUrl.origin); res.setHeader("Content-Type", "text/plain; charset=utf-8");
if (requestUrl.pathname !== expectedPath) { res.end("Not found");
res.statusCode = 404; return;
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(
[
"<!doctype html>",
"<html><head><meta charset='utf-8' /></head>",
"<body><h2>Chutes OAuth complete</h2>",
"<p>You can close this window and return to clawdbot.</p></body></html>",
].join(""),
);
resolve({ code, state });
} catch (err) {
reject(err);
} }
});
server.once("error", reject); const code = requestUrl.searchParams.get("code")?.trim();
server.listen(port, hostname, () => { const state = requestUrl.searchParams.get("state")?.trim();
params.onProgress?.(
`Waiting for OAuth callback on ${redirectUrl.origin}${expectedPath}`, 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(
[
"<!doctype html>",
"<html><head><meta charset='utf-8' /></head>",
"<body><h2>Chutes OAuth complete</h2>",
"<p>You can close this window and return to clawdbot.</p></body></html>",
].join(""),
); );
}); if (timeout) clearTimeout(timeout);
}, server.close();
); resolve({ code, state });
} catch (err) {
if (timeout) clearTimeout(timeout);
server.close();
reject(err);
}
});
timeout = setTimeout(() => { server.once("error", (err) => {
try { if (timeout) clearTimeout(timeout);
server?.close();
} catch {}
}, params.timeoutMs);
return await resultPromise;
} finally {
if (timeout) clearTimeout(timeout);
if (server) {
try {
server.close(); 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: { export async function loginChutes(params: {