mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 02:41:07 +00:00
* Remove Qwen OAuth integration (qwen-portal-auth) Qwen OAuth via portal.qwen.ai is being deprecated by the Qwen team due to traffic impact on their primary Qwen Code user base. Users should migrate to the officially supported Model Studio (Alibaba Cloud Coding Plan) provider instead. Ref: https://github.com/openclaw/openclaw/issues/49557 - Delete extensions/qwen-portal-auth/ plugin entirely - Remove qwen-portal from onboarding auth choices, provider aliases, auto-enable list, bundled plugin defaults, and pricing cache - Remove Qwen CLI credential sync (external-cli-sync, cli-credentials) - Remove QWEN_OAUTH_MARKER from model auth markers - Update docs/providers/qwen.md to redirect to Model Studio - Update model-providers docs (EN + zh-CN) to remove Qwen OAuth section - Regenerate config and plugin-sdk baselines - Update all affected tests Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * Clean up residual qwen-portal references after OAuth removal * Add migration hint for deprecated qwen-portal OAuth provider * fix: finish qwen oauth removal follow-up --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: Frank Yang <frank.ekn@gmail.com>
581 lines
16 KiB
TypeScript
581 lines
16 KiB
TypeScript
import { execFileSync, execSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import type { OAuthCredentials, OAuthProvider } from "@mariozechner/pi-ai";
|
|
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
|
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
import { resolveUserPath } from "../utils.js";
|
|
|
|
const log = createSubsystemLogger("agents/auth-profiles");
|
|
|
|
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
|
|
const CODEX_CLI_AUTH_FILENAME = "auth.json";
|
|
const MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH = ".minimax/oauth_creds.json";
|
|
|
|
const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
|
const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code";
|
|
|
|
type CachedValue<T> = {
|
|
value: T | null;
|
|
readAt: number;
|
|
cacheKey: string;
|
|
sourceFingerprint?: number | string | null;
|
|
};
|
|
|
|
let claudeCliCache: CachedValue<ClaudeCliCredential> | null = null;
|
|
let codexCliCache: CachedValue<CodexCliCredential> | null = null;
|
|
let minimaxCliCache: CachedValue<MiniMaxCliCredential> | null = null;
|
|
|
|
export function resetCliCredentialCachesForTest(): void {
|
|
claudeCliCache = null;
|
|
codexCliCache = null;
|
|
minimaxCliCache = null;
|
|
}
|
|
|
|
export type ClaudeCliCredential =
|
|
| {
|
|
type: "oauth";
|
|
provider: "anthropic";
|
|
access: string;
|
|
refresh: string;
|
|
expires: number;
|
|
}
|
|
| {
|
|
type: "token";
|
|
provider: "anthropic";
|
|
token: string;
|
|
expires: number;
|
|
};
|
|
|
|
export type CodexCliCredential = {
|
|
type: "oauth";
|
|
provider: OAuthProvider;
|
|
access: string;
|
|
refresh: string;
|
|
expires: number;
|
|
accountId?: string;
|
|
};
|
|
|
|
export type MiniMaxCliCredential = {
|
|
type: "oauth";
|
|
provider: "minimax-portal";
|
|
access: string;
|
|
refresh: string;
|
|
expires: number;
|
|
};
|
|
|
|
type ClaudeCliFileOptions = {
|
|
homeDir?: string;
|
|
};
|
|
|
|
type ClaudeCliWriteOptions = ClaudeCliFileOptions & {
|
|
platform?: NodeJS.Platform;
|
|
writeKeychain?: (credentials: OAuthCredentials) => boolean;
|
|
writeFile?: (credentials: OAuthCredentials, options?: ClaudeCliFileOptions) => boolean;
|
|
};
|
|
|
|
type ExecSyncFn = typeof execSync;
|
|
type ExecFileSyncFn = typeof execFileSync;
|
|
|
|
function resolveClaudeCliCredentialsPath(homeDir?: string) {
|
|
const baseDir = homeDir ?? resolveUserPath("~");
|
|
return path.join(baseDir, CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH);
|
|
}
|
|
|
|
function parseClaudeCliOauthCredential(claudeOauth: unknown): ClaudeCliCredential | null {
|
|
if (!claudeOauth || typeof claudeOauth !== "object") {
|
|
return null;
|
|
}
|
|
const accessToken = (claudeOauth as Record<string, unknown>).accessToken;
|
|
const refreshToken = (claudeOauth as Record<string, unknown>).refreshToken;
|
|
const expiresAt = (claudeOauth as Record<string, unknown>).expiresAt;
|
|
|
|
if (typeof accessToken !== "string" || !accessToken) {
|
|
return null;
|
|
}
|
|
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) {
|
|
return null;
|
|
}
|
|
if (typeof refreshToken === "string" && refreshToken) {
|
|
return {
|
|
type: "oauth",
|
|
provider: "anthropic",
|
|
access: accessToken,
|
|
refresh: refreshToken,
|
|
expires: expiresAt,
|
|
};
|
|
}
|
|
return {
|
|
type: "token",
|
|
provider: "anthropic",
|
|
token: accessToken,
|
|
expires: expiresAt,
|
|
};
|
|
}
|
|
|
|
function resolveCodexCliAuthPath() {
|
|
return path.join(resolveCodexHomePath(), CODEX_CLI_AUTH_FILENAME);
|
|
}
|
|
|
|
function resolveCodexHomePath() {
|
|
const configured = process.env.CODEX_HOME;
|
|
const home = configured ? resolveUserPath(configured) : resolveUserPath("~/.codex");
|
|
try {
|
|
return fs.realpathSync.native(home);
|
|
} catch {
|
|
return home;
|
|
}
|
|
}
|
|
|
|
function resolveMiniMaxCliCredentialsPath(homeDir?: string) {
|
|
const baseDir = homeDir ?? resolveUserPath("~");
|
|
return path.join(baseDir, MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH);
|
|
}
|
|
|
|
function readFileMtimeMs(filePath: string): number | null {
|
|
try {
|
|
return fs.statSync(filePath).mtimeMs;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readCachedCliCredential<T>(options: {
|
|
ttlMs: number;
|
|
cache: CachedValue<T> | null;
|
|
cacheKey: string;
|
|
read: () => T | null;
|
|
setCache: (next: CachedValue<T> | null) => void;
|
|
readSourceFingerprint?: () => number | string | null;
|
|
}): T | null {
|
|
const { ttlMs, cache, cacheKey, read, setCache, readSourceFingerprint } = options;
|
|
if (ttlMs <= 0) {
|
|
return read();
|
|
}
|
|
|
|
const now = Date.now();
|
|
const sourceFingerprint = readSourceFingerprint?.();
|
|
if (
|
|
cache &&
|
|
cache.cacheKey === cacheKey &&
|
|
cache.sourceFingerprint === sourceFingerprint &&
|
|
now - cache.readAt < ttlMs
|
|
) {
|
|
return cache.value;
|
|
}
|
|
|
|
const value = read();
|
|
const cachedSourceFingerprint = readSourceFingerprint?.();
|
|
if (!readSourceFingerprint || cachedSourceFingerprint === sourceFingerprint) {
|
|
setCache({
|
|
value,
|
|
readAt: now,
|
|
cacheKey,
|
|
sourceFingerprint: cachedSourceFingerprint,
|
|
});
|
|
} else {
|
|
setCache(null);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function computeCodexKeychainAccount(codexHome: string) {
|
|
const hash = createHash("sha256").update(codexHome).digest("hex");
|
|
return `cli|${hash.slice(0, 16)}`;
|
|
}
|
|
|
|
function decodeJwtExpiryMs(token: string): number | null {
|
|
const parts = token.split(".");
|
|
if (parts.length < 2) {
|
|
return null;
|
|
}
|
|
try {
|
|
const payloadRaw = Buffer.from(parts[1], "base64url").toString("utf8");
|
|
const payload = JSON.parse(payloadRaw) as { exp?: unknown };
|
|
return typeof payload.exp === "number" && Number.isFinite(payload.exp) && payload.exp > 0
|
|
? payload.exp * 1000
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readCodexKeychainCredentials(options?: {
|
|
platform?: NodeJS.Platform;
|
|
execSync?: ExecSyncFn;
|
|
}): CodexCliCredential | null {
|
|
const platform = options?.platform ?? process.platform;
|
|
if (platform !== "darwin") {
|
|
return null;
|
|
}
|
|
const execSyncImpl = options?.execSync ?? execSync;
|
|
|
|
const codexHome = resolveCodexHomePath();
|
|
const account = computeCodexKeychainAccount(codexHome);
|
|
|
|
try {
|
|
const secret = execSyncImpl(
|
|
`security find-generic-password -s "Codex Auth" -a "${account}" -w`,
|
|
{
|
|
encoding: "utf8",
|
|
timeout: 5000,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
},
|
|
).trim();
|
|
|
|
const parsed = JSON.parse(secret) as Record<string, unknown>;
|
|
const tokens = parsed.tokens as Record<string, unknown> | undefined;
|
|
const accessToken = tokens?.access_token;
|
|
const refreshToken = tokens?.refresh_token;
|
|
if (typeof accessToken !== "string" || !accessToken) {
|
|
return null;
|
|
}
|
|
if (typeof refreshToken !== "string" || !refreshToken) {
|
|
return null;
|
|
}
|
|
|
|
// No explicit expiry stored; treat as fresh for an hour from last_refresh or now.
|
|
const lastRefreshRaw = parsed.last_refresh;
|
|
const lastRefresh =
|
|
typeof lastRefreshRaw === "string" || typeof lastRefreshRaw === "number"
|
|
? new Date(lastRefreshRaw).getTime()
|
|
: Date.now();
|
|
const fallbackExpiry = Number.isFinite(lastRefresh)
|
|
? lastRefresh + 60 * 60 * 1000
|
|
: Date.now() + 60 * 60 * 1000;
|
|
const expires = decodeJwtExpiryMs(accessToken) ?? fallbackExpiry;
|
|
const accountId = typeof tokens?.account_id === "string" ? tokens.account_id : undefined;
|
|
|
|
log.info("read codex credentials from keychain", {
|
|
source: "keychain",
|
|
expires: new Date(expires).toISOString(),
|
|
});
|
|
|
|
return {
|
|
type: "oauth",
|
|
provider: "openai-codex" as OAuthProvider,
|
|
access: accessToken,
|
|
refresh: refreshToken,
|
|
expires,
|
|
accountId,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readPortalCliOauthCredentials<TProvider extends string>(
|
|
credPath: string,
|
|
provider: TProvider,
|
|
): { type: "oauth"; provider: TProvider; access: string; refresh: string; expires: number } | null {
|
|
const raw = loadJsonFile(credPath);
|
|
if (!raw || typeof raw !== "object") {
|
|
return null;
|
|
}
|
|
const data = raw as Record<string, unknown>;
|
|
const accessToken = data.access_token;
|
|
const refreshToken = data.refresh_token;
|
|
const expiresAt = data.expiry_date;
|
|
|
|
if (typeof accessToken !== "string" || !accessToken) {
|
|
return null;
|
|
}
|
|
if (typeof refreshToken !== "string" || !refreshToken) {
|
|
return null;
|
|
}
|
|
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
type: "oauth",
|
|
provider,
|
|
access: accessToken,
|
|
refresh: refreshToken,
|
|
expires: expiresAt,
|
|
};
|
|
}
|
|
|
|
function readMiniMaxCliCredentials(options?: { homeDir?: string }): MiniMaxCliCredential | null {
|
|
const credPath = resolveMiniMaxCliCredentialsPath(options?.homeDir);
|
|
return readPortalCliOauthCredentials(credPath, "minimax-portal");
|
|
}
|
|
|
|
function readClaudeCliKeychainCredentials(
|
|
execSyncImpl: ExecSyncFn = execSync,
|
|
): ClaudeCliCredential | null {
|
|
try {
|
|
const result = execSyncImpl(
|
|
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
|
|
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
|
);
|
|
|
|
const data = JSON.parse(result.trim());
|
|
return parseClaudeCliOauthCredential(data?.claudeAiOauth);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function readClaudeCliCredentials(options?: {
|
|
allowKeychainPrompt?: boolean;
|
|
platform?: NodeJS.Platform;
|
|
homeDir?: string;
|
|
execSync?: ExecSyncFn;
|
|
}): ClaudeCliCredential | null {
|
|
const platform = options?.platform ?? process.platform;
|
|
if (platform === "darwin" && options?.allowKeychainPrompt !== false) {
|
|
const keychainCreds = readClaudeCliKeychainCredentials(options?.execSync);
|
|
if (keychainCreds) {
|
|
log.info("read anthropic credentials from claude cli keychain", {
|
|
type: keychainCreds.type,
|
|
});
|
|
return keychainCreds;
|
|
}
|
|
}
|
|
|
|
const credPath = resolveClaudeCliCredentialsPath(options?.homeDir);
|
|
const raw = loadJsonFile(credPath);
|
|
if (!raw || typeof raw !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const data = raw as Record<string, unknown>;
|
|
return parseClaudeCliOauthCredential(data.claudeAiOauth);
|
|
}
|
|
|
|
export function readClaudeCliCredentialsCached(options?: {
|
|
allowKeychainPrompt?: boolean;
|
|
ttlMs?: number;
|
|
platform?: NodeJS.Platform;
|
|
homeDir?: string;
|
|
execSync?: ExecSyncFn;
|
|
}): ClaudeCliCredential | null {
|
|
return readCachedCliCredential({
|
|
ttlMs: options?.ttlMs ?? 0,
|
|
cache: claudeCliCache,
|
|
cacheKey: resolveClaudeCliCredentialsPath(options?.homeDir),
|
|
read: () =>
|
|
readClaudeCliCredentials({
|
|
allowKeychainPrompt: options?.allowKeychainPrompt,
|
|
platform: options?.platform,
|
|
homeDir: options?.homeDir,
|
|
execSync: options?.execSync,
|
|
}),
|
|
setCache: (next) => {
|
|
claudeCliCache = next;
|
|
},
|
|
});
|
|
}
|
|
|
|
export function writeClaudeCliKeychainCredentials(
|
|
newCredentials: OAuthCredentials,
|
|
options?: { execFileSync?: ExecFileSyncFn },
|
|
): boolean {
|
|
const execFileSyncImpl = options?.execFileSync ?? execFileSync;
|
|
try {
|
|
const existingResult = execFileSyncImpl(
|
|
"security",
|
|
["find-generic-password", "-s", CLAUDE_CLI_KEYCHAIN_SERVICE, "-w"],
|
|
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
|
);
|
|
|
|
const existingData = JSON.parse(existingResult.trim());
|
|
const existingOauth = existingData?.claudeAiOauth;
|
|
if (!existingOauth || typeof existingOauth !== "object") {
|
|
return false;
|
|
}
|
|
|
|
existingData.claudeAiOauth = {
|
|
...existingOauth,
|
|
accessToken: newCredentials.access,
|
|
refreshToken: newCredentials.refresh,
|
|
expiresAt: newCredentials.expires,
|
|
};
|
|
|
|
const newValue = JSON.stringify(existingData);
|
|
|
|
// Use execFileSync to avoid shell interpretation of user-controlled token values.
|
|
// This prevents command injection via $() or backtick expansion in OAuth tokens.
|
|
execFileSyncImpl(
|
|
"security",
|
|
[
|
|
"add-generic-password",
|
|
"-U",
|
|
"-s",
|
|
CLAUDE_CLI_KEYCHAIN_SERVICE,
|
|
"-a",
|
|
CLAUDE_CLI_KEYCHAIN_ACCOUNT,
|
|
"-w",
|
|
newValue,
|
|
],
|
|
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
|
);
|
|
|
|
log.info("wrote refreshed credentials to claude cli keychain", {
|
|
expires: new Date(newCredentials.expires).toISOString(),
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
log.warn("failed to write credentials to claude cli keychain", {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function writeClaudeCliFileCredentials(
|
|
newCredentials: OAuthCredentials,
|
|
options?: ClaudeCliFileOptions,
|
|
): boolean {
|
|
const credPath = resolveClaudeCliCredentialsPath(options?.homeDir);
|
|
|
|
if (!fs.existsSync(credPath)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const raw = loadJsonFile(credPath);
|
|
if (!raw || typeof raw !== "object") {
|
|
return false;
|
|
}
|
|
|
|
const data = raw as Record<string, unknown>;
|
|
const existingOauth = data.claudeAiOauth as Record<string, unknown> | undefined;
|
|
if (!existingOauth || typeof existingOauth !== "object") {
|
|
return false;
|
|
}
|
|
|
|
data.claudeAiOauth = {
|
|
...existingOauth,
|
|
accessToken: newCredentials.access,
|
|
refreshToken: newCredentials.refresh,
|
|
expiresAt: newCredentials.expires,
|
|
};
|
|
|
|
saveJsonFile(credPath, data);
|
|
log.info("wrote refreshed credentials to claude cli file", {
|
|
expires: new Date(newCredentials.expires).toISOString(),
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
log.warn("failed to write credentials to claude cli file", {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function writeClaudeCliCredentials(
|
|
newCredentials: OAuthCredentials,
|
|
options?: ClaudeCliWriteOptions,
|
|
): boolean {
|
|
const platform = options?.platform ?? process.platform;
|
|
const writeKeychain = options?.writeKeychain ?? writeClaudeCliKeychainCredentials;
|
|
const writeFile =
|
|
options?.writeFile ??
|
|
((credentials, fileOptions) => writeClaudeCliFileCredentials(credentials, fileOptions));
|
|
|
|
if (platform === "darwin") {
|
|
const didWriteKeychain = writeKeychain(newCredentials);
|
|
if (didWriteKeychain) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return writeFile(newCredentials, { homeDir: options?.homeDir });
|
|
}
|
|
|
|
export function readCodexCliCredentials(options?: {
|
|
platform?: NodeJS.Platform;
|
|
execSync?: ExecSyncFn;
|
|
}): CodexCliCredential | null {
|
|
const keychain = readCodexKeychainCredentials({
|
|
platform: options?.platform,
|
|
execSync: options?.execSync,
|
|
});
|
|
if (keychain) {
|
|
return keychain;
|
|
}
|
|
|
|
const authPath = resolveCodexCliAuthPath();
|
|
const raw = loadJsonFile(authPath);
|
|
if (!raw || typeof raw !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const data = raw as Record<string, unknown>;
|
|
const tokens = data.tokens as Record<string, unknown> | undefined;
|
|
if (!tokens || typeof tokens !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const accessToken = tokens.access_token;
|
|
const refreshToken = tokens.refresh_token;
|
|
|
|
if (typeof accessToken !== "string" || !accessToken) {
|
|
return null;
|
|
}
|
|
if (typeof refreshToken !== "string" || !refreshToken) {
|
|
return null;
|
|
}
|
|
|
|
let fallbackExpiry: number;
|
|
try {
|
|
const stat = fs.statSync(authPath);
|
|
fallbackExpiry = stat.mtimeMs + 60 * 60 * 1000;
|
|
} catch {
|
|
fallbackExpiry = Date.now() + 60 * 60 * 1000;
|
|
}
|
|
const expires = decodeJwtExpiryMs(accessToken) ?? fallbackExpiry;
|
|
|
|
return {
|
|
type: "oauth",
|
|
provider: "openai-codex" as OAuthProvider,
|
|
access: accessToken,
|
|
refresh: refreshToken,
|
|
expires,
|
|
accountId: typeof tokens.account_id === "string" ? tokens.account_id : undefined,
|
|
};
|
|
}
|
|
|
|
export function readCodexCliCredentialsCached(options?: {
|
|
ttlMs?: number;
|
|
platform?: NodeJS.Platform;
|
|
execSync?: ExecSyncFn;
|
|
}): CodexCliCredential | null {
|
|
const authPath = resolveCodexCliAuthPath();
|
|
return readCachedCliCredential({
|
|
ttlMs: options?.ttlMs ?? 0,
|
|
cache: codexCliCache,
|
|
cacheKey: `${options?.platform ?? process.platform}|${authPath}`,
|
|
read: () =>
|
|
readCodexCliCredentials({
|
|
platform: options?.platform,
|
|
execSync: options?.execSync,
|
|
}),
|
|
setCache: (next) => {
|
|
codexCliCache = next;
|
|
},
|
|
readSourceFingerprint: () => readFileMtimeMs(authPath),
|
|
});
|
|
}
|
|
|
|
export function readMiniMaxCliCredentialsCached(options?: {
|
|
ttlMs?: number;
|
|
homeDir?: string;
|
|
}): MiniMaxCliCredential | null {
|
|
const credPath = resolveMiniMaxCliCredentialsPath(options?.homeDir);
|
|
return readCachedCliCredential({
|
|
ttlMs: options?.ttlMs ?? 0,
|
|
cache: minimaxCliCache,
|
|
cacheKey: credPath,
|
|
read: () => readMiniMaxCliCredentials({ homeDir: options?.homeDir }),
|
|
setCache: (next) => {
|
|
minimaxCliCache = next;
|
|
},
|
|
readSourceFingerprint: () => readFileMtimeMs(credPath),
|
|
});
|
|
}
|