committed by
GitHub
Unverified
parent
3d804a9f5e
commit
2c5c82bb74
11
electron/api/context.ts
Normal file
11
electron/api/context.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { ClawHubService } from '../gateway/clawhub';
|
||||
import type { HostEventBus } from './event-bus';
|
||||
|
||||
export interface HostApiContext {
|
||||
gatewayManager: GatewayManager;
|
||||
clawHubService: ClawHubService;
|
||||
eventBus: HostEventBus;
|
||||
mainWindow: BrowserWindow | null;
|
||||
}
|
||||
36
electron/api/event-bus.ts
Normal file
36
electron/api/event-bus.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ServerResponse } from 'http';
|
||||
|
||||
type EventPayload = unknown;
|
||||
|
||||
export class HostEventBus {
|
||||
private readonly clients = new Set<ServerResponse>();
|
||||
|
||||
addSseClient(res: ServerResponse): void {
|
||||
this.clients.add(res);
|
||||
res.on('close', () => {
|
||||
this.clients.delete(res);
|
||||
});
|
||||
}
|
||||
|
||||
emit(eventName: string, payload: EventPayload): void {
|
||||
const message = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||
for (const client of this.clients) {
|
||||
try {
|
||||
client.write(message);
|
||||
} catch {
|
||||
this.clients.delete(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
closeAll(): void {
|
||||
for (const client of this.clients) {
|
||||
try {
|
||||
client.end();
|
||||
} catch {
|
||||
// Ignore individual client close failures.
|
||||
}
|
||||
}
|
||||
this.clients.clear();
|
||||
}
|
||||
}
|
||||
39
electron/api/route-utils.ts
Normal file
39
electron/api/route-utils.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
|
||||
export async function parseJsonBody<T>(req: IncomingMessage): Promise<T> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of req) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
||||
if (!raw) {
|
||||
return {} as T;
|
||||
}
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
export function setCorsHeaders(res: ServerResponse): void {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
}
|
||||
|
||||
export function sendJson(res: ServerResponse, statusCode: number, payload: unknown): void {
|
||||
setCorsHeaders(res);
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function sendNoContent(res: ServerResponse): void {
|
||||
setCorsHeaders(res);
|
||||
res.statusCode = 204;
|
||||
res.end();
|
||||
}
|
||||
|
||||
export function sendText(res: ServerResponse, statusCode: number, text: string): void {
|
||||
setCorsHeaders(res);
|
||||
res.statusCode = statusCode;
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
res.end(text);
|
||||
}
|
||||
32
electron/api/routes/app.ts
Normal file
32
electron/api/routes/app.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { setCorsHeaders, sendNoContent } from '../route-utils';
|
||||
|
||||
export async function handleAppRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/events' && req.method === 'GET') {
|
||||
setCorsHeaders(res);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write(': connected\n\n');
|
||||
ctx.eventBus.addSseClient(res);
|
||||
// Send a current-state snapshot immediately so renderer subscribers do not
|
||||
// miss lifecycle transitions that happened before the SSE connection opened.
|
||||
res.write(`event: gateway:status\ndata: ${JSON.stringify(ctx.gatewayManager.getStatus())}\n\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
sendNoContent(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
167
electron/api/routes/channels.ts
Normal file
167
electron/api/routes/channels.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { app } from 'electron';
|
||||
import { existsSync, cpSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
deleteChannelConfig,
|
||||
getChannelFormValues,
|
||||
listConfiguredChannels,
|
||||
saveChannelConfig,
|
||||
setChannelEnabled,
|
||||
validateChannelConfig,
|
||||
validateChannelCredentials,
|
||||
} from '../../utils/channel-config';
|
||||
import { whatsAppLoginManager } from '../../utils/whatsapp-login';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> {
|
||||
const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk');
|
||||
const targetManifest = join(targetDir, 'openclaw.plugin.json');
|
||||
|
||||
if (existsSync(targetManifest)) {
|
||||
return { installed: true };
|
||||
}
|
||||
|
||||
const candidateSources = app.isPackaged
|
||||
? [
|
||||
join(process.resourcesPath, 'openclaw-plugins', 'dingtalk'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', 'dingtalk'),
|
||||
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', 'dingtalk'),
|
||||
]
|
||||
: [
|
||||
join(app.getAppPath(), 'build', 'openclaw-plugins', 'dingtalk'),
|
||||
join(process.cwd(), 'build', 'openclaw-plugins', 'dingtalk'),
|
||||
join(__dirname, '../../../build/openclaw-plugins/dingtalk'),
|
||||
];
|
||||
|
||||
const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json')));
|
||||
if (!sourceDir) {
|
||||
return {
|
||||
installed: false,
|
||||
warning: `Bundled DingTalk plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
cpSync(sourceDir, targetDir, { recursive: true, dereference: true });
|
||||
if (!existsSync(targetManifest)) {
|
||||
return { installed: false, warning: 'Failed to install DingTalk plugin mirror (manifest missing).' };
|
||||
}
|
||||
return { installed: true };
|
||||
} catch {
|
||||
return { installed: false, warning: 'Failed to install bundled DingTalk plugin mirror' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleChannelRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/channels/configured' && req.method === 'GET') {
|
||||
sendJson(res, 200, { success: true, channels: await listConfiguredChannels() });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/config/validate' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string }>(req);
|
||||
sendJson(res, 200, { success: true, ...(await validateChannelConfig(body.channelType)) });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/credentials/validate' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string; config: Record<string, string> }>(req);
|
||||
sendJson(res, 200, { success: true, ...(await validateChannelCredentials(body.channelType, body.config)) });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/whatsapp/start' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ accountId: string }>(req);
|
||||
await whatsAppLoginManager.start(body.accountId);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/whatsapp/cancel' && req.method === 'POST') {
|
||||
try {
|
||||
await whatsAppLoginManager.stop();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/config' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string; config: Record<string, unknown> }>(req);
|
||||
if (body.channelType === 'dingtalk') {
|
||||
const installResult = await ensureDingTalkPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'DingTalk plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
await saveChannelConfig(body.channelType, body.config);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/config/enabled' && req.method === 'PUT') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string; enabled: boolean }>(req);
|
||||
await setChannelEnabled(body.channelType, body.enabled);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/channels/config/') && req.method === 'GET') {
|
||||
try {
|
||||
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
values: await getChannelFormValues(channelType),
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/channels/config/') && req.method === 'DELETE') {
|
||||
try {
|
||||
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
|
||||
await deleteChannelConfig(channelType);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ctx;
|
||||
return false;
|
||||
}
|
||||
168
electron/api/routes/cron.ts
Normal file
168
electron/api/routes/cron.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
interface GatewayCronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
schedule: { kind: string; expr?: string; everyMs?: number; at?: string; tz?: string };
|
||||
payload: { kind: string; message?: string; text?: string };
|
||||
delivery?: { mode: string; channel?: string; to?: string };
|
||||
sessionTarget?: string;
|
||||
state: {
|
||||
nextRunAtMs?: number;
|
||||
lastRunAtMs?: number;
|
||||
lastStatus?: string;
|
||||
lastError?: string;
|
||||
lastDurationMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
function transformCronJob(job: GatewayCronJob) {
|
||||
const message = job.payload?.message || job.payload?.text || '';
|
||||
const channelType = job.delivery?.channel;
|
||||
const target = channelType
|
||||
? { channelType, channelId: channelType, channelName: channelType }
|
||||
: undefined;
|
||||
const lastRun = job.state?.lastRunAtMs
|
||||
? {
|
||||
time: new Date(job.state.lastRunAtMs).toISOString(),
|
||||
success: job.state.lastStatus === 'ok',
|
||||
error: job.state.lastError,
|
||||
duration: job.state.lastDurationMs,
|
||||
}
|
||||
: undefined;
|
||||
const nextRun = job.state?.nextRunAtMs
|
||||
? new Date(job.state.nextRunAtMs).toISOString()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
name: job.name,
|
||||
message,
|
||||
schedule: job.schedule,
|
||||
target,
|
||||
enabled: job.enabled,
|
||||
createdAt: new Date(job.createdAtMs).toISOString(),
|
||||
updatedAt: new Date(job.updatedAtMs).toISOString(),
|
||||
lastRun,
|
||||
nextRun,
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleCronRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/cron/jobs' && req.method === 'GET') {
|
||||
try {
|
||||
const result = await ctx.gatewayManager.rpc('cron.list', { includeDisabled: true });
|
||||
const data = result as { jobs?: GatewayCronJob[] };
|
||||
const jobs = data?.jobs ?? [];
|
||||
for (const job of jobs) {
|
||||
const isIsolatedAgent =
|
||||
(job.sessionTarget === 'isolated' || !job.sessionTarget) &&
|
||||
job.payload?.kind === 'agentTurn';
|
||||
const needsRepair =
|
||||
isIsolatedAgent &&
|
||||
job.delivery?.mode === 'announce' &&
|
||||
!job.delivery?.channel;
|
||||
if (needsRepair) {
|
||||
try {
|
||||
await ctx.gatewayManager.rpc('cron.update', {
|
||||
id: job.id,
|
||||
patch: { delivery: { mode: 'none' } },
|
||||
});
|
||||
job.delivery = { mode: 'none' };
|
||||
if (job.state?.lastError?.includes('Channel is required')) {
|
||||
job.state.lastError = undefined;
|
||||
job.state.lastStatus = 'ok';
|
||||
}
|
||||
} catch {
|
||||
// ignore per-job repair failure
|
||||
}
|
||||
}
|
||||
}
|
||||
sendJson(res, 200, jobs.map(transformCronJob));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/cron/jobs' && req.method === 'POST') {
|
||||
try {
|
||||
const input = await parseJsonBody<{ name: string; message: string; schedule: string; enabled?: boolean }>(req);
|
||||
const result = await ctx.gatewayManager.rpc('cron.add', {
|
||||
name: input.name,
|
||||
schedule: { kind: 'cron', expr: input.schedule },
|
||||
payload: { kind: 'agentTurn', message: input.message },
|
||||
enabled: input.enabled ?? true,
|
||||
wakeMode: 'next-heartbeat',
|
||||
sessionTarget: 'isolated',
|
||||
delivery: { mode: 'none' },
|
||||
});
|
||||
sendJson(res, 200, result && typeof result === 'object' ? transformCronJob(result as GatewayCronJob) : result);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'PUT') {
|
||||
try {
|
||||
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
|
||||
const input = await parseJsonBody<Record<string, unknown>>(req);
|
||||
const patch = { ...input };
|
||||
if (typeof patch.schedule === 'string') {
|
||||
patch.schedule = { kind: 'cron', expr: patch.schedule };
|
||||
}
|
||||
if (typeof patch.message === 'string') {
|
||||
patch.payload = { kind: 'agentTurn', message: patch.message };
|
||||
delete patch.message;
|
||||
}
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id, patch }));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/cron/jobs/') && req.method === 'DELETE') {
|
||||
try {
|
||||
const id = decodeURIComponent(url.pathname.slice('/api/cron/jobs/'.length));
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.remove', { id }));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/cron/toggle' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ id: string; enabled: boolean }>(req);
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.update', { id: body.id, patch: { enabled: body.enabled } }));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/cron/trigger' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ id: string }>(req);
|
||||
sendJson(res, 200, await ctx.gatewayManager.rpc('cron.run', { id: body.id, mode: 'force' }));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
200
electron/api/routes/files.ts
Normal file
200
electron/api/routes/files.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { dialog, nativeImage } from 'electron';
|
||||
import crypto from 'node:crypto';
|
||||
import { extname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
const EXT_MIME_MAP: Record<string, string> = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.bmp': 'image/bmp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mov': 'video/quicktime',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mkv': 'video/x-matroska',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.flac': 'audio/flac',
|
||||
'.pdf': 'application/pdf',
|
||||
'.zip': 'application/zip',
|
||||
'.gz': 'application/gzip',
|
||||
'.tar': 'application/x-tar',
|
||||
'.7z': 'application/x-7z-compressed',
|
||||
'.rar': 'application/vnd.rar',
|
||||
'.json': 'application/json',
|
||||
'.xml': 'application/xml',
|
||||
'.csv': 'text/csv',
|
||||
'.txt': 'text/plain',
|
||||
'.md': 'text/markdown',
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'text/javascript',
|
||||
'.ts': 'text/typescript',
|
||||
'.py': 'text/x-python',
|
||||
};
|
||||
|
||||
function getMimeType(ext: string): string {
|
||||
return EXT_MIME_MAP[ext.toLowerCase()] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function mimeToExt(mimeType: string): string {
|
||||
for (const [ext, mime] of Object.entries(EXT_MIME_MAP)) {
|
||||
if (mime === mimeType) return ext;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound');
|
||||
|
||||
async function generateImagePreview(filePath: string, mimeType: string): Promise<string | null> {
|
||||
try {
|
||||
const img = nativeImage.createFromPath(filePath);
|
||||
if (img.isEmpty()) return null;
|
||||
const size = img.getSize();
|
||||
const maxDim = 512;
|
||||
if (size.width > maxDim || size.height > maxDim) {
|
||||
const resized = size.width >= size.height
|
||||
? img.resize({ width: maxDim })
|
||||
: img.resize({ height: maxDim });
|
||||
return `data:image/png;base64,${resized.toPNG().toString('base64')}`;
|
||||
}
|
||||
const { readFile } = await import('node:fs/promises');
|
||||
const buf = await readFile(filePath);
|
||||
return `data:${mimeType};base64,${buf.toString('base64')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleFileRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/files/stage-paths' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ filePaths: string[] }>(req);
|
||||
const fsP = await import('node:fs/promises');
|
||||
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
|
||||
const results = [];
|
||||
for (const filePath of body.filePaths) {
|
||||
const id = crypto.randomUUID();
|
||||
const ext = extname(filePath);
|
||||
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
|
||||
await fsP.copyFile(filePath, stagedPath);
|
||||
const s = await fsP.stat(stagedPath);
|
||||
const mimeType = getMimeType(ext);
|
||||
const fileName = filePath.split(/[\\/]/).pop() || 'file';
|
||||
const preview = mimeType.startsWith('image/')
|
||||
? await generateImagePreview(stagedPath, mimeType)
|
||||
: null;
|
||||
results.push({ id, fileName, mimeType, fileSize: s.size, stagedPath, preview });
|
||||
}
|
||||
sendJson(res, 200, results);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/files/stage-buffer' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ base64: string; fileName: string; mimeType: string }>(req);
|
||||
const fsP = await import('node:fs/promises');
|
||||
await fsP.mkdir(OUTBOUND_DIR, { recursive: true });
|
||||
const id = crypto.randomUUID();
|
||||
const ext = extname(body.fileName) || mimeToExt(body.mimeType);
|
||||
const stagedPath = join(OUTBOUND_DIR, `${id}${ext}`);
|
||||
const buffer = Buffer.from(body.base64, 'base64');
|
||||
await fsP.writeFile(stagedPath, buffer);
|
||||
const mimeType = body.mimeType || getMimeType(ext);
|
||||
const preview = mimeType.startsWith('image/')
|
||||
? await generateImagePreview(stagedPath, mimeType)
|
||||
: null;
|
||||
sendJson(res, 200, {
|
||||
id,
|
||||
fileName: body.fileName,
|
||||
mimeType,
|
||||
fileSize: buffer.length,
|
||||
stagedPath,
|
||||
preview,
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/files/thumbnails' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ paths: Array<{ filePath: string; mimeType: string }> }>(req);
|
||||
const fsP = await import('node:fs/promises');
|
||||
const results: Record<string, { preview: string | null; fileSize: number }> = {};
|
||||
for (const { filePath, mimeType } of body.paths) {
|
||||
try {
|
||||
const s = await fsP.stat(filePath);
|
||||
const preview = mimeType.startsWith('image/')
|
||||
? await generateImagePreview(filePath, mimeType)
|
||||
: null;
|
||||
results[filePath] = { preview, fileSize: s.size };
|
||||
} catch {
|
||||
results[filePath] = { preview: null, fileSize: 0 };
|
||||
}
|
||||
}
|
||||
sendJson(res, 200, results);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/files/save-image' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{
|
||||
base64?: string;
|
||||
mimeType?: string;
|
||||
filePath?: string;
|
||||
defaultFileName: string;
|
||||
}>(req);
|
||||
const ext = body.defaultFileName.includes('.')
|
||||
? body.defaultFileName.split('.').pop()!
|
||||
: (body.mimeType?.split('/')[1] || 'png');
|
||||
const result = await dialog.showSaveDialog({
|
||||
defaultPath: join(homedir(), 'Downloads', body.defaultFileName),
|
||||
filters: [
|
||||
{ name: 'Images', extensions: [ext, 'png', 'jpg', 'jpeg', 'webp', 'gif'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
if (result.canceled || !result.filePath) {
|
||||
sendJson(res, 200, { success: false });
|
||||
return true;
|
||||
}
|
||||
const fsP = await import('node:fs/promises');
|
||||
if (body.filePath) {
|
||||
await fsP.copyFile(body.filePath, result.filePath);
|
||||
} else if (body.base64) {
|
||||
await fsP.writeFile(result.filePath, Buffer.from(body.base64, 'base64'));
|
||||
} else {
|
||||
sendJson(res, 400, { success: false, error: 'No image data provided' });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, { success: true, savedPath: result.filePath });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
129
electron/api/routes/gateway.ts
Normal file
129
electron/api/routes/gateway.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { PORTS } from '../../utils/config';
|
||||
import { getSetting } from '../../utils/store';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
export async function handleGatewayRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/app/gateway-info' && req.method === 'GET') {
|
||||
const status = ctx.gatewayManager.getStatus();
|
||||
const token = await getSetting('gatewayToken');
|
||||
const port = status.port || PORTS.OPENCLAW_GATEWAY;
|
||||
sendJson(res, 200, {
|
||||
wsUrl: `ws://127.0.0.1:${port}/ws`,
|
||||
token,
|
||||
port,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/status' && req.method === 'GET') {
|
||||
sendJson(res, 200, ctx.gatewayManager.getStatus());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/health' && req.method === 'GET') {
|
||||
const health = await ctx.gatewayManager.checkHealth();
|
||||
sendJson(res, 200, health);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/start' && req.method === 'POST') {
|
||||
try {
|
||||
await ctx.gatewayManager.start();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/stop' && req.method === 'POST') {
|
||||
try {
|
||||
await ctx.gatewayManager.stop();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/restart' && req.method === 'POST') {
|
||||
try {
|
||||
await ctx.gatewayManager.restart();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/gateway/control-ui' && req.method === 'GET') {
|
||||
try {
|
||||
const status = ctx.gatewayManager.getStatus();
|
||||
const token = await getSetting('gatewayToken');
|
||||
const port = status.port || PORTS.OPENCLAW_GATEWAY;
|
||||
const urlValue = `http://127.0.0.1:${port}/?token=${encodeURIComponent(token)}`;
|
||||
sendJson(res, 200, { success: true, url: urlValue, token, port });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/chat/send-with-media' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
deliver?: boolean;
|
||||
idempotencyKey: string;
|
||||
media?: Array<{ filePath: string; mimeType: string; fileName: string }>;
|
||||
}>(req);
|
||||
const VISION_MIME_TYPES = new Set([
|
||||
'image/png', 'image/jpeg', 'image/bmp', 'image/webp',
|
||||
]);
|
||||
const imageAttachments: Array<{ content: string; mimeType: string; fileName: string }> = [];
|
||||
const fileReferences: string[] = [];
|
||||
if (body.media && body.media.length > 0) {
|
||||
const fsP = await import('node:fs/promises');
|
||||
for (const m of body.media) {
|
||||
fileReferences.push(`[media attached: ${m.filePath} (${m.mimeType}) | ${m.filePath}]`);
|
||||
if (VISION_MIME_TYPES.has(m.mimeType)) {
|
||||
const fileBuffer = await fsP.readFile(m.filePath);
|
||||
imageAttachments.push({
|
||||
content: fileBuffer.toString('base64'),
|
||||
mimeType: m.mimeType,
|
||||
fileName: m.fileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message = fileReferences.length > 0
|
||||
? [body.message, ...fileReferences].filter(Boolean).join('\n')
|
||||
: body.message;
|
||||
const rpcParams: Record<string, unknown> = {
|
||||
sessionKey: body.sessionKey,
|
||||
message,
|
||||
deliver: body.deliver ?? false,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
};
|
||||
if (imageAttachments.length > 0) {
|
||||
rpcParams.attachments = imageAttachments;
|
||||
}
|
||||
const result = await ctx.gatewayManager.rpc('chat.send', rpcParams, 120000);
|
||||
sendJson(res, 200, { success: true, result });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
29
electron/api/routes/logs.ts
Normal file
29
electron/api/routes/logs.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { logger } from '../../utils/logger';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { sendJson } from '../route-utils';
|
||||
|
||||
export async function handleLogRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/logs' && req.method === 'GET') {
|
||||
const tailLines = Number(url.searchParams.get('tailLines') || '100');
|
||||
sendJson(res, 200, { content: await logger.readLogFile(Number.isFinite(tailLines) ? tailLines : 100) });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/logs/dir' && req.method === 'GET') {
|
||||
sendJson(res, 200, { dir: logger.getLogDir() });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/logs/files' && req.method === 'GET') {
|
||||
sendJson(res, 200, { files: await logger.listLogFiles() });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
295
electron/api/routes/providers.ts
Normal file
295
electron/api/routes/providers.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import {
|
||||
deleteApiKey,
|
||||
deleteProvider,
|
||||
getAllProvidersWithKeyInfo,
|
||||
getApiKey,
|
||||
getDefaultProvider,
|
||||
getProvider,
|
||||
hasApiKey,
|
||||
saveProvider,
|
||||
setDefaultProvider,
|
||||
storeApiKey,
|
||||
type ProviderConfig,
|
||||
} from '../../utils/secure-storage';
|
||||
import {
|
||||
getProviderConfig,
|
||||
} from '../../utils/provider-registry';
|
||||
import { deviceOAuthManager, type OAuthProviderType } from '../../utils/device-oauth';
|
||||
import { browserOAuthManager, type BrowserOAuthProviderType } from '../../utils/browser-oauth';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
import {
|
||||
syncDefaultProviderToRuntime,
|
||||
syncDeletedProviderApiKeyToRuntime,
|
||||
syncDeletedProviderToRuntime,
|
||||
syncProviderApiKeyToRuntime,
|
||||
syncSavedProviderToRuntime,
|
||||
syncUpdatedProviderToRuntime,
|
||||
} from '../../services/providers/provider-runtime-sync';
|
||||
import { validateApiKeyWithProvider } from '../../services/providers/provider-validation';
|
||||
import { getProviderService } from '../../services/providers/provider-service';
|
||||
import { providerAccountToConfig } from '../../services/providers/provider-store';
|
||||
import type { ProviderAccount } from '../../shared/providers/types';
|
||||
|
||||
export async function handleProviderRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
const providerService = getProviderService();
|
||||
|
||||
if (url.pathname === '/api/provider-vendors' && req.method === 'GET') {
|
||||
sendJson(res, 200, await providerService.listVendors());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts' && req.method === 'GET') {
|
||||
sendJson(res, 200, await providerService.listAccounts());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ account: ProviderAccount; apiKey?: string }>(req);
|
||||
const account = await providerService.createAccount(body.account, body.apiKey);
|
||||
await syncSavedProviderToRuntime(providerAccountToConfig(account), body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true, account });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts/default' && req.method === 'GET') {
|
||||
sendJson(res, 200, { accountId: await providerService.getDefaultAccountId() ?? null });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/provider-accounts/default' && req.method === 'PUT') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ accountId: string }>(req);
|
||||
await providerService.setDefaultAccount(body.accountId);
|
||||
await syncDefaultProviderToRuntime(body.accountId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'GET') {
|
||||
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
||||
sendJson(res, 200, await providerService.getAccount(accountId));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'PUT') {
|
||||
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
||||
try {
|
||||
const body = await parseJsonBody<{ updates: Partial<ProviderAccount>; apiKey?: string }>(req);
|
||||
const existing = await providerService.getAccount(accountId);
|
||||
if (!existing) {
|
||||
sendJson(res, 404, { success: false, error: 'Provider account not found' });
|
||||
return true;
|
||||
}
|
||||
const nextAccount = await providerService.updateAccount(accountId, body.updates, body.apiKey);
|
||||
await syncUpdatedProviderToRuntime(providerAccountToConfig(nextAccount), body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true, account: nextAccount });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'DELETE') {
|
||||
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
||||
try {
|
||||
const existing = await providerService.getAccount(accountId);
|
||||
const runtimeProviderKey = existing?.vendorId === 'google' && existing.authMode === 'oauth_browser'
|
||||
? 'google-gemini-cli'
|
||||
: undefined;
|
||||
if (url.searchParams.get('apiKeyOnly') === '1') {
|
||||
await providerService.deleteLegacyProviderApiKey(accountId);
|
||||
await syncDeletedProviderApiKeyToRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
accountId,
|
||||
runtimeProviderKey,
|
||||
);
|
||||
sendJson(res, 200, { success: true });
|
||||
return true;
|
||||
}
|
||||
await providerService.deleteAccount(accountId);
|
||||
await syncDeletedProviderToRuntime(
|
||||
existing ? providerAccountToConfig(existing) : null,
|
||||
accountId,
|
||||
ctx.gatewayManager,
|
||||
runtimeProviderKey,
|
||||
);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers' && req.method === 'GET') {
|
||||
sendJson(res, 200, await getAllProvidersWithKeyInfo());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/default' && req.method === 'GET') {
|
||||
sendJson(res, 200, { providerId: await getDefaultProvider() ?? null });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/default' && req.method === 'PUT') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ providerId: string }>(req);
|
||||
await setDefaultProvider(body.providerId);
|
||||
await syncDefaultProviderToRuntime(body.providerId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/validate' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string } }>(req);
|
||||
const provider = await getProvider(body.providerId);
|
||||
const providerType = provider?.type || body.providerId;
|
||||
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
||||
const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
||||
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl }));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { valid: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{
|
||||
provider: OAuthProviderType | BrowserOAuthProviderType;
|
||||
region?: 'global' | 'cn';
|
||||
accountId?: string;
|
||||
label?: string;
|
||||
}>(req);
|
||||
if (body.provider === 'google') {
|
||||
await browserOAuthManager.startFlow(body.provider, {
|
||||
accountId: body.accountId,
|
||||
label: body.label,
|
||||
});
|
||||
} else {
|
||||
await deviceOAuthManager.startFlow(body.provider, body.region, {
|
||||
accountId: body.accountId,
|
||||
label: body.label,
|
||||
});
|
||||
}
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers/oauth/cancel' && req.method === 'POST') {
|
||||
try {
|
||||
await deviceOAuthManager.stopFlow();
|
||||
await browserOAuthManager.stopFlow();
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/providers' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req);
|
||||
const config = body.config;
|
||||
await saveProvider(config);
|
||||
if (body.apiKey !== undefined) {
|
||||
const trimmedKey = body.apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await storeApiKey(config.id, trimmedKey);
|
||||
await syncProviderApiKeyToRuntime(config.type, config.id, trimmedKey);
|
||||
}
|
||||
}
|
||||
await syncSavedProviderToRuntime(config, body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/providers/') && req.method === 'GET') {
|
||||
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
||||
if (providerId.endsWith('/api-key')) {
|
||||
const actualId = providerId.slice(0, -('/api-key'.length));
|
||||
sendJson(res, 200, { apiKey: await getApiKey(actualId) });
|
||||
return true;
|
||||
}
|
||||
if (providerId.endsWith('/has-api-key')) {
|
||||
const actualId = providerId.slice(0, -('/has-api-key'.length));
|
||||
sendJson(res, 200, { hasKey: await hasApiKey(actualId) });
|
||||
return true;
|
||||
}
|
||||
sendJson(res, 200, await getProvider(providerId));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/providers/') && req.method === 'PUT') {
|
||||
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
||||
try {
|
||||
const body = await parseJsonBody<{ updates: Partial<ProviderConfig>; apiKey?: string }>(req);
|
||||
const existing = await getProvider(providerId);
|
||||
if (!existing) {
|
||||
sendJson(res, 404, { success: false, error: 'Provider not found' });
|
||||
return true;
|
||||
}
|
||||
const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() };
|
||||
await saveProvider(nextConfig);
|
||||
if (body.apiKey !== undefined) {
|
||||
const trimmedKey = body.apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await storeApiKey(providerId, trimmedKey);
|
||||
await syncProviderApiKeyToRuntime(nextConfig.type, providerId, trimmedKey);
|
||||
} else {
|
||||
await deleteApiKey(providerId);
|
||||
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
|
||||
}
|
||||
}
|
||||
await syncUpdatedProviderToRuntime(nextConfig, body.apiKey, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/providers/') && req.method === 'DELETE') {
|
||||
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
||||
try {
|
||||
const existing = await getProvider(providerId);
|
||||
if (url.searchParams.get('apiKeyOnly') === '1') {
|
||||
await deleteApiKey(providerId);
|
||||
await syncDeletedProviderApiKeyToRuntime(existing, providerId);
|
||||
sendJson(res, 200, { success: true });
|
||||
return true;
|
||||
}
|
||||
await deleteProvider(providerId);
|
||||
await syncDeletedProviderToRuntime(existing, providerId, ctx.gatewayManager);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
96
electron/api/routes/sessions.ts
Normal file
96
electron/api/routes/sessions.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { join } from 'node:path';
|
||||
import { getOpenClawConfigDir } from '../../utils/paths';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
export async function handleSessionRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/sessions/delete' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ sessionKey: string }>(req);
|
||||
const sessionKey = body.sessionKey;
|
||||
if (!sessionKey || !sessionKey.startsWith('agent:')) {
|
||||
sendJson(res, 400, { success: false, error: `Invalid sessionKey: ${sessionKey}` });
|
||||
return true;
|
||||
}
|
||||
const parts = sessionKey.split(':');
|
||||
if (parts.length < 3) {
|
||||
sendJson(res, 400, { success: false, error: `sessionKey has too few parts: ${sessionKey}` });
|
||||
return true;
|
||||
}
|
||||
const agentId = parts[1];
|
||||
const sessionsDir = join(getOpenClawConfigDir(), 'agents', agentId, 'sessions');
|
||||
const sessionsJsonPath = join(sessionsDir, 'sessions.json');
|
||||
const fsP = await import('node:fs/promises');
|
||||
const raw = await fsP.readFile(sessionsJsonPath, 'utf8');
|
||||
const sessionsJson = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
let uuidFileName: string | undefined;
|
||||
let resolvedSrcPath: string | undefined;
|
||||
if (Array.isArray(sessionsJson.sessions)) {
|
||||
const entry = (sessionsJson.sessions as Array<Record<string, unknown>>)
|
||||
.find((s) => s.key === sessionKey || s.sessionKey === sessionKey);
|
||||
if (entry) {
|
||||
uuidFileName = (entry.file ?? entry.fileName ?? entry.path) as string | undefined;
|
||||
if (!uuidFileName && typeof entry.id === 'string') {
|
||||
uuidFileName = `${entry.id}.jsonl`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!uuidFileName && sessionsJson[sessionKey] != null) {
|
||||
const val = sessionsJson[sessionKey];
|
||||
if (typeof val === 'string') {
|
||||
uuidFileName = val;
|
||||
} else if (typeof val === 'object' && val !== null) {
|
||||
const entry = val as Record<string, unknown>;
|
||||
const absFile = (entry.sessionFile ?? entry.file ?? entry.fileName ?? entry.path) as string | undefined;
|
||||
if (absFile) {
|
||||
if (absFile.startsWith('/') || absFile.match(/^[A-Za-z]:\\/)) {
|
||||
resolvedSrcPath = absFile;
|
||||
} else {
|
||||
uuidFileName = absFile;
|
||||
}
|
||||
} else {
|
||||
const uuidVal = (entry.id ?? entry.sessionId) as string | undefined;
|
||||
if (uuidVal) uuidFileName = uuidVal.endsWith('.jsonl') ? uuidVal : `${uuidVal}.jsonl`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!uuidFileName && !resolvedSrcPath) {
|
||||
sendJson(res, 404, { success: false, error: `Cannot resolve file for session: ${sessionKey}` });
|
||||
return true;
|
||||
}
|
||||
if (!resolvedSrcPath) {
|
||||
if (!uuidFileName!.endsWith('.jsonl')) uuidFileName = `${uuidFileName}.jsonl`;
|
||||
resolvedSrcPath = join(sessionsDir, uuidFileName!);
|
||||
}
|
||||
const dstPath = resolvedSrcPath.replace(/\.jsonl$/, '.deleted.jsonl');
|
||||
try {
|
||||
await fsP.access(resolvedSrcPath);
|
||||
await fsP.rename(resolvedSrcPath, dstPath);
|
||||
} catch {
|
||||
// Non-fatal; still try to update sessions.json.
|
||||
}
|
||||
const raw2 = await fsP.readFile(sessionsJsonPath, 'utf8');
|
||||
const json2 = JSON.parse(raw2) as Record<string, unknown>;
|
||||
if (Array.isArray(json2.sessions)) {
|
||||
json2.sessions = (json2.sessions as Array<Record<string, unknown>>)
|
||||
.filter((s) => s.key !== sessionKey && s.sessionKey !== sessionKey);
|
||||
} else if (json2[sessionKey]) {
|
||||
delete json2[sessionKey];
|
||||
}
|
||||
await fsP.writeFile(sessionsJsonPath, JSON.stringify(json2, null, 2), 'utf8');
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
98
electron/api/routes/settings.ts
Normal file
98
electron/api/routes/settings.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { applyProxySettings } from '../../main/proxy';
|
||||
import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../../utils/store';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
async function handleProxySettingsChange(ctx: HostApiContext): Promise<void> {
|
||||
const settings = await getAllSettings();
|
||||
await applyProxySettings(settings);
|
||||
if (ctx.gatewayManager.getStatus().state === 'running') {
|
||||
await ctx.gatewayManager.restart();
|
||||
}
|
||||
}
|
||||
|
||||
function patchTouchesProxy(patch: Partial<AppSettings>): boolean {
|
||||
return Object.keys(patch).some((key) => (
|
||||
key === 'proxyEnabled' ||
|
||||
key === 'proxyServer' ||
|
||||
key === 'proxyHttpServer' ||
|
||||
key === 'proxyHttpsServer' ||
|
||||
key === 'proxyAllServer' ||
|
||||
key === 'proxyBypassRules'
|
||||
));
|
||||
}
|
||||
|
||||
export async function handleSettingsRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/settings' && req.method === 'GET') {
|
||||
sendJson(res, 200, await getAllSettings());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/settings' && req.method === 'PUT') {
|
||||
try {
|
||||
const patch = await parseJsonBody<Partial<AppSettings>>(req);
|
||||
const entries = Object.entries(patch) as Array<[keyof AppSettings, AppSettings[keyof AppSettings]]>;
|
||||
for (const [key, value] of entries) {
|
||||
await setSetting(key, value);
|
||||
}
|
||||
if (patchTouchesProxy(patch)) {
|
||||
await handleProxySettingsChange(ctx);
|
||||
}
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/settings/') && req.method === 'GET') {
|
||||
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
|
||||
try {
|
||||
sendJson(res, 200, { value: await getSetting(key) });
|
||||
} catch (error) {
|
||||
sendJson(res, 404, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/settings/') && req.method === 'PUT') {
|
||||
const key = url.pathname.slice('/api/settings/'.length) as keyof AppSettings;
|
||||
try {
|
||||
const body = await parseJsonBody<{ value: AppSettings[keyof AppSettings] }>(req);
|
||||
await setSetting(key, body.value);
|
||||
if (
|
||||
key === 'proxyEnabled' ||
|
||||
key === 'proxyServer' ||
|
||||
key === 'proxyHttpServer' ||
|
||||
key === 'proxyHttpsServer' ||
|
||||
key === 'proxyAllServer' ||
|
||||
key === 'proxyBypassRules'
|
||||
) {
|
||||
await handleProxySettingsChange(ctx);
|
||||
}
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/settings/reset' && req.method === 'POST') {
|
||||
try {
|
||||
await resetSettings();
|
||||
await handleProxySettingsChange(ctx);
|
||||
sendJson(res, 200, { success: true, settings: await getAllSettings() });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
90
electron/api/routes/skills.ts
Normal file
90
electron/api/routes/skills.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { getAllSkillConfigs, updateSkillConfig } from '../../utils/skill-config';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
export async function handleSkillRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/skills/configs' && req.method === 'GET') {
|
||||
sendJson(res, 200, await getAllSkillConfigs());
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/skills/config' && req.method === 'PUT') {
|
||||
try {
|
||||
const body = await parseJsonBody<{
|
||||
skillKey: string;
|
||||
apiKey?: string;
|
||||
env?: Record<string, string>;
|
||||
}>(req);
|
||||
sendJson(res, 200, await updateSkillConfig(body.skillKey, {
|
||||
apiKey: body.apiKey,
|
||||
env: body.env,
|
||||
}));
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/clawhub/search' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<Record<string, unknown>>(req);
|
||||
sendJson(res, 200, {
|
||||
success: true,
|
||||
results: await ctx.clawHubService.search(body),
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/clawhub/install' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<Record<string, unknown>>(req);
|
||||
await ctx.clawHubService.install(body);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/clawhub/uninstall' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<Record<string, unknown>>(req);
|
||||
await ctx.clawHubService.uninstall(body);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/clawhub/list' && req.method === 'GET') {
|
||||
try {
|
||||
sendJson(res, 200, { success: true, results: await ctx.clawHubService.listInstalled() });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/clawhub/open-readme' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ slug: string }>(req);
|
||||
await ctx.clawHubService.openSkillReadme(body.slug);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
20
electron/api/routes/usage.ts
Normal file
20
electron/api/routes/usage.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import { getRecentTokenUsageHistory } from '../../utils/token-usage';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { sendJson } from '../route-utils';
|
||||
|
||||
export async function handleUsageRoutes(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
_ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/usage/recent-token-history' && req.method === 'GET') {
|
||||
const parsedLimit = Number(url.searchParams.get('limit') || '');
|
||||
const limit = Number.isFinite(parsedLimit) ? Math.max(Math.floor(parsedLimit), 1) : undefined;
|
||||
sendJson(res, 200, await getRecentTokenUsageHistory(limit));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
60
electron/api/server.ts
Normal file
60
electron/api/server.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import { PORTS } from '../utils/config';
|
||||
import { logger } from '../utils/logger';
|
||||
import type { HostApiContext } from './context';
|
||||
import { handleAppRoutes } from './routes/app';
|
||||
import { handleGatewayRoutes } from './routes/gateway';
|
||||
import { handleSettingsRoutes } from './routes/settings';
|
||||
import { handleProviderRoutes } from './routes/providers';
|
||||
import { handleChannelRoutes } from './routes/channels';
|
||||
import { handleLogRoutes } from './routes/logs';
|
||||
import { handleUsageRoutes } from './routes/usage';
|
||||
import { handleSkillRoutes } from './routes/skills';
|
||||
import { handleFileRoutes } from './routes/files';
|
||||
import { handleSessionRoutes } from './routes/sessions';
|
||||
import { handleCronRoutes } from './routes/cron';
|
||||
import { sendJson } from './route-utils';
|
||||
|
||||
type RouteHandler = (
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
url: URL,
|
||||
ctx: HostApiContext,
|
||||
) => Promise<boolean>;
|
||||
|
||||
const routeHandlers: RouteHandler[] = [
|
||||
handleAppRoutes,
|
||||
handleGatewayRoutes,
|
||||
handleSettingsRoutes,
|
||||
handleProviderRoutes,
|
||||
handleChannelRoutes,
|
||||
handleSkillRoutes,
|
||||
handleFileRoutes,
|
||||
handleSessionRoutes,
|
||||
handleCronRoutes,
|
||||
handleLogRoutes,
|
||||
handleUsageRoutes,
|
||||
];
|
||||
|
||||
export function startHostApiServer(ctx: HostApiContext, port = PORTS.CLAWX_HOST_API): Server {
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url || '/', `http://127.0.0.1:${port}`);
|
||||
for (const handler of routeHandlers) {
|
||||
if (await handler(req, res, requestUrl, ctx)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
sendJson(res, 404, { success: false, error: `No route for ${req.method} ${requestUrl.pathname}` });
|
||||
} catch (error) {
|
||||
logger.error('Host API request failed:', error);
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
logger.info(`Host API server listening on http://127.0.0.1:${port}`);
|
||||
});
|
||||
|
||||
return server;
|
||||
}
|
||||
182
electron/gateway/config-sync.ts
Normal file
182
electron/gateway/config-sync.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { getAllSettings } from '../utils/store';
|
||||
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
|
||||
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
|
||||
import { getOpenClawDir, getOpenClawEntryPath, isOpenClawPresent } from '../utils/paths';
|
||||
import { getUvMirrorEnv } from '../utils/uv-env';
|
||||
import { listConfiguredChannels } from '../utils/channel-config';
|
||||
import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth';
|
||||
import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
|
||||
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export interface GatewayLaunchContext {
|
||||
appSettings: Awaited<ReturnType<typeof getAllSettings>>;
|
||||
openclawDir: string;
|
||||
entryScript: string;
|
||||
gatewayArgs: string[];
|
||||
forkEnv: Record<string, string | undefined>;
|
||||
mode: 'dev' | 'packaged';
|
||||
binPathExists: boolean;
|
||||
loadedProviderKeyCount: number;
|
||||
proxySummary: string;
|
||||
channelStartupSummary: string;
|
||||
}
|
||||
|
||||
export async function syncGatewayConfigBeforeLaunch(
|
||||
appSettings: Awaited<ReturnType<typeof getAllSettings>>,
|
||||
): Promise<void> {
|
||||
await syncProxyConfigToOpenClaw(appSettings);
|
||||
|
||||
try {
|
||||
await sanitizeOpenClawConfig();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sanitize openclaw.json:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await syncGatewayTokenToConfig(appSettings.gatewayToken);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sync gateway token to openclaw.json:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await syncBrowserConfigToOpenClaw();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to sync browser config to openclaw.json:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProviderEnv(): Promise<{ providerEnv: Record<string, string>; loadedProviderKeyCount: number }> {
|
||||
const providerEnv: Record<string, string> = {};
|
||||
const providerTypes = getKeyableProviderTypes();
|
||||
let loadedProviderKeyCount = 0;
|
||||
|
||||
try {
|
||||
const defaultProviderId = await getDefaultProvider();
|
||||
if (defaultProviderId) {
|
||||
const defaultProvider = await getProvider(defaultProviderId);
|
||||
const defaultProviderType = defaultProvider?.type;
|
||||
const defaultProviderKey = await getApiKey(defaultProviderId);
|
||||
if (defaultProviderType && defaultProviderKey) {
|
||||
const envVar = getProviderEnvVar(defaultProviderType);
|
||||
if (envVar) {
|
||||
providerEnv[envVar] = defaultProviderKey;
|
||||
loadedProviderKeyCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to load default provider key for environment injection:', err);
|
||||
}
|
||||
|
||||
for (const providerType of providerTypes) {
|
||||
try {
|
||||
const key = await getApiKey(providerType);
|
||||
if (key) {
|
||||
const envVar = getProviderEnvVar(providerType);
|
||||
if (envVar) {
|
||||
providerEnv[envVar] = key;
|
||||
loadedProviderKeyCount++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to load API key for ${providerType}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return { providerEnv, loadedProviderKeyCount };
|
||||
}
|
||||
|
||||
async function resolveChannelStartupPolicy(): Promise<{
|
||||
skipChannels: boolean;
|
||||
channelStartupSummary: string;
|
||||
}> {
|
||||
try {
|
||||
const configuredChannels = await listConfiguredChannels();
|
||||
if (configuredChannels.length === 0) {
|
||||
return {
|
||||
skipChannels: true,
|
||||
channelStartupSummary: 'skipped(no configured channels)',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
skipChannels: false,
|
||||
channelStartupSummary: `enabled(${configuredChannels.join(',')})`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn('Failed to determine configured channels for gateway launch:', error);
|
||||
return {
|
||||
skipChannels: false,
|
||||
channelStartupSummary: 'enabled(unknown)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareGatewayLaunchContext(port: number): Promise<GatewayLaunchContext> {
|
||||
const openclawDir = getOpenClawDir();
|
||||
const entryScript = getOpenClawEntryPath();
|
||||
|
||||
if (!isOpenClawPresent()) {
|
||||
throw new Error(`OpenClaw package not found at: ${openclawDir}`);
|
||||
}
|
||||
|
||||
const appSettings = await getAllSettings();
|
||||
await syncGatewayConfigBeforeLaunch(appSettings);
|
||||
|
||||
if (!existsSync(entryScript)) {
|
||||
throw new Error(`OpenClaw entry script not found at: ${entryScript}`);
|
||||
}
|
||||
|
||||
const gatewayArgs = ['gateway', '--port', String(port), '--token', appSettings.gatewayToken, '--allow-unconfigured'];
|
||||
const mode = app.isPackaged ? 'packaged' : 'dev';
|
||||
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
const target = `${platform}-${arch}`;
|
||||
const binPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'bin')
|
||||
: path.join(process.cwd(), 'resources', 'bin', target);
|
||||
const binPathExists = existsSync(binPath);
|
||||
const finalPath = binPathExists
|
||||
? `${binPath}${path.delimiter}${process.env.PATH || ''}`
|
||||
: process.env.PATH || '';
|
||||
|
||||
const { providerEnv, loadedProviderKeyCount } = await loadProviderEnv();
|
||||
const { skipChannels, channelStartupSummary } = await resolveChannelStartupPolicy();
|
||||
const uvEnv = await getUvMirrorEnv();
|
||||
const proxyEnv = buildProxyEnv(appSettings);
|
||||
const resolvedProxy = resolveProxySettings(appSettings);
|
||||
const proxySummary = appSettings.proxyEnabled
|
||||
? `http=${resolvedProxy.httpProxy || '-'}, https=${resolvedProxy.httpsProxy || '-'}, all=${resolvedProxy.allProxy || '-'}`
|
||||
: 'disabled';
|
||||
|
||||
const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env;
|
||||
const forkEnv: Record<string, string | undefined> = {
|
||||
...baseEnv,
|
||||
PATH: finalPath,
|
||||
...providerEnv,
|
||||
...uvEnv,
|
||||
...proxyEnv,
|
||||
OPENCLAW_GATEWAY_TOKEN: appSettings.gatewayToken,
|
||||
OPENCLAW_SKIP_CHANNELS: skipChannels ? '1' : '',
|
||||
CLAWDBOT_SKIP_CHANNELS: skipChannels ? '1' : '',
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
};
|
||||
|
||||
return {
|
||||
appSettings,
|
||||
openclawDir,
|
||||
entryScript,
|
||||
gatewayArgs,
|
||||
forkEnv,
|
||||
mode,
|
||||
binPathExists,
|
||||
loadedProviderKeyCount,
|
||||
proxySummary,
|
||||
channelStartupSummary,
|
||||
};
|
||||
}
|
||||
59
electron/gateway/connection-monitor.ts
Normal file
59
electron/gateway/connection-monitor.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
type HealthResult = { ok: boolean; error?: string };
|
||||
|
||||
export class GatewayConnectionMonitor {
|
||||
private pingInterval: NodeJS.Timeout | null = null;
|
||||
private healthCheckInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
startPing(sendPing: () => void, intervalMs = 30000): void {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
}
|
||||
|
||||
this.pingInterval = setInterval(() => {
|
||||
sendPing();
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
startHealthCheck(options: {
|
||||
shouldCheck: () => boolean;
|
||||
checkHealth: () => Promise<HealthResult>;
|
||||
onUnhealthy: (errorMessage: string) => void;
|
||||
onError: (error: unknown) => void;
|
||||
intervalMs?: number;
|
||||
}): void {
|
||||
if (this.healthCheckInterval) {
|
||||
clearInterval(this.healthCheckInterval);
|
||||
}
|
||||
|
||||
this.healthCheckInterval = setInterval(async () => {
|
||||
if (!options.shouldCheck()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const health = await options.checkHealth();
|
||||
if (!health.ok) {
|
||||
const errorMessage = health.error ?? 'Health check failed';
|
||||
logger.warn(`Gateway health check failed: ${errorMessage}`);
|
||||
options.onUnhealthy(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Gateway health check error:', error);
|
||||
options.onError(error);
|
||||
}
|
||||
}, options.intervalMs ?? 30000);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
if (this.healthCheckInterval) {
|
||||
clearInterval(this.healthCheckInterval);
|
||||
this.healthCheckInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
electron/gateway/event-dispatch.ts
Normal file
63
electron/gateway/event-dispatch.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { GatewayEventType, type JsonRpcNotification } from './protocol';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
type GatewayEventEmitter = {
|
||||
emit: (event: string, payload: unknown) => boolean;
|
||||
};
|
||||
|
||||
export function dispatchProtocolEvent(
|
||||
emitter: GatewayEventEmitter,
|
||||
event: string,
|
||||
payload: unknown,
|
||||
): void {
|
||||
switch (event) {
|
||||
case 'tick':
|
||||
break;
|
||||
case 'chat':
|
||||
emitter.emit('chat:message', { message: payload });
|
||||
break;
|
||||
case 'agent': {
|
||||
const p = payload as Record<string, unknown>;
|
||||
const data = (p.data && typeof p.data === 'object') ? p.data as Record<string, unknown> : {};
|
||||
const chatEvent: Record<string, unknown> = {
|
||||
...data,
|
||||
runId: p.runId ?? data.runId,
|
||||
sessionKey: p.sessionKey ?? data.sessionKey,
|
||||
state: p.state ?? data.state,
|
||||
message: p.message ?? data.message,
|
||||
};
|
||||
if (chatEvent.state || chatEvent.message) {
|
||||
emitter.emit('chat:message', { message: chatEvent });
|
||||
}
|
||||
emitter.emit('notification', { method: event, params: payload });
|
||||
break;
|
||||
}
|
||||
case 'channel.status':
|
||||
emitter.emit('channel:status', payload as { channelId: string; status: string });
|
||||
break;
|
||||
default:
|
||||
emitter.emit('notification', { method: event, params: payload });
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchJsonRpcNotification(
|
||||
emitter: GatewayEventEmitter,
|
||||
notification: JsonRpcNotification,
|
||||
): void {
|
||||
emitter.emit('notification', notification);
|
||||
switch (notification.method) {
|
||||
case GatewayEventType.CHANNEL_STATUS_CHANGED:
|
||||
emitter.emit('channel:status', notification.params as { channelId: string; status: string });
|
||||
break;
|
||||
case GatewayEventType.MESSAGE_RECEIVED:
|
||||
emitter.emit('chat:message', notification.params as { message: unknown });
|
||||
break;
|
||||
case GatewayEventType.ERROR: {
|
||||
const errorData = notification.params as { message?: string };
|
||||
emitter.emit('error', new Error(errorData.message || 'Gateway error'));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
logger.debug(`Unknown Gateway notification: ${notification.method}`);
|
||||
}
|
||||
}
|
||||
31
electron/gateway/lifecycle-controller.ts
Normal file
31
electron/gateway/lifecycle-controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import { isLifecycleSuperseded, nextLifecycleEpoch } from './process-policy';
|
||||
|
||||
export class LifecycleSupersededError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'LifecycleSupersededError';
|
||||
}
|
||||
}
|
||||
|
||||
export class GatewayLifecycleController {
|
||||
private epoch = 0;
|
||||
|
||||
getCurrentEpoch(): number {
|
||||
return this.epoch;
|
||||
}
|
||||
|
||||
bump(reason: string): number {
|
||||
this.epoch = nextLifecycleEpoch(this.epoch);
|
||||
logger.debug(`Gateway lifecycle epoch advanced to ${this.epoch} (${reason})`);
|
||||
return this.epoch;
|
||||
}
|
||||
|
||||
assert(expectedEpoch: number, phase: string): void {
|
||||
if (isLifecycleSuperseded(expectedEpoch, this.epoch)) {
|
||||
throw new LifecycleSupersededError(
|
||||
`Gateway ${phase} superseded (expectedEpoch=${expectedEpoch}, currentEpoch=${this.epoch})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
180
electron/gateway/process-launcher.ts
Normal file
180
electron/gateway/process-launcher.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { app, utilityProcess } from 'electron';
|
||||
import { existsSync, writeFileSync } from 'fs';
|
||||
import path from 'path';
|
||||
import type { GatewayLaunchContext } from './config-sync';
|
||||
import type { GatewayLifecycleState } from './process-policy';
|
||||
import { logger } from '../utils/logger';
|
||||
import { appendNodeRequireToNodeOptions } from '../utils/paths';
|
||||
|
||||
const GATEWAY_FETCH_PRELOAD_SOURCE = `'use strict';
|
||||
(function () {
|
||||
var _f = globalThis.fetch;
|
||||
if (typeof _f !== 'function') return;
|
||||
if (globalThis.__clawxFetchPatched) return;
|
||||
globalThis.__clawxFetchPatched = true;
|
||||
|
||||
globalThis.fetch = function clawxFetch(input, init) {
|
||||
var url =
|
||||
typeof input === 'string' ? input
|
||||
: input && typeof input === 'object' && typeof input.url === 'string'
|
||||
? input.url : '';
|
||||
|
||||
if (url.indexOf('openrouter.ai') !== -1) {
|
||||
init = init ? Object.assign({}, init) : {};
|
||||
var prev = init.headers;
|
||||
var flat = {};
|
||||
if (prev && typeof prev.forEach === 'function') {
|
||||
prev.forEach(function (v, k) { flat[k] = v; });
|
||||
} else if (prev && typeof prev === 'object') {
|
||||
Object.assign(flat, prev);
|
||||
}
|
||||
delete flat['http-referer'];
|
||||
delete flat['HTTP-Referer'];
|
||||
delete flat['x-title'];
|
||||
delete flat['X-Title'];
|
||||
flat['HTTP-Referer'] = 'https://claw-x.com';
|
||||
flat['X-Title'] = 'ClawX';
|
||||
init.headers = flat;
|
||||
}
|
||||
return _f.call(globalThis, input, init);
|
||||
};
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
var cp = require('child_process');
|
||||
if (!cp.__clawxPatched) {
|
||||
cp.__clawxPatched = true;
|
||||
['spawn', 'exec', 'execFile', 'fork', 'spawnSync', 'execSync', 'execFileSync'].forEach(function(method) {
|
||||
var original = cp[method];
|
||||
if (typeof original !== 'function') return;
|
||||
cp[method] = function() {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
var optIdx = -1;
|
||||
for (var i = 1; i < args.length; i++) {
|
||||
var a = args[i];
|
||||
if (a && typeof a === 'object' && !Array.isArray(a)) {
|
||||
optIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (optIdx >= 0) {
|
||||
args[optIdx].windowsHide = true;
|
||||
} else {
|
||||
var opts = { windowsHide: true };
|
||||
if (typeof args[args.length - 1] === 'function') {
|
||||
args.splice(args.length - 1, 0, opts);
|
||||
} else {
|
||||
args.push(opts);
|
||||
}
|
||||
}
|
||||
return original.apply(this, args);
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
})();
|
||||
`;
|
||||
|
||||
function ensureGatewayFetchPreload(): string {
|
||||
const dest = path.join(app.getPath('userData'), 'gateway-fetch-preload.cjs');
|
||||
try {
|
||||
writeFileSync(dest, GATEWAY_FETCH_PRELOAD_SOURCE, 'utf-8');
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
return dest;
|
||||
}
|
||||
|
||||
export async function launchGatewayProcess(options: {
|
||||
port: number;
|
||||
launchContext: GatewayLaunchContext;
|
||||
sanitizeSpawnArgs: (args: string[]) => string[];
|
||||
getCurrentState: () => GatewayLifecycleState;
|
||||
getShouldReconnect: () => boolean;
|
||||
onStderrLine: (line: string) => void;
|
||||
onSpawn: (pid: number | undefined) => void;
|
||||
onExit: (child: Electron.UtilityProcess, code: number | null) => void;
|
||||
onError: (error: Error) => void;
|
||||
}): Promise<{ child: Electron.UtilityProcess; lastSpawnSummary: string }> {
|
||||
const {
|
||||
openclawDir,
|
||||
entryScript,
|
||||
gatewayArgs,
|
||||
forkEnv,
|
||||
mode,
|
||||
binPathExists,
|
||||
loadedProviderKeyCount,
|
||||
proxySummary,
|
||||
channelStartupSummary,
|
||||
} = options.launchContext;
|
||||
|
||||
logger.info(
|
||||
`Starting Gateway process (mode=${mode}, port=${options.port}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'}, providerKeys=${loadedProviderKeyCount}, channels=${channelStartupSummary}, proxy=${proxySummary})`,
|
||||
);
|
||||
const lastSpawnSummary = `mode=${mode}, entry="${entryScript}", args="${options.sanitizeSpawnArgs(gatewayArgs).join(' ')}", cwd="${openclawDir}"`;
|
||||
|
||||
const runtimeEnv = { ...forkEnv };
|
||||
if (!app.isPackaged) {
|
||||
try {
|
||||
const preloadPath = ensureGatewayFetchPreload();
|
||||
if (existsSync(preloadPath)) {
|
||||
runtimeEnv.NODE_OPTIONS = appendNodeRequireToNodeOptions(
|
||||
runtimeEnv.NODE_OPTIONS,
|
||||
preloadPath,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Failed to set up OpenRouter headers preload:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return await new Promise<{ child: Electron.UtilityProcess; lastSpawnSummary: string }>((resolve, reject) => {
|
||||
const child = utilityProcess.fork(entryScript, gatewayArgs, {
|
||||
cwd: openclawDir,
|
||||
stdio: 'pipe',
|
||||
env: runtimeEnv as NodeJS.ProcessEnv,
|
||||
serviceName: 'OpenClaw Gateway',
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const resolveOnce = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve({ child, lastSpawnSummary });
|
||||
};
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
|
||||
child.on('error', (error) => {
|
||||
logger.error('Gateway process spawn error:', error);
|
||||
options.onError(error);
|
||||
rejectOnce(error);
|
||||
});
|
||||
|
||||
child.on('exit', (code: number) => {
|
||||
const expectedExit = !options.getShouldReconnect() || options.getCurrentState() === 'stopped';
|
||||
const level = expectedExit ? logger.info : logger.warn;
|
||||
level(`Gateway process exited (code=${code}, expected=${expectedExit ? 'yes' : 'no'})`);
|
||||
options.onExit(child, code);
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
const raw = data.toString();
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
options.onStderrLine(line);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('spawn', () => {
|
||||
logger.info(`Gateway process started (pid=${child.pid})`);
|
||||
options.onSpawn(child.pid);
|
||||
resolveOnce();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,15 @@
|
||||
export interface ReconnectConfig {
|
||||
maxAttempts: number;
|
||||
baseDelay: number;
|
||||
maxDelay: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_RECONNECT_CONFIG: ReconnectConfig = {
|
||||
maxAttempts: 10,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 30000,
|
||||
};
|
||||
|
||||
export function nextLifecycleEpoch(currentEpoch: number): number {
|
||||
return currentEpoch + 1;
|
||||
}
|
||||
@@ -22,6 +34,53 @@ export function getReconnectSkipReason(context: ReconnectAttemptContext): string
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ReconnectScheduleContext {
|
||||
shouldReconnect: boolean;
|
||||
hasReconnectTimer: boolean;
|
||||
reconnectAttempts: number;
|
||||
maxAttempts: number;
|
||||
baseDelay: number;
|
||||
maxDelay: number;
|
||||
}
|
||||
|
||||
export type ReconnectScheduleDecision =
|
||||
| { action: 'skip'; reason: string }
|
||||
| { action: 'already-scheduled' }
|
||||
| { action: 'fail'; attempts: number; maxAttempts: number }
|
||||
| { action: 'schedule'; nextAttempt: number; maxAttempts: number; delay: number };
|
||||
|
||||
export function getReconnectScheduleDecision(
|
||||
context: ReconnectScheduleContext,
|
||||
): ReconnectScheduleDecision {
|
||||
if (!context.shouldReconnect) {
|
||||
return { action: 'skip', reason: 'auto-reconnect disabled' };
|
||||
}
|
||||
|
||||
if (context.hasReconnectTimer) {
|
||||
return { action: 'already-scheduled' };
|
||||
}
|
||||
|
||||
if (context.reconnectAttempts >= context.maxAttempts) {
|
||||
return {
|
||||
action: 'fail',
|
||||
attempts: context.reconnectAttempts,
|
||||
maxAttempts: context.maxAttempts,
|
||||
};
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
context.baseDelay * Math.pow(2, context.reconnectAttempts),
|
||||
context.maxDelay,
|
||||
);
|
||||
|
||||
return {
|
||||
action: 'schedule',
|
||||
nextAttempt: context.reconnectAttempts + 1,
|
||||
maxAttempts: context.maxAttempts,
|
||||
delay,
|
||||
};
|
||||
}
|
||||
|
||||
export type GatewayLifecycleState = 'stopped' | 'starting' | 'running' | 'error' | 'reconnecting';
|
||||
|
||||
export interface RestartDeferralContext {
|
||||
|
||||
42
electron/gateway/request-store.ts
Normal file
42
electron/gateway/request-store.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export interface PendingGatewayRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export function clearPendingGatewayRequests(
|
||||
pendingRequests: Map<string, PendingGatewayRequest>,
|
||||
error: Error,
|
||||
): void {
|
||||
for (const [, request] of pendingRequests) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(error);
|
||||
}
|
||||
pendingRequests.clear();
|
||||
}
|
||||
|
||||
export function resolvePendingGatewayRequest(
|
||||
pendingRequests: Map<string, PendingGatewayRequest>,
|
||||
id: string,
|
||||
value: unknown,
|
||||
): boolean {
|
||||
const request = pendingRequests.get(id);
|
||||
if (!request) return false;
|
||||
clearTimeout(request.timeout);
|
||||
pendingRequests.delete(id);
|
||||
request.resolve(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function rejectPendingGatewayRequest(
|
||||
pendingRequests: Map<string, PendingGatewayRequest>,
|
||||
id: string,
|
||||
error: Error,
|
||||
): boolean {
|
||||
const request = pendingRequests.get(id);
|
||||
if (!request) return false;
|
||||
clearTimeout(request.timeout);
|
||||
pendingRequests.delete(id);
|
||||
request.reject(error);
|
||||
return true;
|
||||
}
|
||||
91
electron/gateway/restart-controller.ts
Normal file
91
electron/gateway/restart-controller.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
getDeferredRestartAction,
|
||||
shouldDeferRestart,
|
||||
type GatewayLifecycleState,
|
||||
} from './process-policy';
|
||||
|
||||
type RestartDeferralState = {
|
||||
state: GatewayLifecycleState;
|
||||
startLock: boolean;
|
||||
};
|
||||
|
||||
type DeferredRestartContext = RestartDeferralState & {
|
||||
shouldReconnect: boolean;
|
||||
};
|
||||
|
||||
export class GatewayRestartController {
|
||||
private deferredRestartPending = false;
|
||||
private restartDebounceTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
isRestartDeferred(context: RestartDeferralState): boolean {
|
||||
return shouldDeferRestart(context);
|
||||
}
|
||||
|
||||
markDeferredRestart(reason: string, context: RestartDeferralState): void {
|
||||
if (!this.deferredRestartPending) {
|
||||
logger.info(
|
||||
`Deferring Gateway restart (${reason}) until startup/reconnect settles (state=${context.state}, startLock=${context.startLock})`,
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Gateway restart already deferred; keeping pending request (${reason}, state=${context.state}, startLock=${context.startLock})`,
|
||||
);
|
||||
}
|
||||
this.deferredRestartPending = true;
|
||||
}
|
||||
|
||||
flushDeferredRestart(
|
||||
trigger: string,
|
||||
context: DeferredRestartContext,
|
||||
executeRestart: () => void,
|
||||
): void {
|
||||
const action = getDeferredRestartAction({
|
||||
hasPendingRestart: this.deferredRestartPending,
|
||||
state: context.state,
|
||||
startLock: context.startLock,
|
||||
shouldReconnect: context.shouldReconnect,
|
||||
});
|
||||
|
||||
if (action === 'none') return;
|
||||
if (action === 'wait') {
|
||||
logger.debug(
|
||||
`Deferred Gateway restart still waiting (${trigger}, state=${context.state}, startLock=${context.startLock})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.deferredRestartPending = false;
|
||||
if (action === 'drop') {
|
||||
logger.info(
|
||||
`Dropping deferred Gateway restart (${trigger}) because lifecycle already recovered (state=${context.state}, shouldReconnect=${context.shouldReconnect})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Executing deferred Gateway restart now (${trigger})`);
|
||||
executeRestart();
|
||||
}
|
||||
|
||||
debouncedRestart(delayMs: number, executeRestart: () => void): void {
|
||||
if (this.restartDebounceTimer) {
|
||||
clearTimeout(this.restartDebounceTimer);
|
||||
}
|
||||
logger.debug(`Gateway restart debounced (will fire in ${delayMs}ms)`);
|
||||
this.restartDebounceTimer = setTimeout(() => {
|
||||
this.restartDebounceTimer = null;
|
||||
executeRestart();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
clearDebounceTimer(): void {
|
||||
if (this.restartDebounceTimer) {
|
||||
clearTimeout(this.restartDebounceTimer);
|
||||
this.restartDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
resetDeferredRestart(): void {
|
||||
this.deferredRestartPending = false;
|
||||
}
|
||||
}
|
||||
106
electron/gateway/startup-orchestrator.ts
Normal file
106
electron/gateway/startup-orchestrator.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { logger } from '../utils/logger';
|
||||
import { LifecycleSupersededError } from './lifecycle-controller';
|
||||
import { getGatewayStartupRecoveryAction } from './startup-recovery';
|
||||
|
||||
export interface ExistingGatewayInfo {
|
||||
port: number;
|
||||
externalToken?: string;
|
||||
}
|
||||
|
||||
type StartupHooks = {
|
||||
port: number;
|
||||
ownedPid?: number;
|
||||
shouldWaitForPortFree: boolean;
|
||||
maxStartAttempts?: number;
|
||||
resetStartupStderrLines: () => void;
|
||||
getStartupStderrLines: () => string[];
|
||||
assertLifecycle: (phase: string) => void;
|
||||
findExistingGateway: (port: number, ownedPid?: number) => Promise<ExistingGatewayInfo | null>;
|
||||
connect: (port: number, externalToken?: string) => Promise<void>;
|
||||
onConnectedToExistingGateway: () => void;
|
||||
waitForPortFree: (port: number) => Promise<void>;
|
||||
startProcess: () => Promise<void>;
|
||||
waitForReady: (port: number) => Promise<void>;
|
||||
onConnectedToManagedGateway: () => void;
|
||||
runDoctorRepair: () => Promise<boolean>;
|
||||
onDoctorRepairSuccess: () => void;
|
||||
delay: (ms: number) => Promise<void>;
|
||||
};
|
||||
|
||||
export async function runGatewayStartupSequence(hooks: StartupHooks): Promise<void> {
|
||||
let configRepairAttempted = false;
|
||||
let startAttempts = 0;
|
||||
const maxStartAttempts = hooks.maxStartAttempts ?? 3;
|
||||
|
||||
while (true) {
|
||||
startAttempts++;
|
||||
hooks.assertLifecycle('start');
|
||||
hooks.resetStartupStderrLines();
|
||||
|
||||
try {
|
||||
logger.debug('Checking for existing Gateway...');
|
||||
const existing = await hooks.findExistingGateway(hooks.port, hooks.ownedPid);
|
||||
hooks.assertLifecycle('start/find-existing');
|
||||
if (existing) {
|
||||
logger.debug(`Found existing Gateway on port ${existing.port}`);
|
||||
await hooks.connect(existing.port, existing.externalToken);
|
||||
hooks.assertLifecycle('start/connect-existing');
|
||||
hooks.onConnectedToExistingGateway();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('No existing Gateway found, starting new process...');
|
||||
|
||||
if (hooks.shouldWaitForPortFree) {
|
||||
await hooks.waitForPortFree(hooks.port);
|
||||
hooks.assertLifecycle('start/wait-port');
|
||||
}
|
||||
|
||||
await hooks.startProcess();
|
||||
hooks.assertLifecycle('start/start-process');
|
||||
|
||||
await hooks.waitForReady(hooks.port);
|
||||
hooks.assertLifecycle('start/wait-ready');
|
||||
|
||||
await hooks.connect(hooks.port);
|
||||
hooks.assertLifecycle('start/connect');
|
||||
|
||||
hooks.onConnectedToManagedGateway();
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof LifecycleSupersededError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const recoveryAction = getGatewayStartupRecoveryAction({
|
||||
startupError: error,
|
||||
startupStderrLines: hooks.getStartupStderrLines(),
|
||||
configRepairAttempted,
|
||||
attempt: startAttempts,
|
||||
maxAttempts: maxStartAttempts,
|
||||
});
|
||||
|
||||
if (recoveryAction === 'repair') {
|
||||
configRepairAttempted = true;
|
||||
logger.warn(
|
||||
'Detected invalid OpenClaw config during Gateway startup; running doctor repair before retry',
|
||||
);
|
||||
const repaired = await hooks.runDoctorRepair();
|
||||
if (repaired) {
|
||||
logger.info('OpenClaw doctor repair completed; retrying Gateway startup');
|
||||
hooks.onDoctorRepairSuccess();
|
||||
continue;
|
||||
}
|
||||
logger.error('OpenClaw doctor repair failed; not retrying Gateway startup');
|
||||
}
|
||||
|
||||
if (recoveryAction === 'retry') {
|
||||
logger.warn(`Transient start error: ${String(error)}. Retrying... (${startAttempts}/${maxStartAttempts})`);
|
||||
await hooks.delay(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,14 @@ const INVALID_CONFIG_PATTERNS: RegExp[] = [
|
||||
/\brun:\s*openclaw doctor --fix\b/i,
|
||||
];
|
||||
|
||||
const TRANSIENT_START_ERROR_PATTERNS: RegExp[] = [
|
||||
/WebSocket closed before handshake/i,
|
||||
/ECONNREFUSED/i,
|
||||
/Gateway process exited before becoming ready/i,
|
||||
/Timed out waiting for connect\.challenge/i,
|
||||
/Connect handshake timeout/i,
|
||||
];
|
||||
|
||||
function normalizeLogLine(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
@@ -58,3 +66,34 @@ export function shouldAttemptConfigAutoRepair(
|
||||
return hasInvalidConfigFailureSignal(startupError, startupStderrLines);
|
||||
}
|
||||
|
||||
export function isTransientGatewayStartError(error: unknown): boolean {
|
||||
const errorText = error instanceof Error
|
||||
? `${error.name}: ${error.message}`
|
||||
: String(error ?? '');
|
||||
return TRANSIENT_START_ERROR_PATTERNS.some((pattern) => pattern.test(errorText));
|
||||
}
|
||||
|
||||
export type GatewayStartupRecoveryAction = 'repair' | 'retry' | 'fail';
|
||||
|
||||
export function getGatewayStartupRecoveryAction(options: {
|
||||
startupError: unknown;
|
||||
startupStderrLines: string[];
|
||||
configRepairAttempted: boolean;
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
}): GatewayStartupRecoveryAction {
|
||||
if (shouldAttemptConfigAutoRepair(
|
||||
options.startupError,
|
||||
options.startupStderrLines,
|
||||
options.configRepairAttempted,
|
||||
)) {
|
||||
return 'repair';
|
||||
}
|
||||
|
||||
if (options.attempt < options.maxAttempts && isTransientGatewayStartError(options.startupError)) {
|
||||
return 'retry';
|
||||
}
|
||||
|
||||
return 'fail';
|
||||
}
|
||||
|
||||
|
||||
42
electron/gateway/startup-stderr.ts
Normal file
42
electron/gateway/startup-stderr.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type GatewayStderrClassification = {
|
||||
level: 'drop' | 'debug' | 'warn';
|
||||
normalized: string;
|
||||
};
|
||||
|
||||
const MAX_STDERR_LINES = 120;
|
||||
|
||||
export function classifyGatewayStderrMessage(message: string): GatewayStderrClassification {
|
||||
const msg = message.trim();
|
||||
if (!msg) {
|
||||
return { level: 'drop', normalized: msg };
|
||||
}
|
||||
|
||||
// Known noisy lines that are not actionable for Gateway lifecycle debugging.
|
||||
if (msg.includes('openclaw-control-ui') && msg.includes('token_mismatch')) {
|
||||
return { level: 'drop', normalized: msg };
|
||||
}
|
||||
if (msg.includes('closed before connect') && msg.includes('token mismatch')) {
|
||||
return { level: 'drop', normalized: msg };
|
||||
}
|
||||
|
||||
// Downgrade frequent non-fatal noise.
|
||||
if (msg.includes('ExperimentalWarning')) return { level: 'debug', normalized: msg };
|
||||
if (msg.includes('DeprecationWarning')) return { level: 'debug', normalized: msg };
|
||||
if (msg.includes('Debugger attached')) return { level: 'debug', normalized: msg };
|
||||
|
||||
// Electron restricts NODE_OPTIONS in packaged apps; this is expected and harmless.
|
||||
if (msg.includes('node: --require is not allowed in NODE_OPTIONS')) {
|
||||
return { level: 'debug', normalized: msg };
|
||||
}
|
||||
|
||||
return { level: 'warn', normalized: msg };
|
||||
}
|
||||
|
||||
export function recordGatewayStartupStderrLine(lines: string[], line: string): void {
|
||||
const normalized = line.trim();
|
||||
if (!normalized) return;
|
||||
lines.push(normalized);
|
||||
if (lines.length > MAX_STDERR_LINES) {
|
||||
lines.splice(0, lines.length - MAX_STDERR_LINES);
|
||||
}
|
||||
}
|
||||
38
electron/gateway/state.ts
Normal file
38
electron/gateway/state.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { PORTS } from '../utils/config';
|
||||
import { logger } from '../utils/logger';
|
||||
import type { GatewayStatus } from './manager';
|
||||
|
||||
type GatewayStateHooks = {
|
||||
emitStatus: (status: GatewayStatus) => void;
|
||||
onTransition?: (previousState: GatewayStatus['state'], nextState: GatewayStatus['state']) => void;
|
||||
};
|
||||
|
||||
export class GatewayStateController {
|
||||
private status: GatewayStatus = { state: 'stopped', port: PORTS.OPENCLAW_GATEWAY };
|
||||
|
||||
constructor(private readonly hooks: GatewayStateHooks) {}
|
||||
|
||||
getStatus(): GatewayStatus {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
isConnected(isSocketOpen: boolean): boolean {
|
||||
return this.status.state === 'running' && isSocketOpen;
|
||||
}
|
||||
|
||||
setStatus(update: Partial<GatewayStatus>): void {
|
||||
const previousState = this.status.state;
|
||||
this.status = { ...this.status, ...update };
|
||||
|
||||
if (this.status.state === 'running' && this.status.connectedAt) {
|
||||
this.status.uptime = Date.now() - this.status.connectedAt;
|
||||
}
|
||||
|
||||
this.hooks.emitStatus(this.status);
|
||||
|
||||
if (previousState !== this.status.state) {
|
||||
logger.debug(`Gateway state changed: ${previousState} -> ${this.status.state}`);
|
||||
this.hooks.onTransition?.(previousState, this.status.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
348
electron/gateway/supervisor.ts
Normal file
348
electron/gateway/supervisor.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import { app, utilityProcess } from 'electron';
|
||||
import path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import WebSocket from 'ws';
|
||||
import { getOpenClawDir, getOpenClawEntryPath } from '../utils/paths';
|
||||
import { getUvMirrorEnv } from '../utils/uv-env';
|
||||
import { isPythonReady, setupManagedPython } from '../utils/uv-setup';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export function warmupManagedPythonReadiness(): void {
|
||||
void isPythonReady().then((pythonReady) => {
|
||||
if (!pythonReady) {
|
||||
logger.info('Python environment missing or incomplete, attempting background repair...');
|
||||
void setupManagedPython().catch((err) => {
|
||||
logger.error('Background Python repair failed:', err);
|
||||
});
|
||||
}
|
||||
}).catch((err) => {
|
||||
logger.error('Failed to check Python environment:', err);
|
||||
});
|
||||
}
|
||||
|
||||
export async function terminateOwnedGatewayProcess(child: Electron.UtilityProcess): Promise<void> {
|
||||
let exited = false;
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
child.once('exit', () => {
|
||||
exited = true;
|
||||
resolve();
|
||||
});
|
||||
|
||||
const pid = child.pid;
|
||||
logger.info(`Sending kill to Gateway process (pid=${pid ?? 'unknown'})`);
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore if already exited
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!exited) {
|
||||
logger.warn(`Gateway did not exit in time, force-killing (pid=${pid ?? 'unknown'})`);
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function unloadLaunchctlGatewayService(): Promise<void> {
|
||||
if (process.platform !== 'darwin') return;
|
||||
|
||||
try {
|
||||
const uid = process.getuid?.();
|
||||
if (uid === undefined) return;
|
||||
|
||||
const launchdLabel = 'ai.openclaw.gateway';
|
||||
const serviceTarget = `gui/${uid}/${launchdLabel}`;
|
||||
const cp = await import('child_process');
|
||||
const fsPromises = await import('fs/promises');
|
||||
const os = await import('os');
|
||||
|
||||
const loaded = await new Promise<boolean>((resolve) => {
|
||||
cp.exec(`launchctl print ${serviceTarget}`, { timeout: 5000 }, (err) => {
|
||||
resolve(!err);
|
||||
});
|
||||
});
|
||||
|
||||
if (!loaded) return;
|
||||
|
||||
logger.info(`Unloading launchctl service ${serviceTarget} to prevent auto-respawn`);
|
||||
await new Promise<void>((resolve) => {
|
||||
cp.exec(`launchctl bootout ${serviceTarget}`, { timeout: 10000 }, (err) => {
|
||||
if (err) {
|
||||
logger.warn(`Failed to bootout launchctl service: ${err.message}`);
|
||||
} else {
|
||||
logger.info('Successfully unloaded launchctl gateway service');
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
try {
|
||||
const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${launchdLabel}.plist`);
|
||||
await fsPromises.access(plistPath);
|
||||
await fsPromises.unlink(plistPath);
|
||||
logger.info(`Removed legacy launchd plist to prevent reload on next login: ${plistPath}`);
|
||||
} catch {
|
||||
// File doesn't exist or can't be removed -- not fatal
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Error while unloading launchctl gateway service:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForPortFree(port: number, timeoutMs = 30000): Promise<void> {
|
||||
const net = await import('net');
|
||||
const start = Date.now();
|
||||
const pollInterval = 500;
|
||||
let logged = false;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const available = await new Promise<boolean>((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => resolve(false));
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
server.listen(port, '127.0.0.1');
|
||||
});
|
||||
|
||||
if (available) {
|
||||
const elapsed = Date.now() - start;
|
||||
if (elapsed > pollInterval) {
|
||||
logger.info(`Port ${port} became available after ${elapsed}ms`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!logged) {
|
||||
logger.info(`Waiting for port ${port} to become available (Windows TCP TIME_WAIT)...`);
|
||||
logged = true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
logger.warn(`Port ${port} still occupied after ${timeoutMs}ms, proceeding anyway`);
|
||||
}
|
||||
|
||||
async function getListeningProcessIds(port: number): Promise<string[]> {
|
||||
const cmd = process.platform === 'win32'
|
||||
? `netstat -ano | findstr :${port}`
|
||||
: `lsof -i :${port} -sTCP:LISTEN -t`;
|
||||
|
||||
const cp = await import('child_process');
|
||||
const { stdout } = await new Promise<{ stdout: string }>((resolve) => {
|
||||
cp.exec(cmd, { timeout: 5000, windowsHide: true }, (err, stdout) => {
|
||||
if (err) {
|
||||
resolve({ stdout: '' });
|
||||
} else {
|
||||
resolve({ stdout });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const pids: string[] = [];
|
||||
for (const line of stdout.trim().split(/\r?\n/)) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 5 && parts[3] === 'LISTENING') {
|
||||
pids.push(parts[4]);
|
||||
}
|
||||
}
|
||||
return [...new Set(pids)];
|
||||
}
|
||||
|
||||
return [...new Set(stdout.trim().split(/\r?\n/).map((value) => value.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
async function terminateOrphanedProcessIds(port: number, pids: string[]): Promise<void> {
|
||||
logger.info(`Found orphaned process listening on port ${port} (PIDs: ${pids.join(', ')}), attempting to kill...`);
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
await unloadLaunchctlGatewayService();
|
||||
}
|
||||
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const cp = await import('child_process');
|
||||
await new Promise<void>((resolve) => {
|
||||
cp.exec(
|
||||
`taskkill /F /PID ${pid} /T`,
|
||||
{ timeout: 5000, windowsHide: true },
|
||||
() => resolve(),
|
||||
);
|
||||
});
|
||||
} else {
|
||||
process.kill(parseInt(pid, 10), 'SIGTERM');
|
||||
}
|
||||
} catch {
|
||||
// Ignore processes that have already exited.
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, process.platform === 'win32' ? 2000 : 3000));
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(parseInt(pid, 10), 0);
|
||||
process.kill(parseInt(pid, 10), 'SIGKILL');
|
||||
} catch {
|
||||
// Already exited.
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
export async function findExistingGatewayProcess(options: {
|
||||
port: number;
|
||||
ownedPid?: number;
|
||||
}): Promise<{ port: number; externalToken?: string } | null> {
|
||||
const { port, ownedPid } = options;
|
||||
|
||||
try {
|
||||
try {
|
||||
const pids = await getListeningProcessIds(port);
|
||||
if (pids.length > 0 && (!ownedPid || !pids.includes(String(ownedPid)))) {
|
||||
await terminateOrphanedProcessIds(port, pids);
|
||||
return null;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Error checking for existing process on port:', err);
|
||||
}
|
||||
|
||||
return await new Promise<{ port: number; externalToken?: string } | null>((resolve) => {
|
||||
const testWs = new WebSocket(`ws://localhost:${port}/ws`);
|
||||
const timeout = setTimeout(() => {
|
||||
testWs.close();
|
||||
resolve(null);
|
||||
}, 2000);
|
||||
|
||||
testWs.on('open', () => {
|
||||
clearTimeout(timeout);
|
||||
testWs.close();
|
||||
resolve({ port });
|
||||
});
|
||||
|
||||
testWs.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runOpenClawDoctorRepair(): Promise<boolean> {
|
||||
const openclawDir = getOpenClawDir();
|
||||
const entryScript = getOpenClawEntryPath();
|
||||
if (!existsSync(entryScript)) {
|
||||
logger.error(`Cannot run OpenClaw doctor repair: entry script not found at ${entryScript}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
const target = `${platform}-${arch}`;
|
||||
const binPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'bin')
|
||||
: path.join(process.cwd(), 'resources', 'bin', target);
|
||||
const binPathExists = existsSync(binPath);
|
||||
const finalPath = binPathExists
|
||||
? `${binPath}${path.delimiter}${process.env.PATH || ''}`
|
||||
: process.env.PATH || '';
|
||||
|
||||
const uvEnv = await getUvMirrorEnv();
|
||||
const doctorArgs = ['doctor', '--fix', '--yes', '--non-interactive'];
|
||||
logger.info(
|
||||
`Running OpenClaw doctor repair (entry="${entryScript}", args="${doctorArgs.join(' ')}", cwd="${openclawDir}", bundledBin=${binPathExists ? 'yes' : 'no'})`,
|
||||
);
|
||||
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const forkEnv: Record<string, string | undefined> = {
|
||||
...process.env,
|
||||
PATH: finalPath,
|
||||
...uvEnv,
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
};
|
||||
|
||||
const child = utilityProcess.fork(entryScript, doctorArgs, {
|
||||
cwd: openclawDir,
|
||||
stdio: 'pipe',
|
||||
env: forkEnv as NodeJS.ProcessEnv,
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const finish = (ok: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(ok);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
logger.error('OpenClaw doctor repair timed out after 120000ms');
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
finish(false);
|
||||
}, 120000);
|
||||
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
logger.error('Failed to spawn OpenClaw doctor repair process:', err);
|
||||
finish(false);
|
||||
});
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
const raw = data.toString();
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const normalized = line.trim();
|
||||
if (!normalized) continue;
|
||||
logger.debug(`[Gateway doctor stdout] ${normalized}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
const raw = data.toString();
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const normalized = line.trim();
|
||||
if (!normalized) continue;
|
||||
logger.warn(`[Gateway doctor stderr] ${normalized}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('exit', (code: number) => {
|
||||
clearTimeout(timeout);
|
||||
if (code === 0) {
|
||||
logger.info('OpenClaw doctor repair completed successfully');
|
||||
finish(true);
|
||||
return;
|
||||
}
|
||||
logger.warn(`OpenClaw doctor repair exited (code=${code})`);
|
||||
finish(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
318
electron/gateway/ws-client.ts
Normal file
318
electron/gateway/ws-client.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import WebSocket from 'ws';
|
||||
import type { DeviceIdentity } from '../utils/device-identity';
|
||||
import type { PendingGatewayRequest } from './request-store';
|
||||
import {
|
||||
buildDeviceAuthPayload,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
signDevicePayload,
|
||||
} from '../utils/device-identity';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export async function probeGatewayReady(
|
||||
port: number,
|
||||
timeoutMs = 1500,
|
||||
): Promise<boolean> {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const testWs = new WebSocket(`ws://localhost:${port}/ws`);
|
||||
let settled = false;
|
||||
|
||||
const resolveOnce = (value: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
testWs.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
resolveOnce(false);
|
||||
}, timeoutMs);
|
||||
|
||||
testWs.on('open', () => {
|
||||
// Do not resolve on plain socket open. The gateway can accept the TCP/WebSocket
|
||||
// connection before it is ready to issue protocol challenges, which previously
|
||||
// caused a false "ready" result and then a full connect() stall.
|
||||
});
|
||||
|
||||
testWs.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString()) as { type?: string; event?: string };
|
||||
if (message.type === 'event' && message.event === 'connect.challenge') {
|
||||
resolveOnce(true);
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed probe payloads
|
||||
}
|
||||
});
|
||||
|
||||
testWs.on('error', () => {
|
||||
resolveOnce(false);
|
||||
});
|
||||
|
||||
testWs.on('close', () => {
|
||||
resolveOnce(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForGatewayReady(options: {
|
||||
port: number;
|
||||
getProcessExitCode: () => number | null;
|
||||
retries?: number;
|
||||
intervalMs?: number;
|
||||
}): Promise<void> {
|
||||
const retries = options.retries ?? 2400;
|
||||
const intervalMs = options.intervalMs ?? 200;
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
const exitCode = options.getProcessExitCode();
|
||||
if (exitCode !== null) {
|
||||
logger.error(`Gateway process exited before ready (code=${exitCode})`);
|
||||
throw new Error(`Gateway process exited before becoming ready (code=${exitCode})`);
|
||||
}
|
||||
|
||||
try {
|
||||
const ready = await probeGatewayReady(options.port, 1500);
|
||||
if (ready) {
|
||||
logger.debug(`Gateway ready after ${i + 1} attempt(s)`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Gateway not ready yet.
|
||||
}
|
||||
|
||||
if (i > 0 && i % 10 === 0) {
|
||||
logger.debug(`Still waiting for Gateway... (attempt ${i + 1}/${retries})`);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
|
||||
logger.error(`Gateway failed to become ready after ${retries} attempts on port ${options.port}`);
|
||||
throw new Error(`Gateway failed to start after ${retries} retries (port ${options.port})`);
|
||||
}
|
||||
|
||||
export function buildGatewayConnectFrame(options: {
|
||||
challengeNonce: string;
|
||||
token: string;
|
||||
deviceIdentity: DeviceIdentity | null;
|
||||
platform: string;
|
||||
}): { connectId: string; frame: Record<string, unknown> } {
|
||||
const connectId = `connect-${Date.now()}`;
|
||||
const role = 'operator';
|
||||
const scopes = ['operator.admin'];
|
||||
const signedAtMs = Date.now();
|
||||
const clientId = 'gateway-client';
|
||||
const clientMode = 'ui';
|
||||
|
||||
const device = (() => {
|
||||
if (!options.deviceIdentity) return undefined;
|
||||
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: options.deviceIdentity.deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
role,
|
||||
scopes,
|
||||
signedAtMs,
|
||||
token: options.token ?? null,
|
||||
nonce: options.challengeNonce,
|
||||
});
|
||||
const signature = signDevicePayload(options.deviceIdentity.privateKeyPem, payload);
|
||||
return {
|
||||
id: options.deviceIdentity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(options.deviceIdentity.publicKeyPem),
|
||||
signature,
|
||||
signedAt: signedAtMs,
|
||||
nonce: options.challengeNonce,
|
||||
};
|
||||
})();
|
||||
|
||||
return {
|
||||
connectId,
|
||||
frame: {
|
||||
type: 'req',
|
||||
id: connectId,
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: clientId,
|
||||
displayName: 'ClawX',
|
||||
version: '0.1.0',
|
||||
platform: options.platform,
|
||||
mode: clientMode,
|
||||
},
|
||||
auth: {
|
||||
token: options.token,
|
||||
},
|
||||
caps: [],
|
||||
role,
|
||||
scopes,
|
||||
device,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function connectGatewaySocket(options: {
|
||||
port: number;
|
||||
deviceIdentity: DeviceIdentity | null;
|
||||
platform: string;
|
||||
pendingRequests: Map<string, PendingGatewayRequest>;
|
||||
getToken: () => Promise<string>;
|
||||
onHandshakeComplete: (ws: WebSocket) => void;
|
||||
onMessage: (message: unknown) => void;
|
||||
onCloseAfterHandshake: () => void;
|
||||
}): Promise<WebSocket> {
|
||||
logger.debug(`Connecting Gateway WebSocket (ws://localhost:${options.port}/ws)`);
|
||||
|
||||
return await new Promise<WebSocket>((resolve, reject) => {
|
||||
const wsUrl = `ws://localhost:${options.port}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
let handshakeComplete = false;
|
||||
let connectId: string | null = null;
|
||||
let handshakeTimeout: NodeJS.Timeout | null = null;
|
||||
let challengeTimer: NodeJS.Timeout | null = null;
|
||||
let challengeReceived = false;
|
||||
let settled = false;
|
||||
|
||||
const cleanupHandshakeRequest = () => {
|
||||
if (challengeTimer) {
|
||||
clearTimeout(challengeTimer);
|
||||
challengeTimer = null;
|
||||
}
|
||||
if (handshakeTimeout) {
|
||||
clearTimeout(handshakeTimeout);
|
||||
handshakeTimeout = null;
|
||||
}
|
||||
if (connectId && options.pendingRequests.has(connectId)) {
|
||||
const request = options.pendingRequests.get(connectId);
|
||||
if (request) {
|
||||
clearTimeout(request.timeout);
|
||||
}
|
||||
options.pendingRequests.delete(connectId);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveOnce = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupHandshakeRequest();
|
||||
resolve(ws);
|
||||
};
|
||||
|
||||
const rejectOnce = (error: unknown) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanupHandshakeRequest();
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
};
|
||||
|
||||
const sendConnectHandshake = async (challengeNonce: string) => {
|
||||
logger.debug('Sending connect handshake with challenge nonce');
|
||||
|
||||
const currentToken = await options.getToken();
|
||||
const connectPayload = buildGatewayConnectFrame({
|
||||
challengeNonce,
|
||||
token: currentToken,
|
||||
deviceIdentity: options.deviceIdentity,
|
||||
platform: options.platform,
|
||||
});
|
||||
connectId = connectPayload.connectId;
|
||||
|
||||
ws.send(JSON.stringify(connectPayload.frame));
|
||||
|
||||
const requestTimeout = setTimeout(() => {
|
||||
if (!handshakeComplete) {
|
||||
logger.error('Gateway connect handshake timed out');
|
||||
ws.close();
|
||||
rejectOnce(new Error('Connect handshake timeout'));
|
||||
}
|
||||
}, 10000);
|
||||
handshakeTimeout = requestTimeout;
|
||||
|
||||
options.pendingRequests.set(connectId, {
|
||||
resolve: () => {
|
||||
handshakeComplete = true;
|
||||
logger.debug('Gateway connect handshake completed');
|
||||
options.onHandshakeComplete(ws);
|
||||
resolveOnce();
|
||||
},
|
||||
reject: (error) => {
|
||||
logger.error('Gateway connect handshake failed:', error);
|
||||
rejectOnce(error);
|
||||
},
|
||||
timeout: requestTimeout,
|
||||
});
|
||||
};
|
||||
|
||||
challengeTimer = setTimeout(() => {
|
||||
if (!challengeReceived && !settled) {
|
||||
logger.error('Gateway connect.challenge not received within timeout');
|
||||
ws.close();
|
||||
rejectOnce(new Error('Timed out waiting for connect.challenge from Gateway'));
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
ws.on('open', () => {
|
||||
logger.debug('Gateway WebSocket opened, waiting for connect.challenge...');
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
if (
|
||||
!challengeReceived &&
|
||||
typeof message === 'object' && message !== null &&
|
||||
message.type === 'event' && message.event === 'connect.challenge'
|
||||
) {
|
||||
challengeReceived = true;
|
||||
if (challengeTimer) {
|
||||
clearTimeout(challengeTimer);
|
||||
challengeTimer = null;
|
||||
}
|
||||
const nonce = message.payload?.nonce as string | undefined;
|
||||
if (!nonce) {
|
||||
rejectOnce(new Error('Gateway connect.challenge missing nonce'));
|
||||
return;
|
||||
}
|
||||
logger.debug('Received connect.challenge, sending handshake');
|
||||
void sendConnectHandshake(nonce);
|
||||
return;
|
||||
}
|
||||
|
||||
options.onMessage(message);
|
||||
} catch (error) {
|
||||
logger.debug('Failed to parse Gateway WebSocket message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
const reasonStr = reason?.toString() || 'unknown';
|
||||
logger.warn(`Gateway WebSocket closed (code=${code}, reason=${reasonStr}, handshake=${handshakeComplete ? 'ok' : 'pending'})`);
|
||||
if (!handshakeComplete) {
|
||||
rejectOnce(new Error(`WebSocket closed before handshake: ${reasonStr}`));
|
||||
return;
|
||||
}
|
||||
cleanupHandshakeRequest();
|
||||
options.onCloseAfterHandshake();
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
if (error.message?.includes('closed before handshake') || (error as NodeJS.ErrnoException).code === 'ECONNREFUSED') {
|
||||
logger.debug(`Gateway WebSocket connection error (transient): ${error.message}`);
|
||||
} else {
|
||||
logger.error('Gateway WebSocket error:', error);
|
||||
}
|
||||
if (!handshakeComplete) {
|
||||
rejectOnce(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
* Manages window creation, system tray, and IPC handlers
|
||||
*/
|
||||
import { app, BrowserWindow, nativeImage, session, shell } from 'electron';
|
||||
import type { Server } from 'node:http';
|
||||
import { join } from 'path';
|
||||
import { GatewayManager } from '../gateway/manager';
|
||||
import { registerIpcHandlers } from './ipc-handlers';
|
||||
@@ -20,6 +21,12 @@ import { isQuitting, setQuitting } from './app-state';
|
||||
import { applyProxySettings } from './proxy';
|
||||
import { getSetting } from '../utils/store';
|
||||
import { ensureBuiltinSkillsInstalled } from '../utils/skill-config';
|
||||
import { startHostApiServer } from '../api/server';
|
||||
import { HostEventBus } from '../api/event-bus';
|
||||
import { deviceOAuthManager } from '../utils/device-oauth';
|
||||
import { browserOAuthManager } from '../utils/browser-oauth';
|
||||
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
||||
import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync';
|
||||
|
||||
// Disable GPU hardware acceleration globally for maximum stability across
|
||||
// all GPU configurations (no GPU, integrated, discrete).
|
||||
@@ -58,6 +65,8 @@ if (!gotTheLock) {
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
const gatewayManager = new GatewayManager();
|
||||
const clawHubService = new ClawHubService();
|
||||
const hostEventBus = new HostEventBus();
|
||||
let hostApiServer: Server | null = null;
|
||||
|
||||
/**
|
||||
* Resolve the icons directory path (works in both dev and packaged mode)
|
||||
@@ -185,6 +194,13 @@ async function initialize(): Promise<void> {
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers(gatewayManager, clawHubService, mainWindow);
|
||||
|
||||
hostApiServer = startHostApiServer({
|
||||
gatewayManager,
|
||||
clawHubService,
|
||||
eventBus: hostEventBus,
|
||||
mainWindow,
|
||||
});
|
||||
|
||||
// Register update handlers
|
||||
registerUpdateHandlers(appUpdater, mainWindow);
|
||||
|
||||
@@ -216,10 +232,82 @@ async function initialize(): Promise<void> {
|
||||
logger.warn('Failed to install built-in skills:', error);
|
||||
});
|
||||
|
||||
// Bridge gateway and host-side events before any auto-start logic runs, so
|
||||
// renderer subscribers observe the full startup lifecycle.
|
||||
gatewayManager.on('status', (status: { state: string }) => {
|
||||
hostEventBus.emit('gateway:status', status);
|
||||
if (status.state === 'running') {
|
||||
void ensureClawXContext().catch((error) => {
|
||||
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
gatewayManager.on('error', (error) => {
|
||||
hostEventBus.emit('gateway:error', { message: error.message });
|
||||
});
|
||||
|
||||
gatewayManager.on('notification', (notification) => {
|
||||
hostEventBus.emit('gateway:notification', notification);
|
||||
});
|
||||
|
||||
gatewayManager.on('chat:message', (data) => {
|
||||
hostEventBus.emit('gateway:chat-message', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('channel:status', (data) => {
|
||||
hostEventBus.emit('gateway:channel-status', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('exit', (code) => {
|
||||
hostEventBus.emit('gateway:exit', { code });
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:code', (payload) => {
|
||||
hostEventBus.emit('oauth:code', payload);
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:start', (payload) => {
|
||||
hostEventBus.emit('oauth:start', payload);
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:success', (payload) => {
|
||||
hostEventBus.emit('oauth:success', { ...payload, success: true });
|
||||
});
|
||||
|
||||
deviceOAuthManager.on('oauth:error', (error) => {
|
||||
hostEventBus.emit('oauth:error', error);
|
||||
});
|
||||
|
||||
browserOAuthManager.on('oauth:start', (payload) => {
|
||||
hostEventBus.emit('oauth:start', payload);
|
||||
});
|
||||
|
||||
browserOAuthManager.on('oauth:success', (payload) => {
|
||||
hostEventBus.emit('oauth:success', { ...payload, success: true });
|
||||
});
|
||||
|
||||
browserOAuthManager.on('oauth:error', (error) => {
|
||||
hostEventBus.emit('oauth:error', error);
|
||||
});
|
||||
|
||||
whatsAppLoginManager.on('qr', (data) => {
|
||||
hostEventBus.emit('channel:whatsapp-qr', data);
|
||||
});
|
||||
|
||||
whatsAppLoginManager.on('success', (data) => {
|
||||
hostEventBus.emit('channel:whatsapp-success', data);
|
||||
});
|
||||
|
||||
whatsAppLoginManager.on('error', (error) => {
|
||||
hostEventBus.emit('channel:whatsapp-error', error);
|
||||
});
|
||||
|
||||
// Start Gateway automatically (this seeds missing bootstrap files with full templates)
|
||||
const gatewayAutoStart = await getSetting('gatewayAutoStart');
|
||||
if (gatewayAutoStart) {
|
||||
try {
|
||||
await syncAllProviderAuthToRuntime();
|
||||
logger.debug('Auto-starting Gateway...');
|
||||
await gatewayManager.start();
|
||||
logger.info('Gateway auto-start succeeded');
|
||||
@@ -247,16 +335,6 @@ async function initialize(): Promise<void> {
|
||||
}).catch((error) => {
|
||||
logger.warn('CLI auto-install failed:', error);
|
||||
});
|
||||
|
||||
// Re-apply ClawX context after every gateway restart because the gateway
|
||||
// may re-seed workspace files with clean templates (losing ClawX markers).
|
||||
gatewayManager.on('status', (status: { state: string }) => {
|
||||
if (status.state === 'running') {
|
||||
void ensureClawXContext().catch((error) => {
|
||||
logger.warn('Failed to re-merge ClawX context after gateway reconnect:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// When a second instance is launched, focus the existing window instead.
|
||||
@@ -293,6 +371,8 @@ app.on('window-all-closed', () => {
|
||||
|
||||
app.on('before-quit', () => {
|
||||
setQuitting();
|
||||
hostEventBus.closeAll();
|
||||
hostApiServer?.close();
|
||||
// Fire-and-forget: do not await gatewayManager.stop() here.
|
||||
// Awaiting inside before-quit can stall Electron's quit sequence.
|
||||
void gatewayManager.stop().catch((err) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
35
electron/services/providers/provider-migration.ts
Normal file
35
electron/services/providers/provider-migration.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ProviderConfig } from '../../shared/providers/types';
|
||||
import {
|
||||
getDefaultProviderAccountId,
|
||||
providerConfigToAccount,
|
||||
saveProviderAccount,
|
||||
} from './provider-store';
|
||||
import { getClawXProviderStore } from './store-instance';
|
||||
|
||||
const PROVIDER_STORE_SCHEMA_VERSION = 1;
|
||||
|
||||
export async function ensureProviderStoreMigrated(): Promise<void> {
|
||||
const store = await getClawXProviderStore();
|
||||
const schemaVersion = Number(store.get('schemaVersion') ?? 0);
|
||||
|
||||
if (schemaVersion >= PROVIDER_STORE_SCHEMA_VERSION) {
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyProviders = (store.get('providers') ?? {}) as Record<string, ProviderConfig>;
|
||||
const defaultProviderId = (store.get('defaultProvider') ?? null) as string | null;
|
||||
const existingDefaultAccountId = await getDefaultProviderAccountId();
|
||||
|
||||
for (const provider of Object.values(legacyProviders)) {
|
||||
const account = providerConfigToAccount(provider, {
|
||||
isDefault: provider.id === defaultProviderId,
|
||||
});
|
||||
await saveProviderAccount(account);
|
||||
}
|
||||
|
||||
if (!existingDefaultAccountId && defaultProviderId) {
|
||||
store.set('defaultProviderAccountId', defaultProviderId);
|
||||
}
|
||||
|
||||
store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION);
|
||||
}
|
||||
460
electron/services/providers/provider-runtime-sync.ts
Normal file
460
electron/services/providers/provider-runtime-sync.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
import type { GatewayManager } from '../../gateway/manager';
|
||||
import { getProviderAccount, listProviderAccounts } from './provider-store';
|
||||
import { getProviderSecret } from '../secrets/secret-store';
|
||||
import type { ProviderConfig } from '../../utils/secure-storage';
|
||||
import { getAllProviders, getApiKey, getDefaultProvider, getProvider } from '../../utils/secure-storage';
|
||||
import { getProviderConfig, getProviderDefaultModel } from '../../utils/provider-registry';
|
||||
import {
|
||||
removeProviderFromOpenClaw,
|
||||
saveOAuthTokenToOpenClaw,
|
||||
saveProviderKeyToOpenClaw,
|
||||
setOpenClawDefaultModel,
|
||||
setOpenClawDefaultModelWithOverride,
|
||||
syncProviderConfigToOpenClaw,
|
||||
updateAgentModelProvider,
|
||||
} from '../../utils/openclaw-auth';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
const GOOGLE_OAUTH_RUNTIME_PROVIDER = 'google-gemini-cli';
|
||||
const GOOGLE_OAUTH_DEFAULT_MODEL_REF = `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/gemini-3-pro-preview`;
|
||||
|
||||
export function getOpenClawProviderKey(type: string, providerId: string): string {
|
||||
if (type === 'custom' || type === 'ollama') {
|
||||
const suffix = providerId.replace(/-/g, '').slice(0, 8);
|
||||
return `${type}-${suffix}`;
|
||||
}
|
||||
if (type === 'minimax-portal-cn') {
|
||||
return 'minimax-portal';
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
async function resolveRuntimeProviderKey(config: ProviderConfig): Promise<string> {
|
||||
const account = await getProviderAccount(config.id);
|
||||
if (config.type === 'google' && account?.authMode === 'oauth_browser') {
|
||||
return GOOGLE_OAUTH_RUNTIME_PROVIDER;
|
||||
}
|
||||
return getOpenClawProviderKey(config.type, config.id);
|
||||
}
|
||||
|
||||
async function isGoogleBrowserOAuthProvider(config: ProviderConfig): Promise<boolean> {
|
||||
const account = await getProviderAccount(config.id);
|
||||
if (config.type !== 'google' || account?.authMode !== 'oauth_browser') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const secret = await getProviderSecret(config.id);
|
||||
return secret?.type === 'oauth';
|
||||
}
|
||||
|
||||
export function getProviderModelRef(config: ProviderConfig): string | undefined {
|
||||
const providerKey = getOpenClawProviderKey(config.type, config.id);
|
||||
|
||||
if (config.model) {
|
||||
return config.model.startsWith(`${providerKey}/`)
|
||||
? config.model
|
||||
: `${providerKey}/${config.model}`;
|
||||
}
|
||||
|
||||
const defaultModel = getProviderDefaultModel(config.type);
|
||||
if (!defaultModel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return defaultModel.startsWith(`${providerKey}/`)
|
||||
? defaultModel
|
||||
: `${providerKey}/${defaultModel}`;
|
||||
}
|
||||
|
||||
export async function getProviderFallbackModelRefs(config: ProviderConfig): Promise<string[]> {
|
||||
const allProviders = await getAllProviders();
|
||||
const providerMap = new Map(allProviders.map((provider) => [provider.id, provider]));
|
||||
const seen = new Set<string>();
|
||||
const results: string[] = [];
|
||||
const providerKey = getOpenClawProviderKey(config.type, config.id);
|
||||
|
||||
for (const fallbackModel of config.fallbackModels ?? []) {
|
||||
const normalizedModel = fallbackModel.trim();
|
||||
if (!normalizedModel) continue;
|
||||
|
||||
const modelRef = normalizedModel.startsWith(`${providerKey}/`)
|
||||
? normalizedModel
|
||||
: `${providerKey}/${normalizedModel}`;
|
||||
|
||||
if (seen.has(modelRef)) continue;
|
||||
seen.add(modelRef);
|
||||
results.push(modelRef);
|
||||
}
|
||||
|
||||
for (const fallbackId of config.fallbackProviderIds ?? []) {
|
||||
if (!fallbackId || fallbackId === config.id) continue;
|
||||
|
||||
const fallbackProvider = providerMap.get(fallbackId);
|
||||
if (!fallbackProvider) continue;
|
||||
|
||||
const modelRef = getProviderModelRef(fallbackProvider);
|
||||
if (!modelRef || seen.has(modelRef)) continue;
|
||||
|
||||
seen.add(modelRef);
|
||||
results.push(modelRef);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function scheduleGatewayRestart(
|
||||
gatewayManager: GatewayManager | undefined,
|
||||
message: string,
|
||||
options?: { delayMs?: number; onlyIfRunning?: boolean },
|
||||
): void {
|
||||
if (!gatewayManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options?.onlyIfRunning && gatewayManager.getStatus().state === 'stopped') {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(message);
|
||||
gatewayManager.debouncedRestart(options?.delayMs);
|
||||
}
|
||||
|
||||
export async function syncProviderApiKeyToRuntime(
|
||||
providerType: string,
|
||||
providerId: string,
|
||||
apiKey: string,
|
||||
): Promise<void> {
|
||||
const ock = getOpenClawProviderKey(providerType, providerId);
|
||||
await saveProviderKeyToOpenClaw(ock, apiKey);
|
||||
}
|
||||
|
||||
export async function syncAllProviderAuthToRuntime(): Promise<void> {
|
||||
const accounts = await listProviderAccounts();
|
||||
|
||||
for (const account of accounts) {
|
||||
const runtimeProviderKey = await resolveRuntimeProviderKey({
|
||||
id: account.id,
|
||||
name: account.label,
|
||||
type: account.vendorId,
|
||||
baseUrl: account.baseUrl,
|
||||
model: account.model,
|
||||
fallbackModels: account.fallbackModels,
|
||||
fallbackProviderIds: account.fallbackAccountIds,
|
||||
enabled: account.enabled,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
});
|
||||
|
||||
const secret = await getProviderSecret(account.id);
|
||||
if (!secret) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (secret.type === 'api_key') {
|
||||
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (secret.type === 'local' && secret.apiKey) {
|
||||
await saveProviderKeyToOpenClaw(runtimeProviderKey, secret.apiKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (secret.type === 'oauth') {
|
||||
await saveOAuthTokenToOpenClaw(runtimeProviderKey, {
|
||||
access: secret.accessToken,
|
||||
refresh: secret.refreshToken,
|
||||
expires: secret.expiresAt,
|
||||
email: secret.email,
|
||||
projectId: secret.subject,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncSavedProviderToRuntime(
|
||||
config: ProviderConfig,
|
||||
apiKey: string | undefined,
|
||||
gatewayManager?: GatewayManager,
|
||||
): Promise<void> {
|
||||
const ock = await resolveRuntimeProviderKey(config);
|
||||
const secret = await getProviderSecret(config.id);
|
||||
|
||||
if (apiKey !== undefined) {
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await saveProviderKeyToOpenClaw(ock, trimmedKey);
|
||||
}
|
||||
} else if (secret?.type === 'api_key') {
|
||||
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
|
||||
} else if (secret?.type === 'oauth') {
|
||||
await saveOAuthTokenToOpenClaw(ock, {
|
||||
access: secret.accessToken,
|
||||
refresh: secret.refreshToken,
|
||||
expires: secret.expiresAt,
|
||||
email: secret.email,
|
||||
projectId: secret.subject,
|
||||
});
|
||||
} else if (secret?.type === 'local' && secret.apiKey) {
|
||||
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
|
||||
}
|
||||
|
||||
const meta = getProviderConfig(config.type);
|
||||
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
|
||||
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
await syncProviderConfigToOpenClaw(ock, config.model, {
|
||||
baseUrl: config.baseUrl || meta?.baseUrl,
|
||||
api,
|
||||
apiKeyEnv: meta?.apiKeyEnv,
|
||||
headers: meta?.headers,
|
||||
});
|
||||
|
||||
if (config.type === 'custom' || config.type === 'ollama') {
|
||||
const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id);
|
||||
if (resolvedKey && config.baseUrl) {
|
||||
const modelId = config.model;
|
||||
await updateAgentModelProvider(ock, {
|
||||
baseUrl: config.baseUrl,
|
||||
api: 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
apiKey: resolvedKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
scheduleGatewayRestart(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after saving provider "${ock}" config`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncUpdatedProviderToRuntime(
|
||||
config: ProviderConfig,
|
||||
apiKey: string | undefined,
|
||||
gatewayManager?: GatewayManager,
|
||||
): Promise<void> {
|
||||
const ock = await resolveRuntimeProviderKey(config);
|
||||
const fallbackModels = await getProviderFallbackModelRefs(config);
|
||||
const meta = getProviderConfig(config.type);
|
||||
const api = config.type === 'custom' || config.type === 'ollama' ? 'openai-completions' : meta?.api;
|
||||
const secret = await getProviderSecret(config.id);
|
||||
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (apiKey !== undefined) {
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await saveProviderKeyToOpenClaw(ock, trimmedKey);
|
||||
}
|
||||
} else if (secret?.type === 'api_key') {
|
||||
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
|
||||
} else if (secret?.type === 'oauth') {
|
||||
await saveOAuthTokenToOpenClaw(ock, {
|
||||
access: secret.accessToken,
|
||||
refresh: secret.refreshToken,
|
||||
expires: secret.expiresAt,
|
||||
email: secret.email,
|
||||
projectId: secret.subject,
|
||||
});
|
||||
} else if (secret?.type === 'local' && secret.apiKey) {
|
||||
await saveProviderKeyToOpenClaw(ock, secret.apiKey);
|
||||
}
|
||||
|
||||
await syncProviderConfigToOpenClaw(ock, config.model, {
|
||||
baseUrl: config.baseUrl || meta?.baseUrl,
|
||||
api,
|
||||
apiKeyEnv: meta?.apiKeyEnv,
|
||||
headers: meta?.headers,
|
||||
});
|
||||
|
||||
if (config.type === 'custom' || config.type === 'ollama') {
|
||||
const resolvedKey = apiKey !== undefined ? (apiKey.trim() || null) : await getApiKey(config.id);
|
||||
if (resolvedKey && config.baseUrl) {
|
||||
const modelId = config.model;
|
||||
await updateAgentModelProvider(ock, {
|
||||
baseUrl: config.baseUrl,
|
||||
api: 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
apiKey: resolvedKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProviderId = await getDefaultProvider();
|
||||
if (defaultProviderId === config.id) {
|
||||
const modelOverride = config.model ? `${ock}/${config.model}` : undefined;
|
||||
if (config.type !== 'custom' && config.type !== 'ollama') {
|
||||
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
|
||||
} else {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: config.baseUrl,
|
||||
api: 'openai-completions',
|
||||
}, fallbackModels);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleGatewayRestart(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after updating provider "${ock}" config`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncDeletedProviderToRuntime(
|
||||
provider: ProviderConfig | null,
|
||||
providerId: string,
|
||||
gatewayManager?: GatewayManager,
|
||||
runtimeProviderKey?: string,
|
||||
): Promise<void> {
|
||||
if (!provider?.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
|
||||
scheduleGatewayRestart(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after deleting provider "${ock}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncDeletedProviderApiKeyToRuntime(
|
||||
provider: ProviderConfig | null,
|
||||
providerId: string,
|
||||
runtimeProviderKey?: string,
|
||||
): Promise<void> {
|
||||
if (!provider?.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ock = runtimeProviderKey ?? await resolveRuntimeProviderKey({ ...provider, id: providerId });
|
||||
await removeProviderFromOpenClaw(ock);
|
||||
}
|
||||
|
||||
export async function syncDefaultProviderToRuntime(
|
||||
providerId: string,
|
||||
gatewayManager?: GatewayManager,
|
||||
): Promise<void> {
|
||||
const provider = await getProvider(providerId);
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ock = await resolveRuntimeProviderKey(provider);
|
||||
const providerKey = await getApiKey(providerId);
|
||||
const fallbackModels = await getProviderFallbackModelRefs(provider);
|
||||
const oauthTypes = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
||||
const isGoogleOAuthProvider = await isGoogleBrowserOAuthProvider(provider);
|
||||
const isOAuthProvider = (oauthTypes.includes(provider.type) && !providerKey) || isGoogleOAuthProvider;
|
||||
|
||||
if (!isOAuthProvider) {
|
||||
const modelOverride = provider.model
|
||||
? (provider.model.startsWith(`${ock}/`) ? provider.model : `${ock}/${provider.model}`)
|
||||
: undefined;
|
||||
|
||||
if (provider.type === 'custom' || provider.type === 'ollama') {
|
||||
await setOpenClawDefaultModelWithOverride(ock, modelOverride, {
|
||||
baseUrl: provider.baseUrl,
|
||||
api: 'openai-completions',
|
||||
}, fallbackModels);
|
||||
} else {
|
||||
await setOpenClawDefaultModel(ock, modelOverride, fallbackModels);
|
||||
}
|
||||
|
||||
if (providerKey) {
|
||||
await saveProviderKeyToOpenClaw(ock, providerKey);
|
||||
}
|
||||
} else {
|
||||
if (isGoogleOAuthProvider) {
|
||||
const secret = await getProviderSecret(provider.id);
|
||||
if (secret?.type === 'oauth') {
|
||||
await saveOAuthTokenToOpenClaw(GOOGLE_OAUTH_RUNTIME_PROVIDER, {
|
||||
access: secret.accessToken,
|
||||
refresh: secret.refreshToken,
|
||||
expires: secret.expiresAt,
|
||||
email: secret.email,
|
||||
projectId: secret.subject,
|
||||
});
|
||||
}
|
||||
|
||||
const modelOverride = provider.model
|
||||
? (provider.model.startsWith(`${GOOGLE_OAUTH_RUNTIME_PROVIDER}/`)
|
||||
? provider.model
|
||||
: `${GOOGLE_OAUTH_RUNTIME_PROVIDER}/${provider.model}`)
|
||||
: GOOGLE_OAUTH_DEFAULT_MODEL_REF;
|
||||
|
||||
await setOpenClawDefaultModel(GOOGLE_OAUTH_RUNTIME_PROVIDER, modelOverride, fallbackModels);
|
||||
logger.info(`Configured openclaw.json for Google browser OAuth provider "${provider.id}"`);
|
||||
scheduleGatewayRestart(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after provider switch to "${GOOGLE_OAUTH_RUNTIME_PROVIDER}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultBaseUrl = provider.type === 'minimax-portal'
|
||||
? 'https://api.minimax.io/anthropic'
|
||||
: (provider.type === 'minimax-portal-cn' ? 'https://api.minimaxi.com/anthropic' : 'https://portal.qwen.ai/v1');
|
||||
const api: 'anthropic-messages' | 'openai-completions' =
|
||||
(provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
|
||||
? 'anthropic-messages'
|
||||
: 'openai-completions';
|
||||
|
||||
let baseUrl = provider.baseUrl || defaultBaseUrl;
|
||||
if ((provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn') && baseUrl) {
|
||||
baseUrl = baseUrl.replace(/\/v1$/, '').replace(/\/anthropic$/, '').replace(/\/$/, '') + '/anthropic';
|
||||
}
|
||||
|
||||
const targetProviderKey = (provider.type === 'minimax-portal' || provider.type === 'minimax-portal-cn')
|
||||
? 'minimax-portal'
|
||||
: provider.type;
|
||||
|
||||
await setOpenClawDefaultModelWithOverride(targetProviderKey, getProviderModelRef(provider), {
|
||||
baseUrl,
|
||||
api,
|
||||
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
||||
apiKeyEnv: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||
}, fallbackModels);
|
||||
|
||||
logger.info(`Configured openclaw.json for OAuth provider "${provider.type}"`);
|
||||
|
||||
try {
|
||||
const defaultModelId = provider.model?.split('/').pop();
|
||||
await updateAgentModelProvider(targetProviderKey, {
|
||||
baseUrl,
|
||||
api,
|
||||
authHeader: targetProviderKey === 'minimax-portal' ? true : undefined,
|
||||
apiKey: targetProviderKey === 'minimax-portal' ? 'minimax-oauth' : 'qwen-oauth',
|
||||
models: defaultModelId ? [{ id: defaultModelId, name: defaultModelId }] : [],
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to update models.json for OAuth provider "${targetProviderKey}":`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(provider.type === 'custom' || provider.type === 'ollama') &&
|
||||
providerKey &&
|
||||
provider.baseUrl
|
||||
) {
|
||||
const modelId = provider.model;
|
||||
await updateAgentModelProvider(ock, {
|
||||
baseUrl: provider.baseUrl,
|
||||
api: 'openai-completions',
|
||||
models: modelId ? [{ id: modelId, name: modelId }] : [],
|
||||
apiKey: providerKey,
|
||||
});
|
||||
}
|
||||
|
||||
scheduleGatewayRestart(
|
||||
gatewayManager,
|
||||
`Scheduling Gateway restart after provider switch to "${ock}"`,
|
||||
{ onlyIfRunning: true },
|
||||
);
|
||||
}
|
||||
168
electron/services/providers/provider-service.ts
Normal file
168
electron/services/providers/provider-service.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
PROVIDER_DEFINITIONS,
|
||||
getProviderDefinition,
|
||||
} from '../../shared/providers/registry';
|
||||
import type {
|
||||
ProviderAccount,
|
||||
ProviderConfig,
|
||||
ProviderDefinition,
|
||||
} from '../../shared/providers/types';
|
||||
import { ensureProviderStoreMigrated } from './provider-migration';
|
||||
import {
|
||||
getDefaultProviderAccountId,
|
||||
getProviderAccount,
|
||||
listProviderAccounts,
|
||||
providerAccountToConfig,
|
||||
providerConfigToAccount,
|
||||
saveProviderAccount,
|
||||
setDefaultProviderAccount,
|
||||
} from './provider-store';
|
||||
import {
|
||||
deleteApiKey,
|
||||
deleteProvider,
|
||||
getAllProviders,
|
||||
getAllProvidersWithKeyInfo,
|
||||
getApiKey,
|
||||
getDefaultProvider,
|
||||
getProvider,
|
||||
hasApiKey,
|
||||
saveProvider,
|
||||
setDefaultProvider,
|
||||
storeApiKey,
|
||||
} from '../../utils/secure-storage';
|
||||
import type { ProviderWithKeyInfo } from '../../shared/providers/types';
|
||||
|
||||
export class ProviderService {
|
||||
async listVendors(): Promise<ProviderDefinition[]> {
|
||||
return PROVIDER_DEFINITIONS;
|
||||
}
|
||||
|
||||
async listAccounts(): Promise<ProviderAccount[]> {
|
||||
await ensureProviderStoreMigrated();
|
||||
return listProviderAccounts();
|
||||
}
|
||||
|
||||
async getAccount(accountId: string): Promise<ProviderAccount | null> {
|
||||
await ensureProviderStoreMigrated();
|
||||
return getProviderAccount(accountId);
|
||||
}
|
||||
|
||||
async getDefaultAccountId(): Promise<string | undefined> {
|
||||
await ensureProviderStoreMigrated();
|
||||
return (await getDefaultProvider()) ?? getDefaultProviderAccountId();
|
||||
}
|
||||
|
||||
async createAccount(account: ProviderAccount, apiKey?: string): Promise<ProviderAccount> {
|
||||
await ensureProviderStoreMigrated();
|
||||
await saveProvider(providerAccountToConfig(account));
|
||||
await saveProviderAccount(account);
|
||||
if (apiKey !== undefined && apiKey.trim()) {
|
||||
await storeApiKey(account.id, apiKey.trim());
|
||||
}
|
||||
return (await getProviderAccount(account.id)) ?? account;
|
||||
}
|
||||
|
||||
async updateAccount(
|
||||
accountId: string,
|
||||
patch: Partial<ProviderAccount>,
|
||||
apiKey?: string,
|
||||
): Promise<ProviderAccount> {
|
||||
await ensureProviderStoreMigrated();
|
||||
const existing = await getProviderAccount(accountId);
|
||||
if (!existing) {
|
||||
throw new Error('Provider account not found');
|
||||
}
|
||||
|
||||
const nextAccount: ProviderAccount = {
|
||||
...existing,
|
||||
...patch,
|
||||
id: accountId,
|
||||
updatedAt: patch.updatedAt ?? new Date().toISOString(),
|
||||
};
|
||||
|
||||
await saveProvider(providerAccountToConfig(nextAccount));
|
||||
await saveProviderAccount(nextAccount);
|
||||
if (apiKey !== undefined) {
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (trimmedKey) {
|
||||
await storeApiKey(accountId, trimmedKey);
|
||||
} else {
|
||||
await deleteApiKey(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
return (await getProviderAccount(accountId)) ?? nextAccount;
|
||||
}
|
||||
|
||||
async deleteAccount(accountId: string): Promise<boolean> {
|
||||
await ensureProviderStoreMigrated();
|
||||
return deleteProvider(accountId);
|
||||
}
|
||||
|
||||
async syncLegacyProvider(config: ProviderConfig, options?: { isDefault?: boolean }): Promise<ProviderAccount> {
|
||||
await ensureProviderStoreMigrated();
|
||||
const account = providerConfigToAccount(config, options);
|
||||
await saveProviderAccount(account);
|
||||
return account;
|
||||
}
|
||||
|
||||
async listLegacyProviders(): Promise<ProviderConfig[]> {
|
||||
return getAllProviders();
|
||||
}
|
||||
|
||||
async listLegacyProvidersWithKeyInfo(): Promise<ProviderWithKeyInfo[]> {
|
||||
return getAllProvidersWithKeyInfo();
|
||||
}
|
||||
|
||||
async getLegacyProvider(providerId: string): Promise<ProviderConfig | null> {
|
||||
return getProvider(providerId);
|
||||
}
|
||||
|
||||
async saveLegacyProvider(config: ProviderConfig): Promise<void> {
|
||||
await saveProvider(config);
|
||||
}
|
||||
|
||||
async deleteLegacyProvider(providerId: string): Promise<boolean> {
|
||||
return deleteProvider(providerId);
|
||||
}
|
||||
|
||||
async setDefaultLegacyProvider(providerId: string): Promise<void> {
|
||||
await setDefaultProvider(providerId);
|
||||
}
|
||||
|
||||
async getDefaultLegacyProvider(): Promise<string | undefined> {
|
||||
return getDefaultProvider();
|
||||
}
|
||||
|
||||
async setLegacyProviderApiKey(providerId: string, apiKey: string): Promise<boolean> {
|
||||
return storeApiKey(providerId, apiKey);
|
||||
}
|
||||
|
||||
async getLegacyProviderApiKey(providerId: string): Promise<string | null> {
|
||||
return getApiKey(providerId);
|
||||
}
|
||||
|
||||
async deleteLegacyProviderApiKey(providerId: string): Promise<boolean> {
|
||||
return deleteApiKey(providerId);
|
||||
}
|
||||
|
||||
async hasLegacyProviderApiKey(providerId: string): Promise<boolean> {
|
||||
return hasApiKey(providerId);
|
||||
}
|
||||
|
||||
async setDefaultAccount(accountId: string): Promise<void> {
|
||||
await ensureProviderStoreMigrated();
|
||||
await setDefaultProviderAccount(accountId);
|
||||
await setDefaultProvider(accountId);
|
||||
}
|
||||
|
||||
getVendorDefinition(vendorId: string): ProviderDefinition | undefined {
|
||||
return getProviderDefinition(vendorId);
|
||||
}
|
||||
}
|
||||
|
||||
const providerService = new ProviderService();
|
||||
|
||||
export function getProviderService(): ProviderService {
|
||||
return providerService;
|
||||
}
|
||||
103
electron/services/providers/provider-store.ts
Normal file
103
electron/services/providers/provider-store.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { ProviderAccount, ProviderConfig, ProviderType } from '../../shared/providers/types';
|
||||
import { getProviderDefinition } from '../../shared/providers/registry';
|
||||
import { getClawXProviderStore } from './store-instance';
|
||||
|
||||
const PROVIDER_STORE_SCHEMA_VERSION = 1;
|
||||
|
||||
function inferAuthMode(type: ProviderType): ProviderAccount['authMode'] {
|
||||
if (type === 'ollama') {
|
||||
return 'local';
|
||||
}
|
||||
|
||||
const definition = getProviderDefinition(type);
|
||||
if (definition?.defaultAuthMode) {
|
||||
return definition.defaultAuthMode;
|
||||
}
|
||||
|
||||
return 'api_key';
|
||||
}
|
||||
|
||||
export function providerConfigToAccount(
|
||||
config: ProviderConfig,
|
||||
options?: { isDefault?: boolean },
|
||||
): ProviderAccount {
|
||||
return {
|
||||
id: config.id,
|
||||
vendorId: config.type,
|
||||
label: config.name,
|
||||
authMode: inferAuthMode(config.type),
|
||||
baseUrl: config.baseUrl,
|
||||
apiProtocol: config.type === 'custom' || config.type === 'ollama'
|
||||
? 'openai-completions'
|
||||
: getProviderDefinition(config.type)?.providerConfig?.api,
|
||||
model: config.model,
|
||||
fallbackModels: config.fallbackModels,
|
||||
fallbackAccountIds: config.fallbackProviderIds,
|
||||
enabled: config.enabled,
|
||||
isDefault: options?.isDefault ?? false,
|
||||
createdAt: config.createdAt,
|
||||
updatedAt: config.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function providerAccountToConfig(account: ProviderAccount): ProviderConfig {
|
||||
return {
|
||||
id: account.id,
|
||||
name: account.label,
|
||||
type: account.vendorId,
|
||||
baseUrl: account.baseUrl,
|
||||
model: account.model,
|
||||
fallbackModels: account.fallbackModels,
|
||||
fallbackProviderIds: account.fallbackAccountIds,
|
||||
enabled: account.enabled,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listProviderAccounts(): Promise<ProviderAccount[]> {
|
||||
const store = await getClawXProviderStore();
|
||||
const accounts = store.get('providerAccounts') as Record<string, ProviderAccount> | undefined;
|
||||
return Object.values(accounts ?? {});
|
||||
}
|
||||
|
||||
export async function getProviderAccount(accountId: string): Promise<ProviderAccount | null> {
|
||||
const store = await getClawXProviderStore();
|
||||
const accounts = store.get('providerAccounts') as Record<string, ProviderAccount> | undefined;
|
||||
return accounts?.[accountId] ?? null;
|
||||
}
|
||||
|
||||
export async function saveProviderAccount(account: ProviderAccount): Promise<void> {
|
||||
const store = await getClawXProviderStore();
|
||||
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
|
||||
accounts[account.id] = account;
|
||||
store.set('providerAccounts', accounts);
|
||||
store.set('schemaVersion', PROVIDER_STORE_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
export async function deleteProviderAccount(accountId: string): Promise<void> {
|
||||
const store = await getClawXProviderStore();
|
||||
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
|
||||
delete accounts[accountId];
|
||||
store.set('providerAccounts', accounts);
|
||||
|
||||
if (store.get('defaultProviderAccountId') === accountId) {
|
||||
store.delete('defaultProviderAccountId');
|
||||
}
|
||||
}
|
||||
|
||||
export async function setDefaultProviderAccount(accountId: string): Promise<void> {
|
||||
const store = await getClawXProviderStore();
|
||||
store.set('defaultProviderAccountId', accountId);
|
||||
|
||||
const accounts = (store.get('providerAccounts') ?? {}) as Record<string, ProviderAccount>;
|
||||
for (const account of Object.values(accounts)) {
|
||||
account.isDefault = account.id === accountId;
|
||||
}
|
||||
store.set('providerAccounts', accounts);
|
||||
}
|
||||
|
||||
export async function getDefaultProviderAccountId(): Promise<string | undefined> {
|
||||
const store = await getClawXProviderStore();
|
||||
return store.get('defaultProviderAccountId') as string | undefined;
|
||||
}
|
||||
238
electron/services/providers/provider-validation.ts
Normal file
238
electron/services/providers/provider-validation.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
||||
|
||||
type ValidationProfile =
|
||||
| 'openai-compatible'
|
||||
| 'google-query-key'
|
||||
| 'anthropic-header'
|
||||
| 'openrouter'
|
||||
| 'none';
|
||||
|
||||
function logValidationStatus(provider: string, status: number): void {
|
||||
console.log(`[clawx-validate] ${provider} HTTP ${status}`);
|
||||
}
|
||||
|
||||
function maskSecret(secret: string): string {
|
||||
if (!secret) return '';
|
||||
if (secret.length <= 8) return `${secret.slice(0, 2)}***`;
|
||||
return `${secret.slice(0, 4)}***${secret.slice(-4)}`;
|
||||
}
|
||||
|
||||
function sanitizeValidationUrl(rawUrl: string): string {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
const key = url.searchParams.get('key');
|
||||
if (key) url.searchParams.set('key', maskSecret(key));
|
||||
return url.toString();
|
||||
} catch {
|
||||
return rawUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
const next = { ...headers };
|
||||
if (next.Authorization?.startsWith('Bearer ')) {
|
||||
const token = next.Authorization.slice('Bearer '.length);
|
||||
next.Authorization = `Bearer ${maskSecret(token)}`;
|
||||
}
|
||||
if (next['x-api-key']) {
|
||||
next['x-api-key'] = maskSecret(next['x-api-key']);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function buildOpenAiModelsUrl(baseUrl: string): string {
|
||||
return `${normalizeBaseUrl(baseUrl)}/models?limit=1`;
|
||||
}
|
||||
|
||||
function logValidationRequest(
|
||||
provider: string,
|
||||
method: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): void {
|
||||
console.log(
|
||||
`[clawx-validate] ${provider} request ${method} ${sanitizeValidationUrl(url)} headers=${JSON.stringify(sanitizeHeaders(headers))}`,
|
||||
);
|
||||
}
|
||||
|
||||
function getValidationProfile(providerType: string): ValidationProfile {
|
||||
switch (providerType) {
|
||||
case 'anthropic':
|
||||
return 'anthropic-header';
|
||||
case 'google':
|
||||
return 'google-query-key';
|
||||
case 'openrouter':
|
||||
return 'openrouter';
|
||||
case 'ollama':
|
||||
return 'none';
|
||||
default:
|
||||
return 'openai-compatible';
|
||||
}
|
||||
}
|
||||
|
||||
async function performProviderValidationRequest(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
try {
|
||||
logValidationRequest(providerLabel, 'GET', url, headers);
|
||||
const response = await proxyAwareFetch(url, { headers });
|
||||
logValidationStatus(providerLabel, response.status);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return classifyAuthResponse(response.status, data);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function classifyAuthResponse(
|
||||
status: number,
|
||||
data: unknown,
|
||||
): { valid: boolean; error?: string } {
|
||||
if (status >= 200 && status < 300) return { valid: true };
|
||||
if (status === 429) return { valid: true };
|
||||
if (status === 401 || status === 403) return { valid: false, error: 'Invalid API key' };
|
||||
|
||||
const obj = data as { error?: { message?: string }; message?: string } | null;
|
||||
const msg = obj?.error?.message || obj?.message || `API error: ${status}`;
|
||||
return { valid: false, error: msg };
|
||||
}
|
||||
|
||||
async function validateOpenAiCompatibleKey(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
const trimmedBaseUrl = baseUrl?.trim();
|
||||
if (!trimmedBaseUrl) {
|
||||
return { valid: false, error: `Base URL is required for provider "${providerType}" validation` };
|
||||
}
|
||||
|
||||
const headers = { Authorization: `Bearer ${apiKey}` };
|
||||
const modelsUrl = buildOpenAiModelsUrl(trimmedBaseUrl);
|
||||
const modelsResult = await performProviderValidationRequest(providerType, modelsUrl, headers);
|
||||
|
||||
if (modelsResult.error?.includes('API error: 404')) {
|
||||
console.log(
|
||||
`[clawx-validate] ${providerType} /models returned 404, falling back to /chat/completions probe`,
|
||||
);
|
||||
const base = normalizeBaseUrl(trimmedBaseUrl);
|
||||
const chatUrl = `${base}/chat/completions`;
|
||||
return await performChatCompletionsProbe(providerType, chatUrl, headers);
|
||||
}
|
||||
|
||||
return modelsResult;
|
||||
}
|
||||
|
||||
async function performChatCompletionsProbe(
|
||||
providerLabel: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
try {
|
||||
logValidationRequest(providerLabel, 'POST', url, headers);
|
||||
const response = await proxyAwareFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'validation-probe',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
});
|
||||
logValidationStatus(providerLabel, response.status);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { valid: false, error: 'Invalid API key' };
|
||||
}
|
||||
if (
|
||||
(response.status >= 200 && response.status < 300) ||
|
||||
response.status === 400 ||
|
||||
response.status === 429
|
||||
) {
|
||||
return { valid: true };
|
||||
}
|
||||
return classifyAuthResponse(response.status, data);
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Connection error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function validateGoogleQueryKey(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
const base = normalizeBaseUrl(baseUrl || 'https://generativelanguage.googleapis.com/v1beta');
|
||||
const url = `${base}/models?pageSize=1&key=${encodeURIComponent(apiKey)}`;
|
||||
return await performProviderValidationRequest(providerType, url, {});
|
||||
}
|
||||
|
||||
async function validateAnthropicHeaderKey(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
const base = normalizeBaseUrl(baseUrl || 'https://api.anthropic.com/v1');
|
||||
const url = `${base}/models?limit=1`;
|
||||
const headers = {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
};
|
||||
return await performProviderValidationRequest(providerType, url, headers);
|
||||
}
|
||||
|
||||
async function validateOpenRouterKey(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
const url = 'https://openrouter.ai/api/v1/auth/key';
|
||||
const headers = { Authorization: `Bearer ${apiKey}` };
|
||||
return await performProviderValidationRequest(providerType, url, headers);
|
||||
}
|
||||
|
||||
export async function validateApiKeyWithProvider(
|
||||
providerType: string,
|
||||
apiKey: string,
|
||||
options?: { baseUrl?: string },
|
||||
): Promise<{ valid: boolean; error?: string }> {
|
||||
const profile = getValidationProfile(providerType);
|
||||
if (profile === 'none') {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const trimmedKey = apiKey.trim();
|
||||
if (!trimmedKey) {
|
||||
return { valid: false, error: 'API key is required' };
|
||||
}
|
||||
|
||||
try {
|
||||
switch (profile) {
|
||||
case 'openai-compatible':
|
||||
return await validateOpenAiCompatibleKey(providerType, trimmedKey, options?.baseUrl);
|
||||
case 'google-query-key':
|
||||
return await validateGoogleQueryKey(providerType, trimmedKey, options?.baseUrl);
|
||||
case 'anthropic-header':
|
||||
return await validateAnthropicHeaderKey(providerType, trimmedKey, options?.baseUrl);
|
||||
case 'openrouter':
|
||||
return await validateOpenRouterKey(providerType, trimmedKey);
|
||||
default:
|
||||
return { valid: false, error: `Unsupported validation profile for provider: ${providerType}` };
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { valid: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
23
electron/services/providers/store-instance.ts
Normal file
23
electron/services/providers/store-instance.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Lazy-load electron-store (ESM module) from the main process only.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let providerStore: any = null;
|
||||
|
||||
export async function getClawXProviderStore() {
|
||||
if (!providerStore) {
|
||||
const Store = (await import('electron-store')).default;
|
||||
providerStore = new Store({
|
||||
name: 'clawx-providers',
|
||||
defaults: {
|
||||
schemaVersion: 0,
|
||||
providers: {} as Record<string, unknown>,
|
||||
providerAccounts: {} as Record<string, unknown>,
|
||||
apiKeys: {} as Record<string, string>,
|
||||
providerSecrets: {} as Record<string, unknown>,
|
||||
defaultProvider: null as string | null,
|
||||
defaultProviderAccountId: null as string | null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return providerStore;
|
||||
}
|
||||
82
electron/services/secrets/secret-store.ts
Normal file
82
electron/services/secrets/secret-store.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { ProviderSecret } from '../../shared/providers/types';
|
||||
import { getClawXProviderStore } from '../providers/store-instance';
|
||||
|
||||
export interface SecretStore {
|
||||
get(accountId: string): Promise<ProviderSecret | null>;
|
||||
set(secret: ProviderSecret): Promise<void>;
|
||||
delete(accountId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class ElectronStoreSecretStore implements SecretStore {
|
||||
async get(accountId: string): Promise<ProviderSecret | null> {
|
||||
const store = await getClawXProviderStore();
|
||||
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
|
||||
const secret = secrets[accountId];
|
||||
if (secret) {
|
||||
return secret;
|
||||
}
|
||||
|
||||
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
|
||||
const apiKey = apiKeys[accountId];
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'api_key',
|
||||
accountId,
|
||||
apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
async set(secret: ProviderSecret): Promise<void> {
|
||||
const store = await getClawXProviderStore();
|
||||
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
|
||||
secrets[secret.accountId] = secret;
|
||||
store.set('providerSecrets', secrets);
|
||||
|
||||
// Keep legacy apiKeys in sync until the rest of the app moves to account-based secrets.
|
||||
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
|
||||
if (secret.type === 'api_key') {
|
||||
apiKeys[secret.accountId] = secret.apiKey;
|
||||
} else if (secret.type === 'local') {
|
||||
if (secret.apiKey) {
|
||||
apiKeys[secret.accountId] = secret.apiKey;
|
||||
} else {
|
||||
delete apiKeys[secret.accountId];
|
||||
}
|
||||
} else {
|
||||
delete apiKeys[secret.accountId];
|
||||
}
|
||||
store.set('apiKeys', apiKeys);
|
||||
}
|
||||
|
||||
async delete(accountId: string): Promise<void> {
|
||||
const store = await getClawXProviderStore();
|
||||
const secrets = (store.get('providerSecrets') ?? {}) as Record<string, ProviderSecret>;
|
||||
delete secrets[accountId];
|
||||
store.set('providerSecrets', secrets);
|
||||
|
||||
const apiKeys = (store.get('apiKeys') ?? {}) as Record<string, string>;
|
||||
delete apiKeys[accountId];
|
||||
store.set('apiKeys', apiKeys);
|
||||
}
|
||||
}
|
||||
|
||||
const secretStore = new ElectronStoreSecretStore();
|
||||
|
||||
export function getSecretStore(): SecretStore {
|
||||
return secretStore;
|
||||
}
|
||||
|
||||
export async function getProviderSecret(accountId: string): Promise<ProviderSecret | null> {
|
||||
return getSecretStore().get(accountId);
|
||||
}
|
||||
|
||||
export async function setProviderSecret(secret: ProviderSecret): Promise<void> {
|
||||
await getSecretStore().set(secret);
|
||||
}
|
||||
|
||||
export async function deleteProviderSecret(accountId: string): Promise<void> {
|
||||
await getSecretStore().delete(accountId);
|
||||
}
|
||||
293
electron/shared/providers/registry.ts
Normal file
293
electron/shared/providers/registry.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import type {
|
||||
ProviderBackendConfig,
|
||||
ProviderDefinition,
|
||||
ProviderType,
|
||||
ProviderTypeInfo,
|
||||
} from './types';
|
||||
|
||||
export const PROVIDER_DEFINITIONS: ProviderDefinition[] = [
|
||||
{
|
||||
id: 'anthropic',
|
||||
name: 'Anthropic',
|
||||
icon: '🤖',
|
||||
placeholder: 'sk-ant-api03-...',
|
||||
model: 'Claude',
|
||||
requiresApiKey: true,
|
||||
category: 'official',
|
||||
envVar: 'ANTHROPIC_API_KEY',
|
||||
defaultModelId: 'claude-opus-4-6',
|
||||
supportedAuthModes: ['api_key'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
},
|
||||
{
|
||||
id: 'openai',
|
||||
name: 'OpenAI',
|
||||
icon: '💚',
|
||||
placeholder: 'sk-proj-...',
|
||||
model: 'GPT',
|
||||
requiresApiKey: true,
|
||||
category: 'official',
|
||||
envVar: 'OPENAI_API_KEY',
|
||||
defaultModelId: 'gpt-5.2',
|
||||
supportedAuthModes: ['api_key'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
providerConfig: {
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
api: 'openai-responses',
|
||||
apiKeyEnv: 'OPENAI_API_KEY',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
icon: '🔷',
|
||||
placeholder: 'AIza...',
|
||||
model: 'Gemini',
|
||||
requiresApiKey: true,
|
||||
category: 'official',
|
||||
envVar: 'GEMINI_API_KEY',
|
||||
defaultModelId: 'gemini-3.1-pro-preview',
|
||||
isOAuth: true,
|
||||
supportsApiKey: true,
|
||||
supportedAuthModes: ['api_key', 'oauth_browser'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
},
|
||||
{
|
||||
id: 'openrouter',
|
||||
name: 'OpenRouter',
|
||||
icon: '🌐',
|
||||
placeholder: 'sk-or-v1-...',
|
||||
model: 'Multi-Model',
|
||||
requiresApiKey: true,
|
||||
showModelId: true,
|
||||
modelIdPlaceholder: 'anthropic/claude-opus-4.6',
|
||||
defaultModelId: 'anthropic/claude-opus-4.6',
|
||||
category: 'compatible',
|
||||
envVar: 'OPENROUTER_API_KEY',
|
||||
supportedAuthModes: ['api_key'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
providerConfig: {
|
||||
baseUrl: 'https://openrouter.ai/api/v1',
|
||||
api: 'openai-completions',
|
||||
apiKeyEnv: 'OPENROUTER_API_KEY',
|
||||
headers: {
|
||||
'HTTP-Referer': 'https://claw-x.com',
|
||||
'X-Title': 'ClawX',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ark',
|
||||
name: 'ByteDance Ark',
|
||||
icon: 'A',
|
||||
placeholder: 'your-ark-api-key',
|
||||
model: 'Doubao',
|
||||
requiresApiKey: true,
|
||||
defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
showBaseUrl: true,
|
||||
showModelId: true,
|
||||
modelIdPlaceholder: 'ep-20260228000000-xxxxx',
|
||||
category: 'official',
|
||||
envVar: 'ARK_API_KEY',
|
||||
supportedAuthModes: ['api_key'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
providerConfig: {
|
||||
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
|
||||
api: 'openai-completions',
|
||||
apiKeyEnv: 'ARK_API_KEY',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'moonshot',
|
||||
name: 'Moonshot (CN)',
|
||||
icon: '🌙',
|
||||
placeholder: 'sk-...',
|
||||
model: 'Kimi',
|
||||
requiresApiKey: true,
|
||||
defaultBaseUrl: 'https://api.moonshot.cn/v1',
|
||||
defaultModelId: 'kimi-k2.5',
|
||||
category: 'official',
|
||||
envVar: 'MOONSHOT_API_KEY',
|
||||
supportedAuthModes: ['api_key'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'siliconflow',
|
||||
name: 'SiliconFlow (CN)',
|
||||
icon: '🌊',
|
||||
placeholder: 'sk-...',
|
||||
model: 'Multi-Model',
|
||||
requiresApiKey: true,
|
||||
defaultBaseUrl: 'https://api.siliconflow.cn/v1',
|
||||
showModelId: true,
|
||||
showModelIdInDevModeOnly: true,
|
||||
modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3',
|
||||
defaultModelId: 'deepseek-ai/DeepSeek-V3',
|
||||
category: 'compatible',
|
||||
envVar: 'SILICONFLOW_API_KEY',
|
||||
supportedAuthModes: ['api_key'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
providerConfig: {
|
||||
baseUrl: 'https://api.siliconflow.cn/v1',
|
||||
api: 'openai-completions',
|
||||
apiKeyEnv: 'SILICONFLOW_API_KEY',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'minimax-portal',
|
||||
name: 'MiniMax (Global)',
|
||||
icon: '☁️',
|
||||
placeholder: 'sk-...',
|
||||
model: 'MiniMax',
|
||||
requiresApiKey: false,
|
||||
isOAuth: true,
|
||||
supportsApiKey: true,
|
||||
defaultModelId: 'MiniMax-M2.5',
|
||||
apiKeyUrl: 'https://intl.minimaxi.com/',
|
||||
category: 'official',
|
||||
envVar: 'MINIMAX_API_KEY',
|
||||
supportedAuthModes: ['oauth_device', 'api_key'],
|
||||
defaultAuthMode: 'oauth_device',
|
||||
supportsMultipleAccounts: true,
|
||||
providerConfig: {
|
||||
baseUrl: 'https://api.minimax.io/anthropic',
|
||||
api: 'anthropic-messages',
|
||||
apiKeyEnv: 'MINIMAX_API_KEY',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'minimax-portal-cn',
|
||||
name: 'MiniMax (CN)',
|
||||
icon: '☁️',
|
||||
placeholder: 'sk-...',
|
||||
model: 'MiniMax',
|
||||
requiresApiKey: false,
|
||||
isOAuth: true,
|
||||
supportsApiKey: true,
|
||||
defaultModelId: 'MiniMax-M2.5',
|
||||
apiKeyUrl: 'https://platform.minimaxi.com/',
|
||||
category: 'official',
|
||||
envVar: 'MINIMAX_CN_API_KEY',
|
||||
supportedAuthModes: ['oauth_device', 'api_key'],
|
||||
defaultAuthMode: 'oauth_device',
|
||||
supportsMultipleAccounts: true,
|
||||
providerConfig: {
|
||||
baseUrl: 'https://api.minimaxi.com/anthropic',
|
||||
api: 'anthropic-messages',
|
||||
apiKeyEnv: 'MINIMAX_CN_API_KEY',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'qwen-portal',
|
||||
name: 'Qwen',
|
||||
icon: '☁️',
|
||||
placeholder: 'sk-...',
|
||||
model: 'Qwen',
|
||||
requiresApiKey: false,
|
||||
isOAuth: true,
|
||||
defaultModelId: 'coder-model',
|
||||
category: 'official',
|
||||
envVar: 'QWEN_API_KEY',
|
||||
supportedAuthModes: ['oauth_device'],
|
||||
defaultAuthMode: 'oauth_device',
|
||||
supportsMultipleAccounts: true,
|
||||
providerConfig: {
|
||||
baseUrl: 'https://portal.qwen.ai/v1',
|
||||
api: 'openai-completions',
|
||||
apiKeyEnv: 'QWEN_API_KEY',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ollama',
|
||||
name: 'Ollama',
|
||||
icon: '🦙',
|
||||
placeholder: 'Not required',
|
||||
requiresApiKey: false,
|
||||
defaultBaseUrl: 'http://localhost:11434/v1',
|
||||
showBaseUrl: true,
|
||||
showModelId: true,
|
||||
modelIdPlaceholder: 'qwen3:latest',
|
||||
category: 'local',
|
||||
supportedAuthModes: ['local'],
|
||||
defaultAuthMode: 'local',
|
||||
supportsMultipleAccounts: true,
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
name: 'Custom',
|
||||
icon: '⚙️',
|
||||
placeholder: 'API key...',
|
||||
requiresApiKey: true,
|
||||
showBaseUrl: true,
|
||||
showModelId: true,
|
||||
modelIdPlaceholder: 'your-provider/model-id',
|
||||
category: 'custom',
|
||||
envVar: 'CUSTOM_API_KEY',
|
||||
supportedAuthModes: ['api_key'],
|
||||
defaultAuthMode: 'api_key',
|
||||
supportsMultipleAccounts: true,
|
||||
},
|
||||
];
|
||||
|
||||
const PROVIDER_DEFINITION_MAP = new Map(
|
||||
PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
|
||||
export function getProviderDefinition(
|
||||
type: ProviderType | string,
|
||||
): ProviderDefinition | undefined {
|
||||
return PROVIDER_DEFINITION_MAP.get(type as ProviderType);
|
||||
}
|
||||
|
||||
export function getProviderTypeInfo(
|
||||
type: ProviderType,
|
||||
): ProviderTypeInfo | undefined {
|
||||
return getProviderDefinition(type);
|
||||
}
|
||||
|
||||
export function getProviderEnvVar(type: string): string | undefined {
|
||||
return getProviderDefinition(type)?.envVar;
|
||||
}
|
||||
|
||||
export function getProviderDefaultModel(type: string): string | undefined {
|
||||
return getProviderDefinition(type)?.defaultModelId;
|
||||
}
|
||||
|
||||
export function getProviderBackendConfig(
|
||||
type: string,
|
||||
): ProviderBackendConfig | undefined {
|
||||
return getProviderDefinition(type)?.providerConfig;
|
||||
}
|
||||
|
||||
export function getProviderUiInfoList(): ProviderTypeInfo[] {
|
||||
return PROVIDER_DEFINITIONS;
|
||||
}
|
||||
|
||||
export function getKeyableProviderTypes(): string[] {
|
||||
return PROVIDER_DEFINITIONS.filter((definition) => definition.envVar).map(
|
||||
(definition) => definition.id,
|
||||
);
|
||||
}
|
||||
169
electron/shared/providers/types.ts
Normal file
169
electron/shared/providers/types.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
export const PROVIDER_TYPES = [
|
||||
'anthropic',
|
||||
'openai',
|
||||
'google',
|
||||
'openrouter',
|
||||
'ark',
|
||||
'moonshot',
|
||||
'siliconflow',
|
||||
'minimax-portal',
|
||||
'minimax-portal-cn',
|
||||
'qwen-portal',
|
||||
'ollama',
|
||||
'custom',
|
||||
] as const;
|
||||
|
||||
export const BUILTIN_PROVIDER_TYPES = [
|
||||
'anthropic',
|
||||
'openai',
|
||||
'google',
|
||||
'openrouter',
|
||||
'ark',
|
||||
'moonshot',
|
||||
'siliconflow',
|
||||
'minimax-portal',
|
||||
'minimax-portal-cn',
|
||||
'qwen-portal',
|
||||
'ollama',
|
||||
] as const;
|
||||
|
||||
export type ProviderType = (typeof PROVIDER_TYPES)[number];
|
||||
export type BuiltinProviderType = (typeof BUILTIN_PROVIDER_TYPES)[number];
|
||||
|
||||
export const OLLAMA_PLACEHOLDER_API_KEY = 'ollama-local';
|
||||
|
||||
export type ProviderProtocol =
|
||||
| 'openai-completions'
|
||||
| 'openai-responses'
|
||||
| 'anthropic-messages';
|
||||
|
||||
export type ProviderAuthMode =
|
||||
| 'api_key'
|
||||
| 'oauth_device'
|
||||
| 'oauth_browser'
|
||||
| 'local';
|
||||
|
||||
export type ProviderVendorCategory =
|
||||
| 'official'
|
||||
| 'compatible'
|
||||
| 'local'
|
||||
| 'custom';
|
||||
|
||||
export interface ProviderConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ProviderType;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
fallbackModels?: string[];
|
||||
fallbackProviderIds?: string[];
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProviderWithKeyInfo extends ProviderConfig {
|
||||
hasKey: boolean;
|
||||
keyMasked: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderTypeInfo {
|
||||
id: ProviderType;
|
||||
name: string;
|
||||
icon: string;
|
||||
placeholder: string;
|
||||
model?: string;
|
||||
requiresApiKey: boolean;
|
||||
defaultBaseUrl?: string;
|
||||
showBaseUrl?: boolean;
|
||||
showModelId?: boolean;
|
||||
showModelIdInDevModeOnly?: boolean;
|
||||
modelIdPlaceholder?: string;
|
||||
defaultModelId?: string;
|
||||
isOAuth?: boolean;
|
||||
supportsApiKey?: boolean;
|
||||
apiKeyUrl?: string;
|
||||
}
|
||||
|
||||
export interface ProviderModelEntry extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProviderBackendConfig {
|
||||
baseUrl: string;
|
||||
api: ProviderProtocol;
|
||||
apiKeyEnv: string;
|
||||
models?: ProviderModelEntry[];
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ProviderDefinition extends ProviderTypeInfo {
|
||||
category: ProviderVendorCategory;
|
||||
envVar?: string;
|
||||
providerConfig?: ProviderBackendConfig;
|
||||
supportedAuthModes: ProviderAuthMode[];
|
||||
defaultAuthMode: ProviderAuthMode;
|
||||
supportsMultipleAccounts: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderAccount {
|
||||
id: string;
|
||||
vendorId: ProviderType;
|
||||
label: string;
|
||||
authMode: ProviderAuthMode;
|
||||
baseUrl?: string;
|
||||
apiProtocol?: ProviderProtocol;
|
||||
model?: string;
|
||||
fallbackModels?: string[];
|
||||
fallbackAccountIds?: string[];
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
metadata?: {
|
||||
region?: string;
|
||||
email?: string;
|
||||
resourceUrl?: string;
|
||||
customModels?: string[];
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type ProviderSecret =
|
||||
| {
|
||||
type: 'api_key';
|
||||
accountId: string;
|
||||
apiKey: string;
|
||||
}
|
||||
| {
|
||||
type: 'oauth';
|
||||
accountId: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
scopes?: string[];
|
||||
email?: string;
|
||||
subject?: string;
|
||||
}
|
||||
| {
|
||||
type: 'local';
|
||||
accountId: string;
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
export interface ModelSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
vendorId: string;
|
||||
accountId?: string;
|
||||
supportsVision?: boolean;
|
||||
supportsReasoning?: boolean;
|
||||
contextWindow?: number;
|
||||
pricing?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
};
|
||||
source: 'builtin' | 'remote' | 'gateway' | 'custom';
|
||||
}
|
||||
161
electron/utils/browser-oauth.ts
Normal file
161
electron/utils/browser-oauth.ts
Normal 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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
738
electron/utils/gemini-cli-oauth.ts
Normal file
738
electron/utils/gemini-cli-oauth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user