220 lines
7.0 KiB
TypeScript
220 lines
7.0 KiB
TypeScript
import { getOAuthApiKey, type OAuthCredentials, type OAuthProvider } from "@mariozechner/pi-ai";
|
|
import lockfile from "proper-lockfile";
|
|
|
|
import type { ClawdbotConfig } from "../../config/config.js";
|
|
import { refreshChutesTokens } from "../chutes-oauth.js";
|
|
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
|
|
import { writeClaudeCliCredentials } from "../cli-credentials.js";
|
|
import { AUTH_STORE_LOCK_OPTIONS, CLAUDE_CLI_PROFILE_ID } from "./constants.js";
|
|
import { formatAuthDoctorHint } from "./doctor.js";
|
|
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
|
|
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
|
|
import { ensureAuthProfileStore, saveAuthProfileStore } from "./store.js";
|
|
import type { AuthProfileStore } from "./types.js";
|
|
|
|
function buildOAuthApiKey(provider: string, credentials: OAuthCredentials): string {
|
|
const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity";
|
|
return needsProjectId
|
|
? JSON.stringify({
|
|
token: credentials.access,
|
|
projectId: credentials.projectId,
|
|
})
|
|
: credentials.access;
|
|
}
|
|
|
|
async function refreshOAuthTokenWithLock(params: {
|
|
profileId: string;
|
|
agentDir?: string;
|
|
}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> {
|
|
const authPath = resolveAuthStorePath(params.agentDir);
|
|
ensureAuthStoreFile(authPath);
|
|
|
|
let release: (() => Promise<void>) | undefined;
|
|
try {
|
|
release = await lockfile.lock(authPath, {
|
|
...AUTH_STORE_LOCK_OPTIONS,
|
|
});
|
|
|
|
const store = ensureAuthProfileStore(params.agentDir);
|
|
const cred = store.profiles[params.profileId];
|
|
if (!cred || cred.type !== "oauth") return null;
|
|
|
|
if (Date.now() < cred.expires) {
|
|
return {
|
|
apiKey: buildOAuthApiKey(cred.provider, cred),
|
|
newCredentials: cred,
|
|
};
|
|
}
|
|
|
|
const oauthCreds: Record<string, OAuthCredentials> = {
|
|
[cred.provider]: cred,
|
|
};
|
|
|
|
const result =
|
|
String(cred.provider) === "chutes"
|
|
? await (async () => {
|
|
const newCredentials = await refreshChutesTokens({
|
|
credential: cred,
|
|
});
|
|
return { apiKey: newCredentials.access, newCredentials };
|
|
})()
|
|
: String(cred.provider) === "qwen-portal"
|
|
? await (async () => {
|
|
const newCredentials = await refreshQwenPortalCredentials(cred);
|
|
return { apiKey: newCredentials.access, newCredentials };
|
|
})()
|
|
: await getOAuthApiKey(cred.provider as OAuthProvider, oauthCreds);
|
|
if (!result) return null;
|
|
store.profiles[params.profileId] = {
|
|
...cred,
|
|
...result.newCredentials,
|
|
type: "oauth",
|
|
};
|
|
saveAuthProfileStore(store, params.agentDir);
|
|
|
|
// Sync refreshed credentials back to Claude Code CLI if this is the claude-cli profile
|
|
// This ensures Claude Code continues to work after ClawdBot refreshes the token
|
|
if (params.profileId === CLAUDE_CLI_PROFILE_ID && cred.provider === "anthropic") {
|
|
writeClaudeCliCredentials(result.newCredentials);
|
|
}
|
|
|
|
return result;
|
|
} finally {
|
|
if (release) {
|
|
try {
|
|
await release();
|
|
} catch {
|
|
// ignore unlock errors
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function tryResolveOAuthProfile(params: {
|
|
cfg?: ClawdbotConfig;
|
|
store: AuthProfileStore;
|
|
profileId: string;
|
|
agentDir?: string;
|
|
}): Promise<{ apiKey: string; provider: string; email?: string } | null> {
|
|
const { cfg, store, profileId } = params;
|
|
const cred = store.profiles[profileId];
|
|
if (!cred || cred.type !== "oauth") return null;
|
|
const profileConfig = cfg?.auth?.profiles?.[profileId];
|
|
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
|
if (profileConfig && profileConfig.mode !== cred.type) return null;
|
|
|
|
if (Date.now() < cred.expires) {
|
|
return {
|
|
apiKey: buildOAuthApiKey(cred.provider, cred),
|
|
provider: cred.provider,
|
|
email: cred.email,
|
|
};
|
|
}
|
|
|
|
const refreshed = await refreshOAuthTokenWithLock({
|
|
profileId,
|
|
agentDir: params.agentDir,
|
|
});
|
|
if (!refreshed) return null;
|
|
return {
|
|
apiKey: refreshed.apiKey,
|
|
provider: cred.provider,
|
|
email: cred.email,
|
|
};
|
|
}
|
|
|
|
export async function resolveApiKeyForProfile(params: {
|
|
cfg?: ClawdbotConfig;
|
|
store: AuthProfileStore;
|
|
profileId: string;
|
|
agentDir?: string;
|
|
}): Promise<{ apiKey: string; provider: string; email?: string } | null> {
|
|
const { cfg, store, profileId } = params;
|
|
const cred = store.profiles[profileId];
|
|
if (!cred) return null;
|
|
const profileConfig = cfg?.auth?.profiles?.[profileId];
|
|
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
|
if (profileConfig && profileConfig.mode !== cred.type) {
|
|
// Compatibility: treat "oauth" config as compatible with stored token profiles.
|
|
if (!(profileConfig.mode === "oauth" && cred.type === "token")) return null;
|
|
}
|
|
|
|
if (cred.type === "api_key") {
|
|
return { apiKey: cred.key, provider: cred.provider, email: cred.email };
|
|
}
|
|
if (cred.type === "token") {
|
|
const token = cred.token?.trim();
|
|
if (!token) return null;
|
|
if (
|
|
typeof cred.expires === "number" &&
|
|
Number.isFinite(cred.expires) &&
|
|
cred.expires > 0 &&
|
|
Date.now() >= cred.expires
|
|
) {
|
|
return null;
|
|
}
|
|
return { apiKey: token, provider: cred.provider, email: cred.email };
|
|
}
|
|
if (Date.now() < cred.expires) {
|
|
return {
|
|
apiKey: buildOAuthApiKey(cred.provider, cred),
|
|
provider: cred.provider,
|
|
email: cred.email,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const result = await refreshOAuthTokenWithLock({
|
|
profileId,
|
|
agentDir: params.agentDir,
|
|
});
|
|
if (!result) return null;
|
|
return {
|
|
apiKey: result.apiKey,
|
|
provider: cred.provider,
|
|
email: cred.email,
|
|
};
|
|
} catch (error) {
|
|
const refreshedStore = ensureAuthProfileStore(params.agentDir);
|
|
const refreshed = refreshedStore.profiles[profileId];
|
|
if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) {
|
|
return {
|
|
apiKey: buildOAuthApiKey(refreshed.provider, refreshed),
|
|
provider: refreshed.provider,
|
|
email: refreshed.email ?? cred.email,
|
|
};
|
|
}
|
|
const fallbackProfileId = suggestOAuthProfileIdForLegacyDefault({
|
|
cfg,
|
|
store: refreshedStore,
|
|
provider: cred.provider,
|
|
legacyProfileId: profileId,
|
|
});
|
|
if (fallbackProfileId && fallbackProfileId !== profileId) {
|
|
try {
|
|
const fallbackResolved = await tryResolveOAuthProfile({
|
|
cfg,
|
|
store: refreshedStore,
|
|
profileId: fallbackProfileId,
|
|
agentDir: params.agentDir,
|
|
});
|
|
if (fallbackResolved) return fallbackResolved;
|
|
} catch {
|
|
// keep original error
|
|
}
|
|
}
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const hint = formatAuthDoctorHint({
|
|
cfg,
|
|
store: refreshedStore,
|
|
provider: cred.provider,
|
|
profileId,
|
|
});
|
|
throw new Error(
|
|
`OAuth token refresh failed for ${cred.provider}: ${message}. ` +
|
|
"Please try again or re-authenticate." +
|
|
(hint ? `\n\n${hint}` : ""),
|
|
);
|
|
}
|
|
}
|