Refactor clawx (#344)

Co-authored-by: ashione <skyzlxuan@gmail.com>
This commit is contained in:
paisley
2026-03-09 13:10:42 +08:00
committed by GitHub
Unverified
parent 3d804a9f5e
commit 2c5c82bb74
75 changed files with 7640 additions and 3106 deletions

View File

@@ -0,0 +1,161 @@
import { EventEmitter } from 'events';
import { BrowserWindow, shell } from 'electron';
import { logger } from './logger';
import { loginGeminiCliOAuth, type GeminiCliOAuthCredentials } from './gemini-cli-oauth';
import { getProviderService } from '../services/providers/provider-service';
import { getSecretStore } from '../services/secrets/secret-store';
import { saveOAuthTokenToOpenClaw } from './openclaw-auth';
export type BrowserOAuthProviderType = 'google';
const GOOGLE_RUNTIME_PROVIDER_ID = 'google-gemini-cli';
const GOOGLE_OAUTH_DEFAULT_MODEL = 'gemini-3-pro-preview';
class BrowserOAuthManager extends EventEmitter {
private activeProvider: BrowserOAuthProviderType | null = null;
private activeAccountId: string | null = null;
private activeLabel: string | null = null;
private active = false;
private mainWindow: BrowserWindow | null = null;
setWindow(window: BrowserWindow) {
this.mainWindow = window;
}
async startFlow(
provider: BrowserOAuthProviderType,
options?: { accountId?: string; label?: string },
): Promise<boolean> {
if (this.active) {
await this.stopFlow();
}
this.active = true;
this.activeProvider = provider;
this.activeAccountId = options?.accountId || provider;
this.activeLabel = options?.label || null;
this.emit('oauth:start', { provider, accountId: this.activeAccountId });
try {
if (provider !== 'google') {
throw new Error(`Unsupported browser OAuth provider type: ${provider}`);
}
const token = await loginGeminiCliOAuth({
isRemote: false,
openUrl: async (url) => {
await shell.openExternal(url);
},
log: (message) => logger.info(`[BrowserOAuth] ${message}`),
note: async (message, title) => {
logger.info(`[BrowserOAuth] ${title || 'OAuth note'}: ${message}`);
},
prompt: async () => {
throw new Error('Manual browser OAuth fallback is not implemented in ClawX yet.');
},
progress: {
update: (message) => logger.info(`[BrowserOAuth] ${message}`),
stop: (message) => {
if (message) {
logger.info(`[BrowserOAuth] ${message}`);
}
},
},
});
await this.onSuccess(provider, token);
return true;
} catch (error) {
if (!this.active) {
return false;
}
logger.error(`[BrowserOAuth] Flow error for ${provider}:`, error);
this.emitError(error instanceof Error ? error.message : String(error));
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
return false;
}
}
async stopFlow(): Promise<void> {
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
logger.info('[BrowserOAuth] Flow explicitly stopped');
}
private async onSuccess(
providerType: BrowserOAuthProviderType,
token: GeminiCliOAuthCredentials,
) {
const accountId = this.activeAccountId || providerType;
const accountLabel = this.activeLabel;
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
logger.info(`[BrowserOAuth] Successfully completed OAuth for ${providerType}`);
const providerService = getProviderService();
const existing = await providerService.getAccount(accountId);
const nextAccount = await providerService.createAccount({
id: accountId,
vendorId: providerType,
label: accountLabel || existing?.label || 'Google Gemini',
authMode: 'oauth_browser',
baseUrl: existing?.baseUrl,
apiProtocol: existing?.apiProtocol,
model: existing?.model || GOOGLE_OAUTH_DEFAULT_MODEL,
fallbackModels: existing?.fallbackModels,
fallbackAccountIds: existing?.fallbackAccountIds,
enabled: existing?.enabled ?? true,
isDefault: existing?.isDefault ?? false,
metadata: {
...existing?.metadata,
email: token.email,
resourceUrl: GOOGLE_RUNTIME_PROVIDER_ID,
},
createdAt: existing?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
await getSecretStore().set({
type: 'oauth',
accountId,
accessToken: token.access,
refreshToken: token.refresh,
expiresAt: token.expires,
email: token.email,
subject: token.projectId,
});
await saveOAuthTokenToOpenClaw(GOOGLE_RUNTIME_PROVIDER_ID, {
access: token.access,
refresh: token.refresh,
expires: token.expires,
email: token.email,
projectId: token.projectId,
});
this.emit('oauth:success', { provider: providerType, accountId: nextAccount.id });
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:success', {
provider: providerType,
accountId: nextAccount.id,
success: true,
});
}
}
private emitError(message: string) {
this.emit('oauth:error', { message });
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:error', { message });
}
}
}
export const browserOAuthManager = new BrowserOAuthManager();

View File

