fix: harden Chutes OAuth flow (#726) (thanks @FrieSei)

This commit is contained in:
Peter Steinberger
2026-01-13 05:01:08 +00:00
parent 3271ff1d6e
commit f566e6451f
7 changed files with 101 additions and 109 deletions

View File

@@ -5,6 +5,7 @@
### Changes ### Changes
- Models/Moonshot: add Kimi K2 0905 + turbo/thinking variants to the preset + docs. (#818 — thanks @mickahouan) - 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) - 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. - Agents: make workspace bootstrap truncation configurable (default 20k) and warn when files are truncated.
### Fixes ### Fixes

View File

@@ -15,7 +15,7 @@ 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 { refreshChutesTokens } from "./chutes-oauth.js";
import { import {
readClaudeCliCredentialsCached, readClaudeCliCredentialsCached,
readCodexCliCredentialsCached, readCodexCliCredentialsCached,
@@ -68,7 +68,8 @@ export type TokenCredential = {
export type OAuthCredential = OAuthCredentials & { export type OAuthCredential = OAuthCredentials & {
type: "oauth"; type: "oauth";
provider: OAuthProvider; provider: string;
clientId?: string;
email?: string; email?: string;
}; };
@@ -172,7 +173,7 @@ async function updateAuthProfileStoreWithLock(params: {
} }
function buildOAuthApiKey( function buildOAuthApiKey(
provider: OAuthProvider, provider: string,
credentials: OAuthCredentials, credentials: OAuthCredentials,
): string { ): string {
const needsProjectId = const needsProjectId =
@@ -187,7 +188,6 @@ function buildOAuthApiKey(
async function refreshOAuthTokenWithLock(params: { async function refreshOAuthTokenWithLock(params: {
profileId: string; profileId: string;
provider: OAuthProvider;
agentDir?: string; agentDir?: string;
}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { }): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
const authPath = resolveAuthStorePath(params.agentDir); const authPath = resolveAuthStorePath(params.agentDir);
@@ -218,11 +218,11 @@ async function refreshOAuthTokenWithLock(params: {
String(cred.provider) === "chutes" String(cred.provider) === "chutes"
? await (async () => { ? await (async () => {
const newCredentials = await refreshChutesTokens({ const newCredentials = await refreshChutesTokens({
credential: cred as unknown as ChutesStoredOAuth, credential: cred,
}); });
return { apiKey: newCredentials.access, newCredentials }; return { apiKey: newCredentials.access, newCredentials };
})() })()
: await getOAuthApiKey(cred.provider, oauthCreds); : await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds);
if (!result) return null; if (!result) return null;
store.profiles[params.profileId] = { store.profiles[params.profileId] = {
...cred, ...cred,
@@ -269,7 +269,7 @@ function coerceLegacyStore(raw: unknown): LegacyAuthStore | null {
} }
entries[key] = { entries[key] = {
...typed, ...typed,
provider: typed.provider ?? (key as OAuthProvider), provider: String(typed.provider ?? key),
} as AuthProfileCredential; } as AuthProfileCredential;
} }
return Object.keys(entries).length > 0 ? entries : null; return Object.keys(entries).length > 0 ? entries : null;
@@ -336,7 +336,7 @@ function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
if (store.profiles[profileId]) continue; if (store.profiles[profileId]) continue;
store.profiles[profileId] = { store.profiles[profileId] = {
type: "oauth", type: "oauth",
provider: provider as OAuthProvider, provider,
...creds, ...creds,
}; };
mutated = true; mutated = true;
@@ -478,7 +478,7 @@ function syncExternalCliCredentials(
const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID]; const existingCodex = store.profiles[CODEX_CLI_PROFILE_ID];
const shouldSyncCodex = const shouldSyncCodex =
!existingCodex || !existingCodex ||
existingCodex.provider !== ("openai-codex" as OAuthProvider) || existingCodex.provider !== "openai-codex" ||
!isExternalProfileFresh(existingCodex, now); !isExternalProfileFresh(existingCodex, now);
const codexCreds = shouldSyncCodex const codexCreds = shouldSyncCodex
? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }) ? readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
@@ -490,7 +490,7 @@ function syncExternalCliCredentials(
// Codex creds don't carry expiry; use file mtime heuristic for freshness. // Codex creds don't carry expiry; use file mtime heuristic for freshness.
const shouldUpdate = const shouldUpdate =
!existingOAuth || !existingOAuth ||
existingOAuth.provider !== ("openai-codex" as unknown as OAuthProvider) || existingOAuth.provider !== "openai-codex" ||
existingOAuth.expires <= now || existingOAuth.expires <= now ||
codexCreds.expires > existingOAuth.expires; codexCreds.expires > existingOAuth.expires;
@@ -535,14 +535,14 @@ export function loadAuthProfileStore(): AuthProfileStore {
if (cred.type === "api_key") { if (cred.type === "api_key") {
store.profiles[profileId] = { store.profiles[profileId] = {
type: "api_key", type: "api_key",
provider: cred.provider ?? (provider as OAuthProvider), provider: String(cred.provider ?? provider),
key: cred.key, key: cred.key,
...(cred.email ? { email: cred.email } : {}), ...(cred.email ? { email: cred.email } : {}),
}; };
} else if (cred.type === "token") { } else if (cred.type === "token") {
store.profiles[profileId] = { store.profiles[profileId] = {
type: "token", type: "token",
provider: cred.provider ?? (provider as OAuthProvider), provider: String(cred.provider ?? provider),
token: cred.token, token: cred.token,
...(typeof cred.expires === "number" ...(typeof cred.expires === "number"
? { expires: cred.expires } ? { expires: cred.expires }
@@ -552,7 +552,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
} else { } else {
store.profiles[profileId] = { store.profiles[profileId] = {
type: "oauth", type: "oauth",
provider: cred.provider ?? (provider as OAuthProvider), provider: String(cred.provider ?? provider),
access: cred.access, access: cred.access,
refresh: cred.refresh, refresh: cred.refresh,
expires: cred.expires, expires: cred.expires,
@@ -600,14 +600,14 @@ export function ensureAuthProfileStore(
if (cred.type === "api_key") { if (cred.type === "api_key") {
store.profiles[profileId] = { store.profiles[profileId] = {
type: "api_key", type: "api_key",
provider: cred.provider ?? (provider as OAuthProvider), provider: String(cred.provider ?? provider),
key: cred.key, key: cred.key,
...(cred.email ? { email: cred.email } : {}), ...(cred.email ? { email: cred.email } : {}),
}; };
} else if (cred.type === "token") { } else if (cred.type === "token") {
store.profiles[profileId] = { store.profiles[profileId] = {
type: "token", type: "token",
provider: cred.provider ?? (provider as OAuthProvider), provider: String(cred.provider ?? provider),
token: cred.token, token: cred.token,
...(typeof cred.expires === "number" ...(typeof cred.expires === "number"
? { expires: cred.expires } ? { expires: cred.expires }
@@ -617,7 +617,7 @@ export function ensureAuthProfileStore(
} else { } else {
store.profiles[profileId] = { store.profiles[profileId] = {
type: "oauth", type: "oauth",
provider: cred.provider ?? (provider as OAuthProvider), provider: String(cred.provider ?? provider),
access: cred.access, access: cred.access,
refresh: cred.refresh, refresh: cred.refresh,
expires: cred.expires, expires: cred.expires,
@@ -1231,7 +1231,6 @@ export async function resolveApiKeyForProfile(params: {
try { try {
const result = await refreshOAuthTokenWithLock({ const result = await refreshOAuthTokenWithLock({
profileId, profileId,
provider: cred.provider,
agentDir: params.agentDir, agentDir: params.agentDir,
}); });
if (!result) return null; if (!result) return null;
@@ -1351,7 +1350,6 @@ async function tryResolveOAuthProfile(params: {
const refreshed = await refreshOAuthTokenWithLock({ const refreshed = await refreshOAuthTokenWithLock({
profileId, profileId,
provider: cred.provider,
agentDir: params.agentDir, agentDir: params.agentDir,
}); });
if (!refreshed) return null; if (!refreshed) return null;

View File

@@ -26,7 +26,6 @@ export type ChutesOAuthAppConfig = {
export type ChutesStoredOAuth = OAuthCredentials & { export type ChutesStoredOAuth = OAuthCredentials & {
clientId?: string; clientId?: string;
clientSecret?: string;
}; };
export function generateChutesPkce(): ChutesPkce { export function generateChutesPkce(): ChutesPkce {
@@ -45,7 +44,7 @@ export function parseOAuthCallbackInput(
try { try {
const url = new URL(trimmed); const url = new URL(trimmed);
const code = url.searchParams.get("code"); const code = url.searchParams.get("code");
const state = url.searchParams.get("state") ?? expectedState; const state = url.searchParams.get("state");
if (!code) return { error: "Missing 'code' parameter in URL" }; if (!code) return { error: "Missing 'code' parameter in URL" };
if (!state) { if (!state) {
return { error: "Missing 'state' parameter. Paste the full URL." }; return { error: "Missing 'state' parameter. Paste the full URL." };
@@ -138,7 +137,6 @@ export async function exchangeChutesCodeForTokens(params: {
email: info?.username, email: info?.username,
accountId: info?.sub, accountId: info?.sub,
clientId: params.app.clientId, clientId: params.app.clientId,
clientSecret: params.app.clientSecret,
} as unknown as ChutesStoredOAuth; } as unknown as ChutesStoredOAuth;
} }
@@ -162,10 +160,7 @@ export async function refreshChutesTokens(params: {
"Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).", "Missing CHUTES_CLIENT_ID for Chutes OAuth refresh (set env var or re-auth).",
); );
} }
const clientSecret = const clientSecret = process.env.CHUTES_CLIENT_SECRET?.trim() || undefined;
params.credential.clientSecret?.trim() ??
process.env.CHUTES_CLIENT_SECRET?.trim() ??
undefined;
const body = new URLSearchParams({ const body = new URLSearchParams({
grant_type: "refresh_token", grant_type: "refresh_token",
@@ -201,6 +196,5 @@ export async function refreshChutesTokens(params: {
refresh: newRefresh || refreshToken, refresh: newRefresh || refreshToken,
expires: coerceExpiresAt(expiresIn, now), expires: coerceExpiresAt(expiresIn, now),
clientId, clientId,
clientSecret,
} as unknown as ChutesStoredOAuth; } as unknown as ChutesStoredOAuth;
} }

View File

@@ -68,9 +68,7 @@ describe("buildBootstrapContextFiles", () => {
); );
expect(result?.content.length).toBeLessThan(long.length); expect(result?.content.length).toBeLessThan(long.length);
expect(result?.content.startsWith(long.slice(0, 120))).toBe(true); expect(result?.content.startsWith(long.slice(0, 120))).toBe(true);
expect(result?.content.endsWith(long.slice(-expectedTailChars))).toBe( expect(result?.content.endsWith(long.slice(-expectedTailChars))).toBe(true);
true,
);
expect(warnings).toHaveLength(1); expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("TOOLS.md"); expect(warnings[0]).toContain("TOOLS.md");
expect(warnings[0]).toContain("limit 200"); expect(warnings[0]).toContain("limit 200");

View File

@@ -1,8 +1,4 @@
import { import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai";
loginOpenAICodex,
type OAuthCredentials,
type OAuthProvider,
} from "@mariozechner/pi-ai";
import { resolveAgentConfig } from "../agents/agent-scope.js"; import { resolveAgentConfig } from "../agents/agent-scope.js";
import { import {
CLAUDE_CLI_PROFILE_ID, CLAUDE_CLI_PROFILE_ID,
@@ -52,7 +48,6 @@ import {
applySyntheticConfig, applySyntheticConfig,
applySyntheticProviderConfig, applySyntheticProviderConfig,
applyZaiConfig, applyZaiConfig,
MINIMAX_HOSTED_MODEL_REF,
MOONSHOT_DEFAULT_MODEL_REF, MOONSHOT_DEFAULT_MODEL_REF,
OPENROUTER_DEFAULT_MODEL_REF, OPENROUTER_DEFAULT_MODEL_REF,
SYNTHETIC_DEFAULT_MODEL_REF, SYNTHETIC_DEFAULT_MODEL_REF,
@@ -105,6 +100,56 @@ function normalizeApiKeyInput(raw: string): string {
const validateApiKeyInput = (value: unknown) => const validateApiKeyInput = (value: unknown) =>
normalizeApiKeyInput(String(value ?? "")).length > 0 ? undefined : "Required"; 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( function formatApiKeyPreview(
raw: string, raw: string,
opts: { head?: number; tail?: number } = {}, opts: { head?: number; tail?: number } = {},
@@ -574,43 +619,25 @@ export async function applyAuthChoice(params: {
); );
const spin = params.prompter.progress("Starting OAuth flow…"); const spin = params.prompter.progress("Starting OAuth flow…");
let manualCodePromise: Promise<string> | undefined;
try { try {
const { onAuth, onPrompt } = createVpsAwareOAuthHandlers({
isRemote,
prompter: params.prompter,
runtime: params.runtime,
spin,
localBrowserMessage: "Complete sign-in in browser…",
});
const creds = await loginChutes({ const creds = await loginChutes({
app: { app: {
clientId, clientId,
clientSecret, clientSecret,
redirectUri, redirectUri,
scopes: scopes.split(/\\s+/).filter(Boolean), scopes: scopes.split(/\s+/).filter(Boolean),
}, },
manual: isRemote, manual: isRemote,
onAuth: async ({ url }) => { onAuth,
if (isRemote) { onPrompt,
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), onProgress: (msg) => spin.update(msg),
}); });
@@ -618,11 +645,7 @@ export async function applyAuthChoice(params: {
const email = creds.email?.trim() || "default"; const email = creds.email?.trim() || "default";
const profileId = `chutes:${email}`; const profileId = `chutes:${email}`;
await writeOAuthCredentials( await writeOAuthCredentials("chutes", creds, params.agentDir);
"chutes" as unknown as OAuthProvider,
creds,
params.agentDir,
);
nextConfig = applyAuthProfileConfig(nextConfig, { nextConfig = applyAuthProfileConfig(nextConfig, {
profileId, profileId,
provider: "chutes", provider: "chutes",
@@ -637,7 +660,7 @@ export async function applyAuthChoice(params: {
"Verify CHUTES_CLIENT_ID (and CHUTES_CLIENT_SECRET if required).", "Verify CHUTES_CLIENT_ID (and CHUTES_CLIENT_SECRET if required).",
`Verify the OAuth app redirect URI includes: ${redirectUri}`, `Verify the OAuth app redirect URI includes: ${redirectUri}`,
"Chutes docs: https://chutes.ai/docs/sign-in-with-chutes/overview", "Chutes docs: https://chutes.ai/docs/sign-in-with-chutes/overview",
].join("\\n"), ].join("\n"),
"OAuth help", "OAuth help",
); );
} }
@@ -658,47 +681,23 @@ export async function applyAuthChoice(params: {
"OpenAI Codex OAuth", "OpenAI Codex OAuth",
); );
const spin = params.prompter.progress("Starting OAuth flow…"); const spin = params.prompter.progress("Starting OAuth flow…");
let manualCodePromise: Promise<string> | undefined;
try { try {
const { onAuth, onPrompt } = createVpsAwareOAuthHandlers({
isRemote,
prompter: params.prompter,
runtime: params.runtime,
spin,
localBrowserMessage: "Complete sign-in in browser…",
});
const creds = await loginOpenAICodex({ const creds = await loginOpenAICodex({
onAuth: async ({ url }) => { onAuth,
if (isRemote) { onPrompt,
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), onProgress: (msg) => spin.update(msg),
}); });
spin.stop("OpenAI OAuth complete"); spin.stop("OpenAI OAuth complete");
if (creds) { if (creds) {
await writeOAuthCredentials( await writeOAuthCredentials("openai-codex", creds, params.agentDir);
"openai-codex" as unknown as OAuthProvider,
creds,
params.agentDir,
);
nextConfig = applyAuthProfileConfig(nextConfig, { nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "openai-codex:default", profileId: "openai-codex:default",
provider: "openai-codex", provider: "openai-codex",

View File

@@ -1,9 +1,11 @@
import { randomBytes } from "node:crypto";
import { createServer } from "node:http"; import { createServer } from "node:http";
import type { OAuthCredentials } from "@mariozechner/pi-ai"; import type { OAuthCredentials } from "@mariozechner/pi-ai";
import type { ChutesOAuthAppConfig } from "../agents/chutes-oauth.js"; import type { ChutesOAuthAppConfig } from "../agents/chutes-oauth.js";
import { import {
CHUTES_AUTHORIZE_ENDPOINT,
exchangeChutesCodeForTokens, exchangeChutesCodeForTokens,
generateChutesPkce, generateChutesPkce,
parseOAuthCallbackInput, parseOAuthCallbackInput,
@@ -30,7 +32,7 @@ function buildAuthorizeUrl(params: {
code_challenge: params.challenge, code_challenge: params.challenge,
code_challenge_method: "S256", code_challenge_method: "S256",
}); });
return `https://api.chutes.ai/idp/authorize?${qs.toString()}`; return `${CHUTES_AUTHORIZE_ENDPOINT}?${qs.toString()}`;
} }
async function waitForLocalCallback(params: { async function waitForLocalCallback(params: {
@@ -129,7 +131,7 @@ export async function loginChutes(params: {
fetchFn?: typeof fetch; fetchFn?: typeof fetch;
}): Promise<OAuthCredentials> { }): Promise<OAuthCredentials> {
const { verifier, challenge } = generateChutesPkce(); const { verifier, challenge } = generateChutesPkce();
const state = verifier; const state = randomBytes(16).toString("hex");
const timeoutMs = params.timeoutMs ?? 3 * 60 * 1000; const timeoutMs = params.timeoutMs ?? 3 * 60 * 1000;
const url = buildAuthorizeUrl({ const url = buildAuthorizeUrl({

View File

@@ -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 { resolveDefaultAgentDir } from "../agents/agent-scope.js";
import { upsertAuthProfile } from "../agents/auth-profiles.js"; import { upsertAuthProfile } from "../agents/auth-profiles.js";
import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js"; import { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js";
@@ -103,7 +103,7 @@ function buildMoonshotModelDefinition(): ModelDefinitionConfig {
} }
export async function writeOAuthCredentials( export async function writeOAuthCredentials(
provider: OAuthProvider, provider: string,
creds: OAuthCredentials, creds: OAuthCredentials,
agentDir?: string, agentDir?: string,
): Promise<void> { ): Promise<void> {