mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 11:00:44 +00:00
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
// Public auth/onboarding helpers for provider plugins.
|
|
|
|
import path from "node:path";
|
|
import { resolveOpenClawAgentDir } from "../agents/agent-paths.js";
|
|
import { resolveApiKeyForProfile } from "../agents/auth-profiles/oauth.js";
|
|
import { resolveAuthProfileOrder } from "../agents/auth-profiles/order.js";
|
|
import { listProfilesForProvider } from "../agents/auth-profiles/profiles.js";
|
|
import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js";
|
|
import { resolveEnvApiKey } from "../agents/model-auth-env.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { resolveStateDir } from "../config/paths.js";
|
|
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
|
|
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
|
import { resolveProviderEndpoint } from "./provider-model-shared.js";
|
|
|
|
export type { OpenClawConfig } from "../config/config.js";
|
|
export type { SecretInput } from "../config/types.secrets.js";
|
|
export type { SecretInputMode } from "../plugins/provider-auth-types.js";
|
|
export type { ProviderAuthResult } from "../plugins/types.js";
|
|
export type { ProviderAuthContext } from "../plugins/types.js";
|
|
export type { AuthProfileStore, OAuthCredential } from "../agents/auth-profiles/types.js";
|
|
|
|
export { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "../agents/auth-profiles/constants.js";
|
|
export {
|
|
ensureAuthProfileStore,
|
|
ensureAuthProfileStoreForLocalUpdate,
|
|
updateAuthProfileStoreWithLock,
|
|
} from "../agents/auth-profiles/store.js";
|
|
export {
|
|
listProfilesForProvider,
|
|
removeProviderAuthProfilesWithLock,
|
|
upsertAuthProfile,
|
|
upsertAuthProfileWithLock,
|
|
} from "../agents/auth-profiles/profiles.js";
|
|
export { resolveEnvApiKey } from "../agents/model-auth-env.js";
|
|
export { readClaudeCliCredentialsCached } from "../agents/cli-credentials.js";
|
|
export { suggestOAuthProfileIdForLegacyDefault } from "../agents/auth-profiles/repair.js";
|
|
export {
|
|
CUSTOM_LOCAL_AUTH_MARKER,
|
|
MINIMAX_OAUTH_MARKER,
|
|
isKnownEnvApiKeyMarker,
|
|
isNonSecretApiKeyMarker,
|
|
resolveOAuthApiKeyMarker,
|
|
resolveNonEnvSecretRefApiKeyMarker,
|
|
} from "../agents/model-auth-markers.js";
|
|
export {
|
|
formatApiKeyPreview,
|
|
normalizeApiKeyInput,
|
|
validateApiKeyInput,
|
|
} from "../plugins/provider-auth-input.js";
|
|
export {
|
|
ensureApiKeyFromEnvOrPrompt,
|
|
ensureApiKeyFromOptionEnvOrPrompt,
|
|
normalizeSecretInputModeInput,
|
|
promptSecretRefForSetup,
|
|
resolveSecretInputModeForEnvSelection,
|
|
} from "../plugins/provider-auth-input.js";
|
|
export { normalizeApiKeyConfig } from "../agents/models-config.providers.secrets.js";
|
|
export {
|
|
buildTokenProfileId,
|
|
validateAnthropicSetupToken,
|
|
} from "../plugins/provider-auth-token.js";
|
|
export {
|
|
applyAuthProfileConfig,
|
|
buildApiKeyCredential,
|
|
upsertApiKeyProfile,
|
|
writeOAuthCredentials,
|
|
type ApiKeyStorageOptions,
|
|
type WriteOAuthCredentialsOptions,
|
|
} from "../plugins/provider-auth-helpers.js";
|
|
export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js";
|
|
export { coerceSecretRef, hasConfiguredSecretInput } from "../config/types.secrets.js";
|
|
export { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";
|
|
export { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
|
export { resolveOpenClawAgentDir } from "../agents/agent-paths.js";
|
|
export {
|
|
normalizeOptionalSecretInput,
|
|
normalizeSecretInput,
|
|
} from "../utils/normalize-secret-input.js";
|
|
export {
|
|
listKnownProviderAuthEnvVarNames,
|
|
omitEnvKeysCaseInsensitive,
|
|
} from "../secrets/provider-env-vars.js";
|
|
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
|
export {
|
|
generateHexPkceVerifierChallenge,
|
|
generatePkceVerifierChallenge,
|
|
toFormUrlEncoded,
|
|
} from "./oauth-utils.js";
|
|
export {
|
|
DEFAULT_OAUTH_REFRESH_MARGIN_MS,
|
|
hasUsableOAuthCredential,
|
|
} from "../agents/auth-profiles/credential-state.js";
|
|
|
|
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
|
|
|
|
export const COPILOT_EDITOR_VERSION = "vscode/1.96.2";
|
|
export const COPILOT_USER_AGENT = "GitHubCopilotChat/0.26.7";
|
|
export const COPILOT_EDITOR_PLUGIN_VERSION = "copilot-chat/0.35.0";
|
|
export const COPILOT_GITHUB_API_VERSION = "2025-04-01";
|
|
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
|
|
|
|
export type CachedCopilotToken = {
|
|
token: string;
|
|
expiresAt: number;
|
|
updatedAt: number;
|
|
};
|
|
|
|
export function buildCopilotIdeHeaders(
|
|
params: {
|
|
includeApiVersion?: boolean;
|
|
} = {},
|
|
): Record<string, string> {
|
|
return {
|
|
"Editor-Version": COPILOT_EDITOR_VERSION,
|
|
"Editor-Plugin-Version": COPILOT_EDITOR_PLUGIN_VERSION,
|
|
"User-Agent": COPILOT_USER_AGENT,
|
|
...(params.includeApiVersion ? { "X-Github-Api-Version": COPILOT_GITHUB_API_VERSION } : {}),
|
|
};
|
|
}
|
|
|
|
function resolveCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env) {
|
|
return path.join(resolveStateDir(env), "credentials", "github-copilot.token.json");
|
|
}
|
|
|
|
function isCopilotTokenUsable(cache: CachedCopilotToken, now = Date.now()): boolean {
|
|
return cache.expiresAt - now > 5 * 60 * 1000;
|
|
}
|
|
|
|
function parseCopilotTokenResponse(value: unknown): {
|
|
token: string;
|
|
expiresAt: number;
|
|
} {
|
|
if (!value || typeof value !== "object") {
|
|
throw new Error("Unexpected response from GitHub Copilot token endpoint");
|
|
}
|
|
const asRecord = value as Record<string, unknown>;
|
|
const token = asRecord.token;
|
|
const expiresAt = asRecord.expires_at;
|
|
if (typeof token !== "string" || token.trim().length === 0) {
|
|
throw new Error("Copilot token response missing token");
|
|
}
|
|
|
|
let expiresAtMs: number;
|
|
if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) {
|
|
expiresAtMs = expiresAt < 100_000_000_000 ? expiresAt * 1000 : expiresAt;
|
|
} else if (typeof expiresAt === "string" && expiresAt.trim().length > 0) {
|
|
const parsed = Number.parseInt(expiresAt, 10);
|
|
if (!Number.isFinite(parsed)) {
|
|
throw new Error("Copilot token response has invalid expires_at");
|
|
}
|
|
expiresAtMs = parsed < 100_000_000_000 ? parsed * 1000 : parsed;
|
|
} else {
|
|
throw new Error("Copilot token response missing expires_at");
|
|
}
|
|
|
|
return { token, expiresAt: expiresAtMs };
|
|
}
|
|
|
|
function resolveCopilotProxyHost(proxyEp: string): string | null {
|
|
const trimmed = proxyEp.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const urlText = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
try {
|
|
const url = new URL(urlText);
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
return null;
|
|
}
|
|
return normalizeLowercaseStringOrEmpty(url.hostname);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function deriveCopilotApiBaseUrlFromToken(token: string): string | null {
|
|
const trimmed = token.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const match = trimmed.match(/(?:^|;)\s*proxy-ep=([^;\s]+)/i);
|
|
const proxyEp = match?.[1]?.trim();
|
|
if (!proxyEp) {
|
|
return null;
|
|
}
|
|
|
|
const proxyHost = resolveCopilotProxyHost(proxyEp);
|
|
if (!proxyHost) {
|
|
return null;
|
|
}
|
|
const host = proxyHost.replace(/^proxy\./i, "api.");
|
|
|
|
const baseUrl = `https://${host}`;
|
|
return resolveProviderEndpoint(baseUrl).endpointClass === "invalid" ? null : baseUrl;
|
|
}
|
|
|
|
export async function resolveCopilotApiToken(params: {
|
|
githubToken: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
fetchImpl?: typeof fetch;
|
|
cachePath?: string;
|
|
loadJsonFileImpl?: (path: string) => unknown;
|
|
saveJsonFileImpl?: (path: string, value: CachedCopilotToken) => void;
|
|
}): Promise<{
|
|
token: string;
|
|
expiresAt: number;
|
|
source: string;
|
|
baseUrl: string;
|
|
}> {
|
|
const env = params.env ?? process.env;
|
|
const cachePath = params.cachePath?.trim() || resolveCopilotTokenCachePath(env);
|
|
const loadJsonFileFn = params.loadJsonFileImpl ?? loadJsonFile;
|
|
const saveJsonFileFn = params.saveJsonFileImpl ?? saveJsonFile;
|
|
const cached = loadJsonFileFn(cachePath) as CachedCopilotToken | undefined;
|
|
if (cached && typeof cached.token === "string" && typeof cached.expiresAt === "number") {
|
|
if (isCopilotTokenUsable(cached)) {
|
|
return {
|
|
token: cached.token,
|
|
expiresAt: cached.expiresAt,
|
|
source: `cache:${cachePath}`,
|
|
baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token) ?? DEFAULT_COPILOT_API_BASE_URL,
|
|
};
|
|
}
|
|
}
|
|
|
|
const fetchImpl = params.fetchImpl ?? fetch;
|
|
const res = await fetchImpl(COPILOT_TOKEN_URL, {
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
Authorization: `Bearer ${params.githubToken}`,
|
|
...buildCopilotIdeHeaders({ includeApiVersion: true }),
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Copilot token exchange failed: HTTP ${res.status}`);
|
|
}
|
|
|
|
const json = parseCopilotTokenResponse(await res.json());
|
|
const payload: CachedCopilotToken = {
|
|
token: json.token,
|
|
expiresAt: json.expiresAt,
|
|
updatedAt: Date.now(),
|
|
};
|
|
saveJsonFileFn(cachePath, payload);
|
|
|
|
return {
|
|
token: payload.token,
|
|
expiresAt: payload.expiresAt,
|
|
source: `fetched:${COPILOT_TOKEN_URL}`,
|
|
baseUrl: deriveCopilotApiBaseUrlFromToken(payload.token) ?? DEFAULT_COPILOT_API_BASE_URL,
|
|
};
|
|
}
|
|
|
|
export function isProviderApiKeyConfigured(params: {
|
|
provider: string;
|
|
agentDir?: string;
|
|
}): boolean {
|
|
if (resolveEnvApiKey(params.provider)?.apiKey) {
|
|
return true;
|
|
}
|
|
const agentDir = params.agentDir?.trim();
|
|
if (!agentDir) {
|
|
return false;
|
|
}
|
|
const store = ensureAuthProfileStore(agentDir, {
|
|
allowKeychainPrompt: false,
|
|
});
|
|
return listProfilesForProvider(store, params.provider).length > 0;
|
|
}
|
|
|
|
export function listUsableProviderAuthProfileIds(params: {
|
|
provider: string;
|
|
cfg?: OpenClawConfig;
|
|
agentDir?: string;
|
|
}): { agentDir: string; profileIds: string[] } {
|
|
try {
|
|
const agentDir = params.agentDir?.trim() || resolveOpenClawAgentDir();
|
|
const store = ensureAuthProfileStore(agentDir, {
|
|
allowKeychainPrompt: false,
|
|
});
|
|
return {
|
|
agentDir,
|
|
profileIds: resolveAuthProfileOrder({
|
|
cfg: params.cfg,
|
|
store,
|
|
provider: params.provider,
|
|
}),
|
|
};
|
|
} catch {
|
|
return { agentDir: "", profileIds: [] };
|
|
}
|
|
}
|
|
|
|
export function isProviderAuthProfileConfigured(params: {
|
|
provider: string;
|
|
cfg?: OpenClawConfig;
|
|
agentDir?: string;
|
|
}): boolean {
|
|
return listUsableProviderAuthProfileIds(params).profileIds.length > 0;
|
|
}
|
|
|
|
export async function resolveProviderAuthProfileApiKey(params: {
|
|
provider: string;
|
|
cfg?: OpenClawConfig;
|
|
agentDir?: string;
|
|
}): Promise<string | undefined> {
|
|
const { agentDir, profileIds } = listUsableProviderAuthProfileIds(params);
|
|
if (!agentDir || profileIds.length === 0) {
|
|
return undefined;
|
|
}
|
|
const store = ensureAuthProfileStore(agentDir, {
|
|
allowKeychainPrompt: false,
|
|
});
|
|
for (const profileId of profileIds) {
|
|
const resolved = await resolveApiKeyForProfile({
|
|
cfg: params.cfg,
|
|
store,
|
|
agentDir,
|
|
profileId,
|
|
});
|
|
if (resolved?.apiKey) {
|
|
return resolved.apiKey;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|