@@ -111,21 +111,6 @@ export async function saveChannelConfig(
}
}
// DingTalk is a channel plugin; make sure it's explicitly allowed.
// Newer OpenClaw versions may not load non-bundled plugins when allowlist is empty.
if (channelType === 'dingtalk') {
if (!currentConfig.plugins) {
currentConfig.plugins = {};
}
currentConfig.plugins.enabled = true;
const allow = Array.isArray(currentConfig.plugins.allow)
? currentConfig.plugins.allow as string[]
: [];
if (!allow.includes('dingtalk')) {
currentConfig.plugins.allow = [...allow, 'dingtalk'];
}
}
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
if (PLUGIN_CHANNELS.includes(channelType)) {
if (!currentConfig.plugins) {

View File

@@ -12,6 +12,9 @@ export const PORTS = {
/** ClawX GUI production port (for reference) */
CLAWX_GUI: 23333,
/** Local host API server port */
CLAWX_HOST_API: 3210,
/** OpenClaw Gateway port */
OPENCLAW_GATEWAY: 18789,

View File

@@ -42,6 +42,8 @@ export type { MiniMaxRegion };
class DeviceOAuthManager extends EventEmitter {
private activeProvider: OAuthProviderType | null = null;
private activeAccountId: string | null = null;
private activeLabel: string | null = null;
private active: boolean = false;
private mainWindow: BrowserWindow | null = null;
@@ -49,14 +51,20 @@ class DeviceOAuthManager extends EventEmitter {
this.mainWindow = window;
}
async startFlow(provider: OAuthProviderType, region: MiniMaxRegion = 'global'): Promise<boolean> {
async startFlow(
provider: OAuthProviderType,
region: MiniMaxRegion = 'global',
options?: { accountId?: string; label?: string },
): Promise<boolean> {
if (this.active) {
await this.stopFlow();
}
this.active = true;
this.emit('oauth:start', { provider: provider });
this.emit('oauth:start', { provider, accountId: options?.accountId || provider });
this.activeProvider = provider;
this.activeAccountId = options?.accountId || provider;
this.activeLabel = options?.label || null;
try {
if (provider === 'minimax-portal' || provider === 'minimax-portal-cn') {
@@ -77,6 +85,8 @@ class DeviceOAuthManager extends EventEmitter {
this.emitError(error instanceof Error ? error.message : String(error));
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
return false;
}
}
@@ -84,6 +94,8 @@ class DeviceOAuthManager extends EventEmitter {
async stopFlow(): Promise<void> {
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
logger.info('[DeviceOAuth] Flow explicitly stopped');
}
@@ -194,8 +206,12 @@ class DeviceOAuthManager extends EventEmitter {
api: 'anthropic-messages' | 'openai-completions';
region?: MiniMaxRegion;
}) {
const accountId = this.activeAccountId || providerType;
const accountLabel = this.activeLabel;
this.active = false;
this.activeProvider = null;
this.activeAccountId = null;
this.activeLabel = null;
logger.info(`[DeviceOAuth] Successfully completed OAuth for ${providerType}`);
// 1. Write OAuth token to OpenClaw's auth-profiles.json in native OAuth format.
@@ -254,15 +270,15 @@ class DeviceOAuthManager extends EventEmitter {
}
// 3. Save provider record in ClawX's own store so UI shows it as configured
const existing = await getProvider(providerType);
const existing = await getProvider(accountId);
const nameMap: Record<OAuthProviderType, string> = {
'minimax-portal': 'MiniMax (Global)',
'minimax-portal-cn': 'MiniMax (CN)',
'qwen-portal': 'Qwen',
};
const providerConfig: ProviderConfig = {
id: providerType,
name: nameMap[providerType as OAuthProviderType] || providerType,
id: accountId,
name: accountLabel || nameMap[providerType as OAuthProviderType] || providerType,
type: providerType,
enabled: existing?.enabled ?? true,
baseUrl, // Save the dynamically resolved URL (Global vs CN)
@@ -274,11 +290,11 @@ class DeviceOAuthManager extends EventEmitter {
await saveProvider(providerConfig);
// 4. Emit success internally so the main process can restart the Gateway
this.emit('oauth:success', providerType);
this.emit('oauth:success', { provider: providerType, accountId });
// 5. Emit success to frontend
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:success', { provider: providerType, success: true });
this.mainWindow.webContents.send('oauth:success', { provider: providerType, accountId, success: true });
}
}
@@ -331,12 +347,14 @@ class DeviceOAuthManager extends EventEmitter {
userCode: string;
expiresIn: number;
}) {
this.emit('oauth:code', data);
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:code', data);
}
}
private emitError(message: string) {
this.emit('oauth:error', { message });
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send('oauth:error', { message });
}

View File

@@ -0,0 +1,738 @@
import { execFile, execFileSync } from 'node:child_process';
import { createHash, randomBytes } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { delimiter, dirname, join } from 'node:path';
import { getClawXConfigDir } from './paths';
const CLIENT_ID_KEYS = ['OPENCLAW_GEMINI_OAUTH_CLIENT_ID', 'GEMINI_CLI_OAUTH_CLIENT_ID'];
const CLIENT_SECRET_KEYS = [
'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET',
'GEMINI_CLI_OAUTH_CLIENT_SECRET',
];
const REDIRECT_URI = 'http://127.0.0.1:8085/oauth2callback';
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
const USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json';
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
const SCOPES = [
'https://www.googleapis.com/auth/cloud-platform',
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/userinfo.profile',
];
const TIER_FREE = 'free-tier';
const TIER_LEGACY = 'legacy-tier';
const TIER_STANDARD = 'standard-tier';
const LOCAL_GEMINI_DIR = join(getClawXConfigDir(), 'gemini-cli');
export type GeminiCliOAuthCredentials = {
access: string;
refresh: string;
expires: number;
email?: string;
projectId?: string;
};
export type GeminiCliOAuthContext = {
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
log: (msg: string) => void;
note: (message: string, title?: string) => Promise<void>;
prompt: (message: string) => Promise<string>;
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
};
export class DetailedError extends Error {
detail: string;
constructor(message: string, detail: string) {
super(message);
this.name = 'DetailedError';
this.detail = detail;
}
}
let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null;
function resolveEnv(keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) {
return value;
}
}
return undefined;
}
function findInPath(name: string): string | null {
const exts = process.platform === 'win32' ? ['.cmd', '.bat', '.exe', ''] : [''];
for (const dir of (process.env.PATH ?? '').split(delimiter)) {
if (!dir) continue;
for (const ext of exts) {
const p = join(dir, name + ext);
if (existsSync(p)) {
return p;
}
}
}
return null;
}
function findFile(dir: string, name: string, depth: number): string | null {
if (depth <= 0) {
return null;
}
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const next = join(dir, entry.name);
if (entry.isFile() && entry.name === name) {
return next;
}
if (entry.isDirectory() && !entry.name.startsWith('.')) {
const found = findFile(next, name, depth - 1);
if (found) {
return found;
}
}
}
} catch {
return null;
}
return null;
}
export function extractGeminiCliCredentials(): { clientId: string; clientSecret: string } | null {
if (cachedGeminiCliCredentials) {
return cachedGeminiCliCredentials;
}
try {
const geminiPath = findInPath('gemini');
if (!geminiPath) {
return null;
}
const resolvedPath = realpathSync(geminiPath);
const geminiCliDir = dirname(dirname(resolvedPath));
const searchPaths = [
join(
geminiCliDir,
'node_modules',
'@google',
'gemini-cli-core',
'dist',
'src',
'code_assist',
'oauth2.js',
),
join(
geminiCliDir,
'node_modules',
'@google',
'gemini-cli-core',
'dist',
'code_assist',
'oauth2.js',
),
];
let content: string | null = null;
for (const p of searchPaths) {
if (existsSync(p)) {
content = readFileSync(p, 'utf8');
break;
}
}
if (!content) {
const found = findFile(geminiCliDir, 'oauth2.js', 10);
if (found) {
content = readFileSync(found, 'utf8');
}
}
if (!content) {
return null;
}
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
if (idMatch && secretMatch) {
cachedGeminiCliCredentials = { clientId: idMatch[1], clientSecret: secretMatch[1] };
return cachedGeminiCliCredentials;
}
} catch {
return null;
}
return null;
}
function extractFromLocalInstall(): { clientId: string; clientSecret: string } | null {
const coreDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core');
if (!existsSync(coreDir)) {
return null;
}
const searchPaths = [
join(coreDir, 'dist', 'src', 'code_assist', 'oauth2.js'),
join(coreDir, 'dist', 'code_assist', 'oauth2.js'),
];
let content: string | null = null;
for (const p of searchPaths) {
if (existsSync(p)) {
content = readFileSync(p, 'utf8');
break;
}
}
if (!content) {
const found = findFile(coreDir, 'oauth2.js', 10);
if (found) {
content = readFileSync(found, 'utf8');
}
}
if (!content) {
return null;
}
const idMatch = content.match(/(\d+-[a-z0-9]+\.apps\.googleusercontent\.com)/);
const secretMatch = content.match(/(GOCSPX-[A-Za-z0-9_-]+)/);
if (idMatch && secretMatch) {
return { clientId: idMatch[1], clientSecret: secretMatch[1] };
}
return null;
}
async function installViaNpm(onProgress?: (msg: string) => void): Promise<boolean> {
const npmBin = findInPath('npm');
if (!npmBin) {
return false;
}
onProgress?.('Installing Gemini OAuth helper...');
return await new Promise((resolve) => {
const useShell = process.platform === 'win32';
const child = execFile(
npmBin,
['install', '--prefix', LOCAL_GEMINI_DIR, '@google/gemini-cli'],
{ timeout: 120_000, shell: useShell, env: { ...process.env, NODE_ENV: '' } },
(err) => {
if (err) {
onProgress?.(`Gemini helper install failed, falling back to direct download...`);
resolve(false);
} else {
cachedGeminiCliCredentials = null;
onProgress?.('Gemini OAuth helper installed');
resolve(true);
}
},
);
child.stderr?.on('data', () => {
// Suppress npm noise.
});
});
}
async function installViaDirectDownload(onProgress?: (msg: string) => void): Promise<boolean> {
try {
onProgress?.('Downloading Gemini OAuth helper...');
const metaRes = await fetch('https://registry.npmjs.org/@google/gemini-cli-core/latest');
if (!metaRes.ok) {
onProgress?.(`Failed to fetch Gemini package metadata: ${metaRes.status}`);
return false;
}
const meta = (await metaRes.json()) as { dist?: { tarball?: string } };
const tarballUrl = meta.dist?.tarball;
if (!tarballUrl) {
onProgress?.('Gemini package tarball URL missing');
return false;
}
const tarRes = await fetch(tarballUrl);
if (!tarRes.ok) {
onProgress?.(`Failed to download Gemini package: ${tarRes.status}`);
return false;
}
const buffer = Buffer.from(await tarRes.arrayBuffer());
const targetDir = join(LOCAL_GEMINI_DIR, 'node_modules', '@google', 'gemini-cli-core');
mkdirSync(targetDir, { recursive: true });
const tmpFile = join(LOCAL_GEMINI_DIR, '_tmp_gemini-cli-core.tgz');
writeFileSync(tmpFile, buffer);
try {
execFileSync('tar', ['xzf', tmpFile, '-C', targetDir, '--strip-components=1'], {
timeout: 30_000,
});
} finally {
try {
unlinkSync(tmpFile);
} catch {
// ignore
}
}
cachedGeminiCliCredentials = null;
onProgress?.('Gemini OAuth helper ready');
return true;
} catch (err) {
onProgress?.(`Direct Gemini helper download failed: ${err instanceof Error ? err.message : String(err)}`);
return false;
}
}
async function ensureOAuthClientConfig(
onProgress?: (msg: string) => void,
): Promise<{ clientId: string; clientSecret?: string }> {
const envClientId = resolveEnv(CLIENT_ID_KEYS);
const envClientSecret = resolveEnv(CLIENT_SECRET_KEYS);
if (envClientId) {
return { clientId: envClientId, clientSecret: envClientSecret };
}
const extracted = extractGeminiCliCredentials();
if (extracted) {
return extracted;
}
const localExtracted = extractFromLocalInstall();
if (localExtracted) {
return localExtracted;
}
mkdirSync(LOCAL_GEMINI_DIR, { recursive: true });
const installed = await installViaNpm(onProgress) || await installViaDirectDownload(onProgress);
if (installed) {
const installedExtracted = extractFromLocalInstall();
if (installedExtracted) {
return installedExtracted;
}
}
throw new Error(
'Unable to prepare Gemini OAuth credentials automatically. Set GEMINI_CLI_OAUTH_CLIENT_ID or try again later.',
);
}
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString('hex');
const challenge = createHash('sha256').update(verifier).digest('base64url');
return { verifier, challenge };
}
function buildAuthUrl(clientId: string, challenge: string, verifier: string): string {
const params = new URLSearchParams({
client_id: clientId,
response_type: 'code',
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(' '),
code_challenge: challenge,
code_challenge_method: 'S256',
state: verifier,
access_type: 'offline',
prompt: 'consent',
});
return `${AUTH_URL}?${params.toString()}`;
}
async function waitForLocalCallback(params: {
expectedState: string;
timeoutMs: number;
onProgress?: (message: string) => void;
}): Promise<{ code: string; state: string }> {
const port = 8085;
const hostname = '127.0.0.1';
const expectedPath = '/oauth2callback';
return new Promise((resolve, reject) => {
let timeout: NodeJS.Timeout | null = null;
const server = createServer((req, res) => {
try {
const requestUrl = new URL(req.url ?? '/', `http://${hostname}:${port}`);
if (requestUrl.pathname !== expectedPath) {
res.statusCode = 404;
res.setHeader('Content-Type', 'text/plain');
res.end('Not found');
return;
}
const error = requestUrl.searchParams.get('error');
const code = requestUrl.searchParams.get('code')?.trim();
const state = requestUrl.searchParams.get('state')?.trim();
if (error) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/plain');
res.end(`Authentication failed: ${error}`);
finish(new Error(`OAuth error: ${error}`));
return;
}
if (!code || !state) {
res.statusCode = 400;
res.setHeader('Content-Type', 'text/plain');
res.end('Missing code or state');
finish(new Error('Missing OAuth code or state'));
return;
}
if (state !== params.expectedState) {
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>Session expired</h2><p>This authorization link is from a previous attempt. Please go back to ClawX and try again.</p></body></html>",
);
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>Gemini CLI OAuth complete</h2><p>You can close this window and return to ClawX.</p></body></html>",
);
finish(undefined, { code, state });
} catch (err) {
finish(err instanceof Error ? err : new Error('OAuth callback failed'));
}
});
const finish = (err?: Error, result?: { code: string; state: string }) => {
if (timeout) {
clearTimeout(timeout);
}
try {
server.close();
} catch {
// ignore
}
if (err) {
reject(err);
} else if (result) {
resolve(result);
}
};
server.once('error', (err) => {
finish(err instanceof Error ? err : new Error('OAuth callback server error'));
});
server.listen(port, hostname, () => {
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}...`);
});
timeout = setTimeout(() => {
finish(new DetailedError(
'OAuth login timed out. The browser did not redirect back. Check if localhost:8085 is blocked.',
`Waited ${params.timeoutMs / 1000}s for callback on ${hostname}:${port}`,
));
}, params.timeoutMs);
});
}
async function getUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await fetch(USERINFO_URL, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.ok) {
const data = (await response.json()) as { email?: string };
return data.email;
}
} catch {
// ignore
}
return undefined;
}
function getDefaultTier(
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
): { id?: string } | undefined {
if (!allowedTiers?.length) {
return { id: TIER_LEGACY };
}
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
}
function isVpcScAffected(payload: unknown): boolean {
if (!payload || typeof payload !== 'object') {
return false;
}
const error = (payload as { error?: unknown }).error;
if (!error || typeof error !== 'object') {
return false;
}
const details = (error as { details?: unknown[] }).details;
if (!Array.isArray(details)) {
return false;
}
return details.some(
(item) =>
typeof item === 'object'
&& item
&& (item as { reason?: string }).reason === 'SECURITY_POLICY_VIOLATED',
);
}
async function pollOperation(
operationName: string,
headers: Record<string, string>,
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
for (let attempt = 0; attempt < 24; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5000));
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, { headers });
if (!response.ok) {
continue;
}
const data = (await response.json()) as {
done?: boolean;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (data.done) {
return data;
}
}
throw new Error('Operation polling timeout');
}
async function discoverProject(accessToken: string): Promise<string> {
const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
const headers = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'User-Agent': 'google-api-nodejs-client/9.15.1',
'X-Goog-Api-Client': 'gl-node/clawx',
};
const loadBody = {
cloudaicompanionProject: envProject,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: envProject,
},
};
let data: {
currentTier?: { id?: string };
cloudaicompanionProject?: string | { id?: string };
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
} = {};
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
method: 'POST',
headers,
body: JSON.stringify(loadBody),
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => null);
if (isVpcScAffected(errorPayload)) {
data = { currentTier: { id: TIER_STANDARD } };
} else {
throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`);
}
} else {
data = (await response.json()) as typeof data;
}
if (data.currentTier) {
const project = data.cloudaicompanionProject;
if (typeof project === 'string' && project) {
return project;
}
if (typeof project === 'object' && project?.id) {
return project.id;
}
if (envProject) {
return envProject;
}
}
const hasExistingTierButNoProject = !!data.currentTier;
const tier = hasExistingTierButNoProject ? { id: TIER_FREE } : getDefaultTier(data.allowedTiers);
const tierId = tier?.id || TIER_FREE;
if (tierId !== TIER_FREE && !envProject) {
throw new DetailedError(
'Your Google account requires a Cloud project. Please create one and set GOOGLE_CLOUD_PROJECT.',
`tierId=${tierId}, currentTier=${JSON.stringify(data.currentTier ?? null)}, allowedTiers=${JSON.stringify(data.allowedTiers)}`,
);
}
const onboardBody: Record<string, unknown> = {
tierId,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
},
};
if (tierId !== TIER_FREE && envProject) {
onboardBody.cloudaicompanionProject = envProject;
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
}
const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
method: 'POST',
headers,
body: JSON.stringify(onboardBody),
});
if (!onboardResponse.ok) {
const respText = await onboardResponse.text().catch(() => '');
throw new DetailedError(
'Google project provisioning failed. Please try again later.',
`onboardUser ${onboardResponse.status} ${onboardResponse.statusText}: ${respText}`,
);
}
let lro = (await onboardResponse.json()) as {
done?: boolean;
name?: string;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (!lro.done && lro.name) {
lro = await pollOperation(lro.name, headers);
}
const projectId = lro.response?.cloudaicompanionProject?.id;
if (projectId) {
return projectId;
}
if (envProject) {
return envProject;
}
throw new DetailedError(
'Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.',
`tierId=${tierId}, onboardResponse=${JSON.stringify(lro)}, currentTier=${JSON.stringify(data.currentTier ?? null)}`,
);
}
async function exchangeCodeForTokens(
code: string,
verifier: string,
clientConfig: { clientId: string; clientSecret?: string },
): Promise<GeminiCliOAuthCredentials> {
const { clientId, clientSecret } = clientConfig;
const body = new URLSearchParams({
client_id: clientId,
code,
grant_type: 'authorization_code',
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
});
if (clientSecret) {
body.set('client_secret', clientSecret);
}
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${errorText}`);
}
const data = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
if (!data.refresh_token) {
throw new Error('No refresh token received. Please try again.');
}
const email = await getUserEmail(data.access_token);
const projectId = await discoverProject(data.access_token);
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: data.refresh_token,
access: data.access_token,
expires: expiresAt,
projectId,
email,
};
}
export async function loginGeminiCliOAuth(
ctx: GeminiCliOAuthContext,
): Promise<GeminiCliOAuthCredentials> {
if (ctx.isRemote) {
throw new Error('Remote/manual Gemini OAuth is not implemented in ClawX yet.');
}
await ctx.note(
[
'Browser will open for Google authentication.',
'Sign in with your Google account for Gemini CLI access.',
'The callback will be captured automatically on 127.0.0.1:8085.',
].join('\n'),
'Gemini CLI OAuth',
);
ctx.progress.update('Preparing Google OAuth...');
const clientConfig = await ensureOAuthClientConfig((msg) => ctx.progress.update(msg));
const { verifier, challenge } = generatePkce();
const authUrl = buildAuthUrl(clientConfig.clientId, challenge, verifier);
ctx.progress.update('Complete sign-in in browser...');
try {
await ctx.openUrl(authUrl);
} catch {
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
}
try {
const { code } = await waitForLocalCallback({
expectedState: verifier,
timeoutMs: 5 * 60 * 1000,
onProgress: (msg) => ctx.progress.update(msg),
});
ctx.progress.update('Exchanging authorization code for tokens...');
return await exchangeCodeForTokens(code, verifier, clientConfig);
} catch (err) {
if (
err instanceof Error
&& (err.message.includes('EADDRINUSE')
|| err.message.includes('port')
|| err.message.includes('listen'))
) {
throw new Error(
'Port 8085 is in use by another process. Close the other application using port 8085 and try again.',
{ cause: err },
);
}
throw err;
}
}
// Best-effort check to help with diagnostics if the user claims gemini is installed but PATH is stale.
export function detectGeminiCliVersion(): string | null {
try {
const geminiPath = findInPath('gemini');
if (!geminiPath) {
return null;
}
return execFileSync(geminiPath, ['--version'], { encoding: 'utf8' }).trim();
} catch {
return null;
}
}

View File

@@ -26,6 +26,10 @@ import {
const AUTH_STORE_VERSION = 1;
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
function getOAuthPluginId(provider: string): string {
return `${provider}-auth`;
}
// ── Helpers ──────────────────────────────────────────────────────
/** Non-throwing async existence check (replaces existsSync). */
@@ -76,6 +80,8 @@ interface OAuthProfileEntry {
access: string;
refresh: string;
expires: number;
email?: string;
projectId?: string;
}
interface AuthProfilesStore {
@@ -155,7 +161,7 @@ async function writeOpenClawJson(config: Record<string, unknown>): Promise<void>
*/
export async function saveOAuthTokenToOpenClaw(
provider: string,
token: { access: string; refresh: string; expires: number },
token: { access: string; refresh: string; expires: number; email?: string; projectId?: string },
agentId?: string
): Promise<void> {
const agentIds = agentId ? [agentId] : await discoverAgentIds();
@@ -171,6 +177,8 @@ export async function saveOAuthTokenToOpenClaw(
access: token.access,
refresh: token.refresh,
expires: token.expires,
email: token.email,
projectId: token.projectId,
};
if (!store.order) store.order = {};
@@ -378,7 +386,10 @@ export async function setOpenClawDefaultModel(
const config = await readOpenClawJson();
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
const model = modelOverride || getProviderDefaultModel(provider);
const rawModel = modelOverride || getProviderDefaultModel(provider);
const model = rawModel
? (rawModel.startsWith(`${provider}/`) ? rawModel : `${provider}/${rawModel}`)
: undefined;
if (!model) {
console.warn(`No default model mapping for provider "${provider}"`);
return;
@@ -542,8 +553,14 @@ export async function syncProviderConfigToOpenClaw(
// Ensure extension is enabled for oauth providers to prevent gateway wiping config
if (isOpenClawOAuthPluginProviderKey(provider)) {
const plugins = (config.plugins || {}) as Record<string, unknown>;
const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : [];
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
pEntries[`${provider}-auth`] = { enabled: true };
const pluginId = getOAuthPluginId(provider);
if (!allow.includes(pluginId)) {
allow.push(pluginId);
}
pEntries[pluginId] = { enabled: true };
plugins.allow = allow;
plugins.entries = pEntries;
config.plugins = plugins;
}
@@ -563,7 +580,10 @@ export async function setOpenClawDefaultModelWithOverride(
const config = await readOpenClawJson();
ensureMoonshotKimiWebSearchCnBaseUrl(config, provider);
const model = modelOverride || getProviderDefaultModel(provider);
const rawModel = modelOverride || getProviderDefaultModel(provider);
const model = rawModel
? (rawModel.startsWith(`${provider}/`) ? rawModel : `${provider}/${rawModel}`)
: undefined;
if (!model) {
console.warn(`No default model mapping for provider "${provider}"`);
return;
@@ -622,8 +642,14 @@ export async function setOpenClawDefaultModelWithOverride(
// Ensure the extension plugin is marked as enabled in openclaw.json
if (isOpenClawOAuthPluginProviderKey(provider)) {
const plugins = (config.plugins || {}) as Record<string, unknown>;
const allow = Array.isArray(plugins.allow) ? [...plugins.allow as string[]] : [];
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
pEntries[`${provider}-auth`] = { enabled: true };
const pluginId = getOAuthPluginId(provider);
if (!allow.includes(pluginId)) {
allow.push(pluginId);
}
pEntries[pluginId] = { enabled: true };
plugins.allow = allow;
plugins.entries = pEntries;
config.plugins = plugins;
}
@@ -689,6 +715,22 @@ export async function syncGatewayTokenToConfig(token: string): Promise<void> {
auth.mode = 'token';
auth.token = token;
gateway.auth = auth;
// Packaged ClawX loads the renderer from file://, so the gateway must allow
// that origin for the chat WebSocket handshake.
const controlUi = (
gateway.controlUi && typeof gateway.controlUi === 'object'
? { ...(gateway.controlUi as Record<string, unknown>) }
: {}
) as Record<string, unknown>;
const allowedOrigins = Array.isArray(controlUi.allowedOrigins)
? (controlUi.allowedOrigins as unknown[]).filter((value): value is string => typeof value === 'string')
: [];
if (!allowedOrigins.includes('file://')) {
controlUi.allowedOrigins = [...allowedOrigins, 'file://'];
}
gateway.controlUi = controlUi;
if (!gateway.mode) gateway.mode = 'local';
config.gateway = gateway;

View File

@@ -1,147 +1,25 @@
/**
* Provider Registry — single source of truth for backend provider metadata.
* Centralizes env var mappings, default models, and OpenClaw provider configs.
*
* NOTE: When adding a new provider type, also update src/lib/providers.ts
* Backend compatibility layer around the shared provider registry.
*/
export const BUILTIN_PROVIDER_TYPES = [
'anthropic',
'openai',
'google',
'openrouter',
'ark',
'moonshot',
'siliconflow',
'minimax-portal',
'minimax-portal-cn',
'qwen-portal',
'ollama',
] as const;
export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number];
export type ProviderType = BuiltinProviderType | 'custom';
export {
BUILTIN_PROVIDER_TYPES,
type BuiltinProviderType,
type ProviderType,
} from '../shared/providers/types';
import {
type ProviderBackendConfig,
type ProviderModelEntry,
} from '../shared/providers/types';
import {
getKeyableProviderTypes as getSharedKeyableProviderTypes,
getProviderBackendConfig,
getProviderDefaultModel as getSharedProviderDefaultModel,
getProviderEnvVar as getSharedProviderEnvVar,
} from '../shared/providers/registry';
interface ProviderModelEntry extends Record<string, unknown> {
id: string;
name: string;
}
interface ProviderBackendMeta {
envVar?: string;
defaultModel?: string;
/** OpenClaw models.providers config (omit for built-in providers like anthropic) */
providerConfig?: {
baseUrl: string;
api: string;
apiKeyEnv: string;
models?: ProviderModelEntry[];
headers?: Record<string, string>;
};
}
const REGISTRY: Record<string, ProviderBackendMeta> = {
anthropic: {
envVar: 'ANTHROPIC_API_KEY',
defaultModel: 'anthropic/claude-opus-4-6',
// anthropic is built-in to OpenClaw's model registry, no provider config needed
},
openai: {
envVar: 'OPENAI_API_KEY',
defaultModel: 'openai/gpt-5.2',
providerConfig: {
baseUrl: 'https://api.openai.com/v1',
api: 'openai-responses',
apiKeyEnv: 'OPENAI_API_KEY',
},
},
google: {
envVar: 'GEMINI_API_KEY',
defaultModel: 'google/gemini-3.1-pro-preview',
// google is built-in to OpenClaw's pi-ai catalog, no providerConfig needed.
// Adding models.providers.google overrides the built-in and can break Gemini.
},
openrouter: {
envVar: 'OPENROUTER_API_KEY',
defaultModel: 'openrouter/anthropic/claude-opus-4.6',
providerConfig: {
baseUrl: 'https://openrouter.ai/api/v1',
api: 'openai-completions',
apiKeyEnv: 'OPENROUTER_API_KEY',
headers: {
'HTTP-Referer': 'https://claw-x.com',
'X-Title': 'ClawX',
},
},
},
ark: {
envVar: 'ARK_API_KEY',
providerConfig: {
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
api: 'openai-completions',
apiKeyEnv: 'ARK_API_KEY',
},
},
moonshot: {
envVar: 'MOONSHOT_API_KEY',
defaultModel: 'moonshot/kimi-k2.5',
providerConfig: {
baseUrl: 'https://api.moonshot.cn/v1',
api: 'openai-completions',
apiKeyEnv: 'MOONSHOT_API_KEY',
models: [
{
id: 'kimi-k2.5',
name: 'Kimi K2.5',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 256000,
maxTokens: 8192,
},
],
},
},
siliconflow: {
envVar: 'SILICONFLOW_API_KEY',
defaultModel: 'siliconflow/deepseek-ai/DeepSeek-V3',
providerConfig: {
baseUrl: 'https://api.siliconflow.cn/v1',
api: 'openai-completions',
apiKeyEnv: 'SILICONFLOW_API_KEY',
},
},
'minimax-portal': {
envVar: 'MINIMAX_API_KEY',
defaultModel: 'minimax-portal/MiniMax-M2.5',
providerConfig: {
baseUrl: 'https://api.minimax.io/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'MINIMAX_API_KEY',
},
},
'minimax-portal-cn': {
envVar: 'MINIMAX_CN_API_KEY',
defaultModel: 'minimax-portal/MiniMax-M2.5',
providerConfig: {
baseUrl: 'https://api.minimaxi.com/anthropic',
api: 'anthropic-messages',
apiKeyEnv: 'MINIMAX_CN_API_KEY',
},
},
'qwen-portal': {
envVar: 'QWEN_API_KEY',
defaultModel: 'qwen-portal/coder-model',
providerConfig: {
baseUrl: 'https://portal.qwen.ai/v1',
api: 'openai-completions',
apiKeyEnv: 'QWEN_API_KEY',
},
},
custom: {
envVar: 'CUSTOM_API_KEY',
},
// Additional providers with env var mappings but no default model
// Additional env-backed providers that are not yet exposed in the UI.
const EXTRA_ENV_ONLY_PROVIDERS: Record<string, { envVar: string }> = {
groq: { envVar: 'GROQ_API_KEY' },
deepgram: { envVar: 'DEEPGRAM_API_KEY' },
cerebras: { envVar: 'CEREBRAS_API_KEY' },
@@ -151,26 +29,25 @@ const REGISTRY: Record<string, ProviderBackendMeta> = {
/** Get the environment variable name for a provider type */
export function getProviderEnvVar(type: string): string | undefined {
return REGISTRY[type]?.envVar;
return getSharedProviderEnvVar(type) ?? EXTRA_ENV_ONLY_PROVIDERS[type]?.envVar;
}
/** Get all environment variable names for a provider type (primary first). */
export function getProviderEnvVars(type: string): string[] {
const meta = REGISTRY[type];
if (!meta?.envVar) return [];
return [meta.envVar];
const envVar = getProviderEnvVar(type);
return envVar ? [envVar] : [];
}
/** Get the default model string for a provider type */
export function getProviderDefaultModel(type: string): string | undefined {
return REGISTRY[type]?.defaultModel;
return getSharedProviderDefaultModel(type);
}
/** Get the OpenClaw provider config (baseUrl, api, apiKeyEnv, models, headers) */
export function getProviderConfig(
type: string
): { baseUrl: string; api: string; apiKeyEnv: string; models?: ProviderModelEntry[]; headers?: Record<string, string> } | undefined {
return REGISTRY[type]?.providerConfig;
return getProviderBackendConfig(type) as ProviderBackendConfig | undefined;
}
/**
@@ -178,7 +55,5 @@ export function getProviderConfig(
* Used by GatewayManager to inject API keys as env vars.
*/
export function getKeyableProviderTypes(): string[] {
return Object.entries(REGISTRY)
.filter(([, meta]) => meta.envVar)
.map(([type]) => type);
return [...getSharedKeyableProviderTypes(), ...Object.keys(EXTRA_ENV_ONLY_PROVIDERS)];
}

View File

@@ -1,32 +1,30 @@
/**
* Provider Storage
* Manages provider configurations and API keys.
* Keys are stored in plain text alongside provider configs in a single electron-store.
* This file remains the legacy compatibility layer while the app migrates to
* account-based provider storage and a dedicated secret-store abstraction.
*/
import { BUILTIN_PROVIDER_TYPES, type ProviderType } from './provider-registry';
import { getActiveOpenClawProviders } from './openclaw-auth';
import {
deleteProviderAccount,
getProviderAccount,
listProviderAccounts,
providerAccountToConfig,
providerConfigToAccount,
saveProviderAccount,
setDefaultProviderAccount,
} from '../services/providers/provider-store';
import { ensureProviderStoreMigrated } from '../services/providers/provider-migration';
import { getClawXProviderStore } from '../services/providers/store-instance';
import {
deleteProviderSecret,
getProviderSecret,
setProviderSecret,
} from '../services/secrets/secret-store';
import { getOpenClawProviderKeyForType } from './provider-keys';
// Lazy-load electron-store (ESM module)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let providerStore: any = null;
async function getProviderStore() {
if (!providerStore) {
const Store = (await import('electron-store')).default;
providerStore = new Store({
name: 'clawx-providers',
defaults: {
providers: {} as Record<string, ProviderConfig>,
apiKeys: {} as Record<string, string>,
defaultProvider: null as string | null,
},
});
}
return providerStore;
}
/**
* Provider configuration
*/
@@ -50,10 +48,16 @@ export interface ProviderConfig {
*/
export async function storeApiKey(providerId: string, apiKey: string): Promise<boolean> {
try {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
keys[providerId] = apiKey;
s.set('apiKeys', keys);
await setProviderSecret({
type: 'api_key',
accountId: providerId,
apiKey,
});
return true;
} catch (error) {
console.error('Failed to store API key:', error);
@@ -66,7 +70,16 @@ export async function storeApiKey(providerId: string, apiKey: string): Promise<b
*/
export async function getApiKey(providerId: string): Promise<string | null> {
try {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const secret = await getProviderSecret(providerId);
if (secret?.type === 'api_key') {
return secret.apiKey;
}
if (secret?.type === 'local') {
return secret.apiKey ?? null;
}
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
return keys[providerId] || null;
} catch (error) {
@@ -80,10 +93,12 @@ export async function getApiKey(providerId: string): Promise<string | null> {
*/
export async function deleteApiKey(providerId: string): Promise<boolean> {
try {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
delete keys[providerId];
s.set('apiKeys', keys);
await deleteProviderSecret(providerId);
return true;
} catch (error) {
console.error('Failed to delete API key:', error);
@@ -95,7 +110,13 @@ export async function deleteApiKey(providerId: string): Promise<boolean> {
* Check if an API key exists for a provider
*/
export async function hasApiKey(providerId: string): Promise<boolean> {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const secret = await getProviderSecret(providerId);
if (secret?.type === 'api_key') {
return true;
}
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
return providerId in keys;
}
@@ -104,7 +125,8 @@ export async function hasApiKey(providerId: string): Promise<boolean> {
* List all provider IDs that have stored keys
*/
export async function listStoredKeyIds(): Promise<string[]> {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const keys = (s.get('apiKeys') || {}) as Record<string, string>;
return Object.keys(keys);
}
@@ -115,28 +137,47 @@ export async function listStoredKeyIds(): Promise<string[]> {
* Save a provider configuration
*/
export async function saveProvider(config: ProviderConfig): Promise<void> {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const providers = s.get('providers') as Record<string, ProviderConfig>;
providers[config.id] = config;
s.set('providers', providers);
const defaultProviderId = (s.get('defaultProvider') ?? null) as string | null;
await saveProviderAccount(
providerConfigToAccount(config, { isDefault: defaultProviderId === config.id }),
);
}
/**
* Get a provider configuration
*/
export async function getProvider(providerId: string): Promise<ProviderConfig | null> {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const providers = s.get('providers') as Record<string, ProviderConfig>;
return providers[providerId] || null;
if (providers[providerId]) {
return providers[providerId];
}
const account = await getProviderAccount(providerId);
return account ? providerAccountToConfig(account) : null;
}
/**
* Get all provider configurations
*/
export async function getAllProviders(): Promise<ProviderConfig[]> {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
const providers = s.get('providers') as Record<string, ProviderConfig>;
return Object.values(providers);
const legacyProviders = Object.values(providers);
if (legacyProviders.length > 0) {
return legacyProviders;
}
const accounts = await listProviderAccounts();
return accounts.map(providerAccountToConfig);
}
/**
@@ -144,18 +185,21 @@ export async function getAllProviders(): Promise<ProviderConfig[]> {
*/
export async function deleteProvider(providerId: string): Promise<boolean> {
try {
await ensureProviderStoreMigrated();
// Delete the API key
await deleteApiKey(providerId);
// Delete the provider config
const s = await getProviderStore();
const s = await getClawXProviderStore();
const providers = s.get('providers') as Record<string, ProviderConfig>;
delete providers[providerId];
s.set('providers', providers);
await deleteProviderAccount(providerId);
// Clear default if this was the default
if (s.get('defaultProvider') === providerId) {
s.delete('defaultProvider');
s.delete('defaultProviderAccountId');
}
return true;
@@ -169,16 +213,20 @@ export async function deleteProvider(providerId: string): Promise<boolean> {
* Set the default provider
*/
export async function setDefaultProvider(providerId: string): Promise<void> {
const s = await getProviderStore();
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
s.set('defaultProvider', providerId);
await setDefaultProviderAccount(providerId);
}
/**
* Get the default provider
*/
export async function getDefaultProvider(): Promise<string | undefined> {
const s = await getProviderStore();
return s.get('defaultProvider') as string | undefined;
await ensureProviderStoreMigrated();
const s = await getClawXProviderStore();
return (s.get('defaultProvider') as string | undefined)
?? (s.get('defaultProviderAccountId') as string | undefined);
}
/**