Merge pull request #726 from FrieSei/feature/chutes-oauth
Auth: add Chutes OAuth
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
### Changes
|
||||
- Models/Moonshot: add Kimi K2 0905 + turbo/thinking variants to the preset + docs. (#818 — thanks @mickahouan)
|
||||
- Memory: allow custom OpenAI-compatible embedding endpoints for memory search (remote baseUrl/apiKey/headers). (#819 — thanks @mukhtharcm)
|
||||
- Auth: add Chutes OAuth (PKCE + refresh + onboarding choice). (#726 — thanks @FrieSei)
|
||||
- Agents: make workspace bootstrap truncation configurable (default 20k) and warn when files are truncated.
|
||||
|
||||
### Fixes
|
||||
|
||||
106
src/agents/auth-profiles.chutes.test.ts
Normal file
106
src/agents/auth-profiles.chutes.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
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 {
|
||||
type AuthProfileStore,
|
||||
ensureAuthProfileStore,
|
||||
resolveApiKeyForProfile,
|
||||
} from "./auth-profiles.js";
|
||||
import {
|
||||
CHUTES_TOKEN_ENDPOINT,
|
||||
type ChutesStoredOAuth,
|
||||
} from "./chutes-oauth.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: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (url !== CHUTES_TOKEN_ENDPOINT)
|
||||
return new Response("not found", { status: 404 });
|
||||
return new Response(
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
|
||||
import { createSubsystemLogger } from "../logging.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveClawdbotAgentDir } from "./agent-paths.js";
|
||||
import { refreshChutesTokens } from "./chutes-oauth.js";
|
||||
import {
|
||||
readClaudeCliCredentialsCached,
|
||||
readCodexCliCredentialsCached,
|
||||
@@ -67,7 +68,8 @@ export type TokenCredential = {
|
||||
|
||||
export type OAuthCredential = OAuthCredentials & {
|
||||
type: "oauth";
|
||||
provider: OAuthProvider;
|
||||
provider: string;
|
||||
clientId?: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
@@ -171,7 +173,7 @@ async function updateAuthProfileStoreWithLock(params: {
|
||||
}
|
||||
|
||||
function buildOAuthApiKey(
|
||||
provider: OAuthProvider,
|
||||
provider: string,
|
||||
credentials: OAuthCredentials,
|
||||
): string {
|
||||
const needsProjectId =
|
||||
@@ -186,7 +188,6 @@ function buildOAuthApiKey(
|
||||
|
||||
async function refreshOAuthTokenWithLock(params: {
|
||||
profileId: string;
|
||||
provider: OAuthProvider;
|
||||
agentDir?: string;
|
||||
}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
|
||||
const authPath = resolveAuthStorePath(params.agentDir);
|
||||
@@ -212,7 +213,16 @@ async function refreshOAuthTokenWithLock(params: {
|
||||
const oauthCreds: Record<string, OAuthCredentials> = {
|
||||
[cred.provider]: cred,
|
||||
};
|
||||
const result = await getOAuthApiKey(cred.provider, oauthCreds);
|
||||
|
||||
const result =
|
||||
String(cred.provider) === "chutes"
|
||||
? await (async () => {
|
||||
const newCredentials = await refreshChutesTokens({
|
||||
credential: cred,
|
||||
});
|
||||
return { apiKey: newCredentials.access, newCredentials };
|
||||
})()
|
||||
: await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds);
|
||||
if (!result) return null;
|
||||
store.profiles[params.profileId] = {
|
||||
...cred,
|
||||
@@ -259,7 +269,7 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
|
||||
}
|
||||
entries[key] = {
|
||||
...typed,
|
||||
provider: typed.provider ?? (key as OAuthProvider),
|
||||
provider: String(typed.provider ?? key),
|
||||
} as AuthProfileCredential;
|
||||
}
|
||||
return Object.keys(entries).length > 0 ? entries : null;
|
||||
@@ -326,7 +336,7 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
if (store.profiles[profileId]) continue;
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider: provider as OAuthProvider,
|
||||
provider,
|
||||
...creds,
|
||||
};
|
||||
mutated = true;
|
||||
@@ -468,7 +478,7 @@ function syncExternalCliCredentials(
|
||||
const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID];
|
||||
const shouldSyncCodex =
|
||||
!existingCodex ||
|
||||
existingCodex.provider !== ("openai-codex" as OAuthProvider) ||
|
||||
existingCodex.provider !== "openai-codex" ||
|
||||
!isExternalProfileFresh(existingCodex, now);
|
||||
const codexCreds = shouldSyncCodex
|
||||
? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
|
||||
@@ -480,7 +490,7 @@ function syncExternalCliCredentials(
|
||||
// Codex creds don't carry expiry; use file mtime heuristic for freshness.
|
||||
const shouldUpdate =
|
||||
!existingOAuth ||
|
||||
existingOAuth.provider !== ("openai-codex" as unknown as OAuthProvider) ||
|
||||
existingOAuth.provider !== "openai-codex" ||
|
||||
existingOAuth.expires <= now ||
|
||||
codexCreds.expires > existingOAuth.expires;
|
||||
|
||||
@@ -525,14 +535,14 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
||||
if (cred.type === "api_key") {
|
||||
store.profiles[profileId] = {
|
||||
type: "api_key",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
provider: String(cred.provider ?? provider),
|
||||
key: cred.key,
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else if (cred.type === "token") {
|
||||
store.profiles[profileId] = {
|
||||
type: "token",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
provider: String(cred.provider ?? provider),
|
||||
token: cred.token,
|
||||
...(typeof cred.expires === "number"
|
||||
? { expires: cred.expires }
|
||||
@@ -542,7 +552,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
||||
} else {
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
provider: String(cred.provider ?? provider),
|
||||
access: cred.access,
|
||||
refresh: cred.refresh,
|
||||
expires: cred.expires,
|
||||
@@ -590,14 +600,14 @@ export function ensureAuthProfileStore(
|
||||
if (cred.type === "api_key") {
|
||||
store.profiles[profileId] = {
|
||||
type: "api_key",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
provider: String(cred.provider ?? provider),
|
||||
key: cred.key,
|
||||
...(cred.email ? { email: cred.email } : {}),
|
||||
};
|
||||
} else if (cred.type === "token") {
|
||||
store.profiles[profileId] = {
|
||||
type: "token",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
provider: String(cred.provider ?? provider),
|
||||
token: cred.token,
|
||||
...(typeof cred.expires === "number"
|
||||
? { expires: cred.expires }
|
||||
@@ -607,7 +617,7 @@ export function ensureAuthProfileStore(
|
||||
} else {
|
||||
store.profiles[profileId] = {
|
||||
type: "oauth",
|
||||
provider: cred.provider ?? (provider as OAuthProvider),
|
||||
provider: String(cred.provider ?? provider),
|
||||
access: cred.access,
|
||||
refresh: cred.refresh,
|
||||
expires: cred.expires,
|
||||
@@ -1221,7 +1231,6 @@ export async function resolveApiKeyForProfile(params: {
|
||||
try {
|
||||
const result = await refreshOAuthTokenWithLock({
|
||||
profileId,
|
||||
provider: cred.provider,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (!result) return null;
|
||||
@@ -1341,7 +1350,6 @@ async function tryResolveOAuthProfile(params: {
|
||||
|
||||
const refreshed = await refreshOAuthTokenWithLock({
|
||||
profileId,
|
||||
provider: cred.provider,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
if (!refreshed) return null;
|
||||
|
||||
110
src/agents/chutes-oauth.test.ts
Normal file
110
src/agents/chutes-oauth.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
200
src/agents/chutes-oauth.ts
Normal file
200
src/agents/chutes-oauth.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
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;
|
||||
};
|
||||
|
||||
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");
|
||||
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,
|
||||
} 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 = 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,
|
||||
} as unknown as ChutesStoredOAuth;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ export function buildProgram() {
|
||||
.option("--mode <mode>", "Wizard mode: local|remote")
|
||||
.option(
|
||||
"--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(
|
||||
"--token-provider <id>",
|
||||
@@ -331,6 +331,7 @@ export function buildProgram() {
|
||||
| "setup-token"
|
||||
| "claude-cli"
|
||||
| "token"
|
||||
| "chutes"
|
||||
| "openai-codex"
|
||||
| "openai-api-key"
|
||||
| "openrouter-api-key"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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<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: string | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
loginOpenAICodex,
|
||||
type OAuthCredentials,
|
||||
type OAuthProvider,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import { resolveAgentConfig } from "../agents/agent-scope.js";
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
@@ -32,6 +28,7 @@ import {
|
||||
buildTokenProfileId,
|
||||
validateAnthropicSetupToken,
|
||||
} from "./auth-token.js";
|
||||
import { loginChutes } from "./chutes-oauth.js";
|
||||
import {
|
||||
applyGoogleGeminiModelDefault,
|
||||
GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||
@@ -103,6 +100,56 @@ function normalizeApiKeyInput(raw: string): string {
|
||||
const validateApiKeyInput = (value: unknown) =>
|
||||
normalizeApiKeyInput(String(value ?? "")).length > 0 ? undefined : "Required";
|
||||
|
||||
const validateRequiredInput = (value: string) =>
|
||||
value.trim().length > 0 ? undefined : "Required";
|
||||
|
||||
function createVpsAwareOAuthHandlers(params: {
|
||||
isRemote: boolean;
|
||||
prompter: WizardPrompter;
|
||||
runtime: RuntimeEnv;
|
||||
spin: ReturnType<WizardPrompter["progress"]>;
|
||||
localBrowserMessage: string;
|
||||
}): {
|
||||
onAuth: (event: { url: string }) => Promise<void>;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
placeholder?: string;
|
||||
}) => Promise<string>;
|
||||
} {
|
||||
let manualCodePromise: Promise<string> | undefined;
|
||||
|
||||
return {
|
||||
onAuth: async ({ url }) => {
|
||||
if (params.isRemote) {
|
||||
params.spin.stop("OAuth URL ready");
|
||||
params.runtime.log(
|
||||
`\nOpen this URL in your LOCAL browser:\n\n${url}\n`,
|
||||
);
|
||||
manualCodePromise = params.prompter
|
||||
.text({
|
||||
message: "Paste the redirect URL (or authorization code)",
|
||||
validate: validateRequiredInput,
|
||||
})
|
||||
.then((value) => String(value));
|
||||
return;
|
||||
}
|
||||
|
||||
params.spin.update(params.localBrowserMessage);
|
||||
await openUrl(url);
|
||||
params.runtime.log(`Open: ${url}`);
|
||||
},
|
||||
onPrompt: async (prompt) => {
|
||||
if (manualCodePromise) return manualCodePromise;
|
||||
const code = await params.prompter.text({
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder,
|
||||
validate: validateRequiredInput,
|
||||
});
|
||||
return String(code);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatApiKeyPreview(
|
||||
raw: string,
|
||||
opts: { head?: number; tail?: number } = {},
|
||||
@@ -535,6 +582,88 @@ 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…");
|
||||
try {
|
||||
const { onAuth, onPrompt } = createVpsAwareOAuthHandlers({
|
||||
isRemote,
|
||||
prompter: params.prompter,
|
||||
runtime: params.runtime,
|
||||
spin,
|
||||
localBrowserMessage: "Complete sign-in in browser…",
|
||||
});
|
||||
|
||||
const creds = await loginChutes({
|
||||
app: {
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
scopes: scopes.split(/\s+/).filter(Boolean),
|
||||
},
|
||||
manual: isRemote,
|
||||
onAuth,
|
||||
onPrompt,
|
||||
onProgress: (msg) => spin.update(msg),
|
||||
});
|
||||
|
||||
spin.stop("Chutes OAuth complete");
|
||||
const email = creds.email?.trim() || "default";
|
||||
const profileId = `chutes:${email}`;
|
||||
|
||||
await writeOAuthCredentials("chutes", 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(
|
||||
@@ -552,47 +681,23 @@ export async function applyAuthChoice(params: {
|
||||
"OpenAI Codex OAuth",
|
||||
);
|
||||
const spin = params.prompter.progress("Starting OAuth flow…");
|
||||
let manualCodePromise: Promise<string> | undefined;
|
||||
try {
|
||||
const { onAuth, onPrompt } = createVpsAwareOAuthHandlers({
|
||||
isRemote,
|
||||
prompter: params.prompter,
|
||||
runtime: params.runtime,
|
||||
spin,
|
||||
localBrowserMessage: "Complete sign-in in browser…",
|
||||
});
|
||||
|
||||
const creds = await loginOpenAICodex({
|
||||
onAuth: async ({ url }) => {
|
||||
if (isRemote) {
|
||||
spin.stop("OAuth URL ready");
|
||||
params.runtime.log(
|
||||
`\nOpen this URL in your LOCAL browser:\n\n${url}\n`,
|
||||
);
|
||||
manualCodePromise = params.prompter
|
||||
.text({
|
||||
message: "Paste the redirect URL (or authorization code)",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
})
|
||||
.then((value) => String(value));
|
||||
} else {
|
||||
spin.update("Complete sign-in in browser…");
|
||||
await openUrl(url);
|
||||
params.runtime.log(`Open: ${url}`);
|
||||
}
|
||||
},
|
||||
onPrompt: async (prompt) => {
|
||||
if (manualCodePromise) {
|
||||
return manualCodePromise;
|
||||
}
|
||||
const code = await params.prompter.text({
|
||||
message: prompt.message,
|
||||
placeholder: prompt.placeholder,
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
return String(code);
|
||||
},
|
||||
onAuth,
|
||||
onPrompt,
|
||||
onProgress: (msg) => spin.update(msg),
|
||||
});
|
||||
spin.stop("OpenAI OAuth complete");
|
||||
if (creds) {
|
||||
await writeOAuthCredentials(
|
||||
"openai-codex" as unknown as OAuthProvider,
|
||||
creds,
|
||||
params.agentDir,
|
||||
);
|
||||
await writeOAuthCredentials("openai-codex", creds, params.agentDir);
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "openai-codex:default",
|
||||
provider: "openai-codex",
|
||||
@@ -1059,6 +1164,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":
|
||||
|
||||
112
src/commands/chutes-oauth.test.ts
Normal file
112
src/commands/chutes-oauth.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
186
src/commands/chutes-oauth.ts
Normal file
186
src/commands/chutes-oauth.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
|
||||
import type { ChutesOAuthAppConfig } from "../agents/chutes-oauth.js";
|
||||
import {
|
||||
CHUTES_AUTHORIZE_ENDPOINT,
|
||||
exchangeChutesCodeForTokens,
|
||||
generateChutesPkce,
|
||||
parseOAuthCallbackInput,
|
||||
} 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 `${CHUTES_AUTHORIZE_ENDPOINT}?${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 || "/";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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(""),
|
||||
);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
server.close();
|
||||
resolve({ code, state });
|
||||
} catch (err) {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
server.close();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
server.once("error", (err) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
server.close();
|
||||
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: {
|
||||
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 = randomBytes(16).toString("hex");
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
|
||||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import { resolveDefaultAgentDir } from "../agents/agent-scope.js";
|
||||
import { upsertAuthProfile } from "../agents/auth-profiles.js";
|
||||
import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js";
|
||||
@@ -103,7 +103,7 @@ function buildMoonshotModelDefinition(): ModelDefinitionConfig {
|
||||
}
|
||||
|
||||
export async function writeOAuthCredentials(
|
||||
provider: OAuthProvider,
|
||||
provider: string,
|
||||
creds: OAuthCredentials,
|
||||
agentDir?: string,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -419,6 +419,7 @@ export async function runNonInteractiveOnboarding(
|
||||
} else if (
|
||||
authChoice === "token" ||
|
||||
authChoice === "oauth" ||
|
||||
authChoice === "chutes" ||
|
||||
authChoice === "openai-codex" ||
|
||||
authChoice === "antigravity"
|
||||
) {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type AuthChoice =
|
||||
| "setup-token"
|
||||
| "claude-cli"
|
||||
| "token"
|
||||
| "chutes"
|
||||
| "openai-codex"
|
||||
| "openai-api-key"
|
||||
| "openrouter-api-key"
|
||||
|
||||
Reference in New Issue
Block a user