Auth: add Chutes OAuth

This commit is contained in:
Friederike Seiler
2026-01-11 15:06:54 +01:00
committed by Peter Steinberger
parent 9b44c80b30
commit 4efb5cc18e
14 changed files with 1021 additions and 2 deletions

View File

@@ -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<string, { access?: string }>;
};
expect(persisted.profiles?.["chutes:default"]?.access).toBe("at_new");
});
});

View File

@@ -20,6 +20,7 @@ import {
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;
@@ -212,7 +213,16 @@ async function refreshOAuthTokenWithLock(params: {
const oauthCreds: Record<string, OAuthCredentials> = { const oauthCreds: Record<string, OAuthCredentials> = {
[cred.provider]: cred, [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; if (!result) return null;
store.profiles[params.profileId] = { store.profiles[params.profileId] = {
...cred, ...cred,

View File

@@ -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<string, string>)["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<string, string>).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<typeof refreshChutesTokens>[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);
});
});

204
src/agents/chutes-oauth.ts Normal file
View File

@@ -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<ChutesUserInfo | null> {
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<ChutesStoredOAuth> {
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<ChutesStoredOAuth> {
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;
}

View File

@@ -125,6 +125,10 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY"); return pick("ANTHROPIC_OAUTH_TOKEN") ?? pick("ANTHROPIC_API_KEY");
} }
if (normalized === "chutes") {
return pick("CHUTES_OAUTH_TOKEN") ?? pick("CHUTES_API_KEY");
}
if (normalized === "zai") { if (normalized === "zai") {
return pick("ZAI_API_KEY") ?? pick("Z_AI_API_KEY"); return pick("ZAI_API_KEY") ?? pick("Z_AI_API_KEY");
} }

View File

@@ -264,7 +264,7 @@ export function buildProgram() {
.option("--mode <mode>", "Wizard mode: local|remote") .option("--mode <mode>", "Wizard mode: local|remote")
.option( .option(
"--auth-choice <choice>", "--auth-choice <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( .option(
"--token-provider <id>", "--token-provider <id>",
@@ -331,6 +331,7 @@ export function buildProgram() {
| "setup-token" | "setup-token"
| "claude-cli" | "claude-cli"
| "token" | "token"
| "chutes"
| "openai-codex" | "openai-codex"
| "openai-api-key" | "openai-api-key"
| "openrouter-api-key" | "openrouter-api-key"

View File

@@ -118,4 +118,16 @@ describe("buildAuthChoiceOptions", () => {
expect(options.some((opt) => opt.value === "synthetic-api-key")).toBe(true); 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);
});
}); });

View File

@@ -171,6 +171,7 @@ export function buildAuthChoiceOptions(params: {
value: "openai-codex", value: "openai-codex",
label: "OpenAI Codex (ChatGPT OAuth)", 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: "openai-api-key", label: "OpenAI API key" });
options.push({ value: "openrouter-api-key", label: "OpenRouter API key" }); options.push({ value: "openrouter-api-key", label: "OpenRouter API key" });
options.push({ value: "moonshot-api-key", label: "Moonshot AI API key" }); options.push({ value: "moonshot-api-key", label: "Moonshot AI API key" });

View File

@@ -19,9 +19,13 @@ describe("applyAuthChoice", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR; const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR; const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_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; let tempStateDir: string | null = null;
afterEach(async () => { afterEach(async () => {
vi.unstubAllGlobals();
if (tempStateDir) { if (tempStateDir) {
await fs.rm(tempStateDir, { recursive: true, force: true }); await fs.rm(tempStateDir, { recursive: true, force: true });
tempStateDir = null; tempStateDir = null;
@@ -41,6 +45,21 @@ describe("applyAuthChoice", () => {
} else { } else {
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir; 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 () => { 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.config.models?.providers?.["opencode-zen"]).toBeUndefined();
expect(result.agentModelOverride).toBe("opencode/claude-opus-4-5"); 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<string, { key?: string }>;
};
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",
});
});
}); });

View File

@@ -68,6 +68,7 @@ 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,
@@ -536,6 +537,110 @@ export async function applyAuthChoice(params: {
agentModelOverride = MOONSHOT_DEFAULT_MODEL_REF; agentModelOverride = MOONSHOT_DEFAULT_MODEL_REF;
await noteAgentModel(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<string> | 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") { } else if (params.authChoice === "openai-codex") {
const isRemote = isRemoteEnvironment(); const isRemote = isRemoteEnvironment();
await params.prompter.note( await params.prompter.note(
@@ -1060,6 +1165,8 @@ export function resolvePreferredProviderForAuthChoice(
case "openai-codex": case "openai-codex":
case "codex-cli": case "codex-cli":
return "openai-codex"; return "openai-codex";
case "chutes":
return "chutes";
case "openai-api-key": case "openai-api-key":
return "openai"; return "openai";
case "openrouter-api-key": case "openrouter-api-key":

View File

@@ -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<number> {
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");
});
});

View File

@@ -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<typeof createServer> | 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(
[
"<!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);
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<void>;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
onProgress?: (message: string) => void;
fetchFn?: typeof fetch;
}): Promise<OAuthCredentials> {
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,
});
}

View File

@@ -419,6 +419,7 @@ export async function runNonInteractiveOnboarding(
} else if ( } else if (
authChoice === "token" || authChoice === "token" ||
authChoice === "oauth" || authChoice === "oauth" ||
authChoice === "chutes" ||
authChoice === "openai-codex" || authChoice === "openai-codex" ||
authChoice === "antigravity" authChoice === "antigravity"
) { ) {

View File

@@ -8,6 +8,7 @@ export type AuthChoice =
| "setup-token" | "setup-token"
| "claude-cli" | "claude-cli"
| "token" | "token"
| "chutes"
| "openai-codex" | "openai-codex"
| "openai-api-key" | "openai-api-key"
| "openrouter-api-key" | "openrouter-api-key"