Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* Channel Configuration Utilities
|
||||
* Manages channel configuration in OpenClaw config files
|
||||
* Manages channel configuration in OpenClaw config files.
|
||||
*
|
||||
* All file I/O uses async fs/promises to avoid blocking the main thread.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, rmSync } from 'fs';
|
||||
import { access, mkdir, readFile, writeFile, readdir, stat, rm } from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { getOpenClawResolvedDir } from './paths';
|
||||
@@ -14,6 +17,14 @@ const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
|
||||
// Channels that are managed as plugins (config goes under plugins.entries, not channels)
|
||||
const PLUGIN_CHANNELS = ['whatsapp'];
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try { await access(p, constants.F_OK); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ChannelConfigData {
|
||||
enabled?: boolean;
|
||||
[key: string]: unknown;
|
||||
@@ -30,27 +41,23 @@ export interface OpenClawConfig {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure OpenClaw config directory exists
|
||||
*/
|
||||
function ensureConfigDir(): void {
|
||||
if (!existsSync(OPENCLAW_DIR)) {
|
||||
mkdirSync(OPENCLAW_DIR, { recursive: true });
|
||||
// ── Config I/O ───────────────────────────────────────────────────
|
||||
|
||||
async function ensureConfigDir(): Promise<void> {
|
||||
if (!(await fileExists(OPENCLAW_DIR))) {
|
||||
await mkdir(OPENCLAW_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read OpenClaw configuration
|
||||
*/
|
||||
export function readOpenClawConfig(): OpenClawConfig {
|
||||
ensureConfigDir();
|
||||
export async function readOpenClawConfig(): Promise<OpenClawConfig> {
|
||||
await ensureConfigDir();
|
||||
|
||||
if (!existsSync(CONFIG_FILE)) {
|
||||
if (!(await fileExists(CONFIG_FILE))) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(CONFIG_FILE, 'utf-8');
|
||||
const content = await readFile(CONFIG_FILE, 'utf-8');
|
||||
return JSON.parse(content) as OpenClawConfig;
|
||||
} catch (error) {
|
||||
logger.error('Failed to read OpenClaw config', error);
|
||||
@@ -59,14 +66,11 @@ export function readOpenClawConfig(): OpenClawConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write OpenClaw configuration
|
||||
*/
|
||||
export function writeOpenClawConfig(config: OpenClawConfig): void {
|
||||
ensureConfigDir();
|
||||
export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void> {
|
||||
await ensureConfigDir();
|
||||
|
||||
try {
|
||||
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
||||
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
||||
} catch (error) {
|
||||
logger.error('Failed to write OpenClaw config', error);
|
||||
console.error('Failed to write OpenClaw config:', error);
|
||||
@@ -74,16 +78,13 @@ export function writeOpenClawConfig(config: OpenClawConfig): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save channel configuration
|
||||
* @param channelType - The channel type (e.g., 'telegram', 'discord')
|
||||
* @param config - The channel configuration object
|
||||
*/
|
||||
export function saveChannelConfig(
|
||||
// ── Channel operations ───────────────────────────────────────────
|
||||
|
||||
export async function saveChannelConfig(
|
||||
channelType: string,
|
||||
config: ChannelConfigData
|
||||
): void {
|
||||
const currentConfig = readOpenClawConfig();
|
||||
): Promise<void> {
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
|
||||
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
|
||||
if (PLUGIN_CHANNELS.includes(channelType)) {
|
||||
@@ -97,7 +98,7 @@ export function saveChannelConfig(
|
||||
...currentConfig.plugins.entries[channelType],
|
||||
enabled: config.enabled ?? true,
|
||||
};
|
||||
writeOpenClawConfig(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
logger.info('Plugin channel config saved', {
|
||||
channelType,
|
||||
configFile: CONFIG_FILE,
|
||||
@@ -119,7 +120,6 @@ export function saveChannelConfig(
|
||||
const { guildId, channelId, ...restConfig } = config;
|
||||
transformedConfig = { ...restConfig };
|
||||
|
||||
// Add standard Discord config
|
||||
transformedConfig.groupPolicy = 'allowlist';
|
||||
transformedConfig.dm = { enabled: false };
|
||||
transformedConfig.retry = {
|
||||
@@ -129,21 +129,17 @@ export function saveChannelConfig(
|
||||
jitter: 0.1,
|
||||
};
|
||||
|
||||
// Build guilds structure
|
||||
if (guildId && typeof guildId === 'string' && guildId.trim()) {
|
||||
const guildConfig: Record<string, unknown> = {
|
||||
users: ['*'],
|
||||
requireMention: true,
|
||||
};
|
||||
|
||||
// Add channels config
|
||||
if (channelId && typeof channelId === 'string' && channelId.trim()) {
|
||||
// Specific channel
|
||||
guildConfig.channels = {
|
||||
[channelId.trim()]: { allow: true, requireMention: true }
|
||||
};
|
||||
} else {
|
||||
// All channels
|
||||
guildConfig.channels = {
|
||||
'*': { allow: true, requireMention: true }
|
||||
};
|
||||
@@ -166,8 +162,7 @@ export function saveChannelConfig(
|
||||
.filter(u => u.length > 0);
|
||||
|
||||
if (users.length > 0) {
|
||||
transformedConfig.allowFrom = users; // Use 'allowFrom' (correct key)
|
||||
// transformedConfig.groupPolicy = 'allowlist'; // Default is allowlist
|
||||
transformedConfig.allowFrom = users;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,17 +171,16 @@ export function saveChannelConfig(
|
||||
if (channelType === 'feishu') {
|
||||
const existingConfig = currentConfig.channels[channelType] || {};
|
||||
transformedConfig.dmPolicy = transformedConfig.dmPolicy ?? existingConfig.dmPolicy ?? 'open';
|
||||
|
||||
|
||||
let allowFrom = transformedConfig.allowFrom ?? existingConfig.allowFrom ?? ['*'];
|
||||
if (!Array.isArray(allowFrom)) {
|
||||
allowFrom = [allowFrom];
|
||||
}
|
||||
|
||||
// If dmPolicy is open, OpenClaw schema requires '*' in allowFrom
|
||||
|
||||
if (transformedConfig.dmPolicy === 'open' && !allowFrom.includes('*')) {
|
||||
allowFrom = [...allowFrom, '*'];
|
||||
}
|
||||
|
||||
|
||||
transformedConfig.allowFrom = allowFrom;
|
||||
}
|
||||
|
||||
@@ -197,7 +191,7 @@ export function saveChannelConfig(
|
||||
enabled: transformedConfig.enabled ?? true,
|
||||
};
|
||||
|
||||
writeOpenClawConfig(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
logger.info('Channel config saved', {
|
||||
channelType,
|
||||
configFile: CONFIG_FILE,
|
||||
@@ -208,42 +202,26 @@ export function saveChannelConfig(
|
||||
console.log(`Saved channel config for ${channelType}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel configuration
|
||||
* @param channelType - The channel type
|
||||
*/
|
||||
export function getChannelConfig(channelType: string): ChannelConfigData | undefined {
|
||||
const config = readOpenClawConfig();
|
||||
export async function getChannelConfig(channelType: string): Promise<ChannelConfigData | undefined> {
|
||||
const config = await readOpenClawConfig();
|
||||
return config.channels?.[channelType];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel configuration as form-friendly values.
|
||||
* Reverses the transformation done in saveChannelConfig so the
|
||||
* values can be fed back into the UI form fields.
|
||||
*
|
||||
* @param channelType - The channel type
|
||||
* @returns A flat Record<string, string> matching the form field keys, or undefined
|
||||
*/
|
||||
export function getChannelFormValues(channelType: string): Record<string, string> | undefined {
|
||||
const saved = getChannelConfig(channelType);
|
||||
export async function getChannelFormValues(channelType: string): Promise<Record<string, string> | undefined> {
|
||||
const saved = await getChannelConfig(channelType);
|
||||
if (!saved) return undefined;
|
||||
|
||||
const values: Record<string, string> = {};
|
||||
|
||||
if (channelType === 'discord') {
|
||||
// token is stored at top level
|
||||
if (saved.token && typeof saved.token === 'string') {
|
||||
values.token = saved.token;
|
||||
}
|
||||
|
||||
// Extract guildId and channelId from the nested guilds structure
|
||||
const guilds = saved.guilds as Record<string, Record<string, unknown>> | undefined;
|
||||
if (guilds) {
|
||||
const guildIds = Object.keys(guilds);
|
||||
if (guildIds.length > 0) {
|
||||
values.guildId = guildIds[0];
|
||||
|
||||
const guildConfig = guilds[guildIds[0]];
|
||||
const channels = guildConfig?.channels as Record<string, unknown> | undefined;
|
||||
if (channels) {
|
||||
@@ -255,19 +233,15 @@ export function getChannelFormValues(channelType: string): Record<string, string
|
||||
}
|
||||
}
|
||||
} else if (channelType === 'telegram') {
|
||||
// Special handling for Telegram: convert allowFrom array to allowedUsers string
|
||||
if (Array.isArray(saved.allowFrom)) {
|
||||
values.allowedUsers = saved.allowFrom.join(', ');
|
||||
}
|
||||
|
||||
// Also extract other string values
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (typeof value === 'string' && key !== 'enabled') {
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For other channel types, extract all string values directly
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (typeof value === 'string' && key !== 'enabled') {
|
||||
values[key] = value;
|
||||
@@ -278,31 +252,23 @@ export function getChannelFormValues(channelType: string): Record<string, string
|
||||
return Object.keys(values).length > 0 ? values : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete channel configuration
|
||||
* @param channelType - The channel type
|
||||
*/
|
||||
export function deleteChannelConfig(channelType: string): void {
|
||||
const currentConfig = readOpenClawConfig();
|
||||
export async function deleteChannelConfig(channelType: string): Promise<void> {
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
|
||||
if (currentConfig.channels?.[channelType]) {
|
||||
delete currentConfig.channels[channelType];
|
||||
writeOpenClawConfig(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
console.log(`Deleted channel config for ${channelType}`);
|
||||
} else if (PLUGIN_CHANNELS.includes(channelType)) {
|
||||
// Handle plugin channels (like whatsapp)
|
||||
if (currentConfig.plugins?.entries?.[channelType]) {
|
||||
delete currentConfig.plugins.entries[channelType];
|
||||
|
||||
// Cleanup empty objects
|
||||
if (Object.keys(currentConfig.plugins.entries).length === 0) {
|
||||
delete currentConfig.plugins.entries;
|
||||
}
|
||||
if (currentConfig.plugins && Object.keys(currentConfig.plugins).length === 0) {
|
||||
delete currentConfig.plugins;
|
||||
}
|
||||
|
||||
writeOpenClawConfig(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
console.log(`Deleted plugin channel config for ${channelType}`);
|
||||
}
|
||||
}
|
||||
@@ -310,10 +276,9 @@ export function deleteChannelConfig(channelType: string): void {
|
||||
// Special handling for WhatsApp credentials
|
||||
if (channelType === 'whatsapp') {
|
||||
try {
|
||||
|
||||
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
|
||||
if (existsSync(whatsappDir)) {
|
||||
rmSync(whatsappDir, { recursive: true, force: true });
|
||||
if (await fileExists(whatsappDir)) {
|
||||
await rm(whatsappDir, { recursive: true, force: true });
|
||||
console.log('Deleted WhatsApp credentials directory');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -322,11 +287,8 @@ export function deleteChannelConfig(channelType: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all configured channels
|
||||
*/
|
||||
export function listConfiguredChannels(): string[] {
|
||||
const config = readOpenClawConfig();
|
||||
export async function listConfiguredChannels(): Promise<string[]> {
|
||||
const config = await readOpenClawConfig();
|
||||
const channels: string[] = [];
|
||||
|
||||
if (config.channels) {
|
||||
@@ -338,14 +300,17 @@ export function listConfiguredChannels(): string[] {
|
||||
// Check for WhatsApp credentials directory
|
||||
try {
|
||||
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
|
||||
if (existsSync(whatsappDir)) {
|
||||
const entries = readdirSync(whatsappDir);
|
||||
// Check if there's at least one directory (session)
|
||||
const hasSession = entries.some((entry: string) => {
|
||||
try {
|
||||
return statSync(join(whatsappDir, entry)).isDirectory();
|
||||
} catch { return false; }
|
||||
});
|
||||
if (await fileExists(whatsappDir)) {
|
||||
const entries = await readdir(whatsappDir);
|
||||
const hasSession = await (async () => {
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const s = await stat(join(whatsappDir, entry));
|
||||
if (s.isDirectory()) return true;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
if (hasSession && !channels.includes('whatsapp')) {
|
||||
channels.push('whatsapp');
|
||||
@@ -358,42 +323,28 @@ export function listConfiguredChannels(): string[] {
|
||||
return channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable a channel
|
||||
*/
|
||||
export function setChannelEnabled(channelType: string, enabled: boolean): void {
|
||||
const currentConfig = readOpenClawConfig();
|
||||
export async function setChannelEnabled(channelType: string, enabled: boolean): Promise<void> {
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
|
||||
// Plugin-based channels go under plugins.entries
|
||||
if (PLUGIN_CHANNELS.includes(channelType)) {
|
||||
if (!currentConfig.plugins) {
|
||||
currentConfig.plugins = {};
|
||||
}
|
||||
if (!currentConfig.plugins.entries) {
|
||||
currentConfig.plugins.entries = {};
|
||||
}
|
||||
if (!currentConfig.plugins.entries[channelType]) {
|
||||
currentConfig.plugins.entries[channelType] = {};
|
||||
}
|
||||
if (!currentConfig.plugins) currentConfig.plugins = {};
|
||||
if (!currentConfig.plugins.entries) currentConfig.plugins.entries = {};
|
||||
if (!currentConfig.plugins.entries[channelType]) currentConfig.plugins.entries[channelType] = {};
|
||||
currentConfig.plugins.entries[channelType].enabled = enabled;
|
||||
writeOpenClawConfig(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
console.log(`Set plugin channel ${channelType} enabled: ${enabled}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentConfig.channels) {
|
||||
currentConfig.channels = {};
|
||||
}
|
||||
|
||||
if (!currentConfig.channels[channelType]) {
|
||||
currentConfig.channels[channelType] = {};
|
||||
}
|
||||
|
||||
if (!currentConfig.channels) currentConfig.channels = {};
|
||||
if (!currentConfig.channels[channelType]) currentConfig.channels[channelType] = {};
|
||||
currentConfig.channels[channelType].enabled = enabled;
|
||||
writeOpenClawConfig(currentConfig);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
console.log(`Set channel ${channelType} enabled: ${enabled}`);
|
||||
}
|
||||
|
||||
// ── Validation ───────────────────────────────────────────────────
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
@@ -404,17 +355,9 @@ export interface CredentialValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
/** Extra info returned from the API (e.g. bot username, guild name) */
|
||||
details?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate channel credentials by calling the actual service APIs
|
||||
* This validates the raw config values BEFORE saving them.
|
||||
*
|
||||
* @param channelType - The channel type (e.g., 'discord', 'telegram')
|
||||
* @param config - The raw config values from the form
|
||||
*/
|
||||
export async function validateChannelCredentials(
|
||||
channelType: string,
|
||||
config: Record<string, string>
|
||||
@@ -425,14 +368,10 @@ export async function validateChannelCredentials(
|
||||
case 'telegram':
|
||||
return validateTelegramCredentials(config);
|
||||
default:
|
||||
// For channels without specific validation, just check required fields are present
|
||||
return { valid: true, errors: [], warnings: ['No online validation available for this channel type.'] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Discord bot token and optional guild/channel IDs
|
||||
*/
|
||||
async function validateDiscordCredentials(
|
||||
config: Record<string, string>
|
||||
): Promise<CredentialValidationResult> {
|
||||
@@ -443,12 +382,10 @@ async function validateDiscordCredentials(
|
||||
return { valid: false, errors: ['Bot token is required'], warnings: [] };
|
||||
}
|
||||
|
||||
// 1) Validate bot token by calling GET /users/@me
|
||||
try {
|
||||
const meResponse = await fetch('https://discord.com/api/v10/users/@me', {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
|
||||
if (!meResponse.ok) {
|
||||
if (meResponse.status === 401) {
|
||||
return { valid: false, errors: ['Invalid bot token. Please check and try again.'], warnings: [] };
|
||||
@@ -457,38 +394,25 @@ async function validateDiscordCredentials(
|
||||
const msg = (errorData as { message?: string }).message || `Discord API error: ${meResponse.status}`;
|
||||
return { valid: false, errors: [msg], warnings: [] };
|
||||
}
|
||||
|
||||
const meData = (await meResponse.json()) as { username?: string; id?: string; bot?: boolean };
|
||||
if (!meData.bot) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: ['The provided token belongs to a user account, not a bot. Please use a bot token.'],
|
||||
warnings: [],
|
||||
};
|
||||
return { valid: false, errors: ['The provided token belongs to a user account, not a bot. Please use a bot token.'], warnings: [] };
|
||||
}
|
||||
result.details!.botUsername = meData.username || 'Unknown';
|
||||
result.details!.botId = meData.id || '';
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [`Connection error when validating bot token: ${error instanceof Error ? error.message : String(error)}`],
|
||||
warnings: [],
|
||||
};
|
||||
return { valid: false, errors: [`Connection error when validating bot token: ${error instanceof Error ? error.message : String(error)}`], warnings: [] };
|
||||
}
|
||||
|
||||
// 2) Validate guild ID (optional)
|
||||
const guildId = config.guildId?.trim();
|
||||
if (guildId) {
|
||||
try {
|
||||
const guildResponse = await fetch(`https://discord.com/api/v10/guilds/${guildId}`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
|
||||
if (!guildResponse.ok) {
|
||||
if (guildResponse.status === 403 || guildResponse.status === 404) {
|
||||
result.errors.push(
|
||||
`Cannot access guild (server) with ID "${guildId}". Make sure the bot has been invited to this server.`
|
||||
);
|
||||
result.errors.push(`Cannot access guild (server) with ID "${guildId}". Make sure the bot has been invited to this server.`);
|
||||
result.valid = false;
|
||||
} else {
|
||||
result.errors.push(`Failed to verify guild ID: Discord API returned ${guildResponse.status}`);
|
||||
@@ -503,19 +427,15 @@ async function validateDiscordCredentials(
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Validate channel ID (optional)
|
||||
const channelId = config.channelId?.trim();
|
||||
if (channelId) {
|
||||
try {
|
||||
const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
|
||||
if (!channelResponse.ok) {
|
||||
if (channelResponse.status === 403 || channelResponse.status === 404) {
|
||||
result.errors.push(
|
||||
`Cannot access channel with ID "${channelId}". Make sure the bot has permission to view this channel.`
|
||||
);
|
||||
result.errors.push(`Cannot access channel with ID "${channelId}". Make sure the bot has permission to view this channel.`);
|
||||
result.valid = false;
|
||||
} else {
|
||||
result.errors.push(`Failed to verify channel ID: Discord API returned ${channelResponse.status}`);
|
||||
@@ -524,12 +444,8 @@ async function validateDiscordCredentials(
|
||||
} else {
|
||||
const channelData = (await channelResponse.json()) as { name?: string; guild_id?: string };
|
||||
result.details!.channelName = channelData.name || 'Unknown';
|
||||
|
||||
// Cross-check: if both guild and channel are provided, make sure channel belongs to the guild
|
||||
if (guildId && channelData.guild_id && channelData.guild_id !== guildId) {
|
||||
result.errors.push(
|
||||
`Channel "${channelData.name}" does not belong to the specified guild. It belongs to a different server.`
|
||||
);
|
||||
result.errors.push(`Channel "${channelData.name}" does not belong to the specified guild. It belongs to a different server.`);
|
||||
result.valid = false;
|
||||
}
|
||||
}
|
||||
@@ -541,80 +457,52 @@ async function validateDiscordCredentials(
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Telegram bot token
|
||||
*/
|
||||
async function validateTelegramCredentials(
|
||||
config: Record<string, string>
|
||||
): Promise<CredentialValidationResult> {
|
||||
const botToken = config.botToken?.trim();
|
||||
|
||||
const allowedUsers = config.allowedUsers?.trim();
|
||||
|
||||
if (!botToken) {
|
||||
return { valid: false, errors: ['Bot token is required'], warnings: [] };
|
||||
}
|
||||
|
||||
if (!allowedUsers) {
|
||||
return { valid: false, errors: ['At least one allowed user ID is required'], warnings: [] };
|
||||
}
|
||||
if (!botToken) return { valid: false, errors: ['Bot token is required'], warnings: [] };
|
||||
if (!allowedUsers) return { valid: false, errors: ['At least one allowed user ID is required'], warnings: [] };
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://api.telegram.org/bot${botToken}/getMe`);
|
||||
const data = (await response.json()) as { ok?: boolean; description?: string; result?: { username?: string } };
|
||||
|
||||
if (data.ok) {
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
details: { botUsername: data.result?.username || 'Unknown' },
|
||||
};
|
||||
return { valid: true, errors: [], warnings: [], details: { botUsername: data.result?.username || 'Unknown' } };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
errors: [data.description || 'Invalid bot token'],
|
||||
warnings: [],
|
||||
};
|
||||
return { valid: false, errors: [data.description || 'Invalid bot token'], warnings: [] };
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [`Connection error: ${error instanceof Error ? error.message : String(error)}`],
|
||||
warnings: [],
|
||||
};
|
||||
return { valid: false, errors: [`Connection error: ${error instanceof Error ? error.message : String(error)}`], warnings: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Validate channel configuration using OpenClaw doctor
|
||||
*/
|
||||
export async function validateChannelConfig(channelType: string): Promise<ValidationResult> {
|
||||
const { execSync } = await import('child_process');
|
||||
const { exec } = await import('child_process');
|
||||
|
||||
const result: ValidationResult = {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
const result: ValidationResult = { valid: true, errors: [], warnings: [] };
|
||||
|
||||
try {
|
||||
// Get OpenClaw path
|
||||
const openclawPath = getOpenClawResolvedDir();
|
||||
|
||||
// Run openclaw doctor command to validate config
|
||||
const output = execSync(
|
||||
`node openclaw.mjs doctor --json 2>&1`,
|
||||
{
|
||||
cwd: openclawPath,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
}
|
||||
);
|
||||
// Run openclaw doctor command to validate config (async to avoid
|
||||
// blocking the main thread).
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
exec(
|
||||
`node openclaw.mjs doctor --json 2>&1`,
|
||||
{
|
||||
cwd: openclawPath,
|
||||
encoding: 'utf-8',
|
||||
timeout: 30000,
|
||||
},
|
||||
(err, stdout) => {
|
||||
if (err) reject(err);
|
||||
else resolve(stdout);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Parse output for errors related to the channel
|
||||
const lines = output.split('\n');
|
||||
for (const line of lines) {
|
||||
const lowerLine = line.toLowerCase();
|
||||
@@ -629,8 +517,7 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
||||
}
|
||||
}
|
||||
|
||||
// If no specific errors found, check if config exists and is valid
|
||||
const config = readOpenClawConfig();
|
||||
const config = await readOpenClawConfig();
|
||||
if (!config.channels?.[channelType]) {
|
||||
result.errors.push(`Channel ${channelType} is not configured`);
|
||||
result.valid = false;
|
||||
@@ -638,7 +525,6 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
||||
result.warnings.push(`Channel ${channelType} is disabled`);
|
||||
}
|
||||
|
||||
// Channel-specific validation
|
||||
if (channelType === 'discord') {
|
||||
const discordConfig = config.channels?.discord;
|
||||
if (!discordConfig?.token) {
|
||||
@@ -651,7 +537,6 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
||||
result.errors.push('Telegram: Bot token is required');
|
||||
result.valid = false;
|
||||
}
|
||||
// Check allowed users (stored as allowFrom array)
|
||||
const allowedUsers = telegramConfig?.allowFrom as string[] | undefined;
|
||||
if (!allowedUsers || allowedUsers.length === 0) {
|
||||
result.errors.push('Telegram: Allowed User IDs are required');
|
||||
@@ -666,7 +551,6 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Check for config errors in the error message
|
||||
if (errorMessage.includes('Unrecognized key') || errorMessage.includes('invalid config')) {
|
||||
result.errors.push(errorMessage);
|
||||
result.valid = false;
|
||||
@@ -674,11 +558,8 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
||||
result.errors.push('OpenClaw not found. Please ensure OpenClaw is installed.');
|
||||
result.valid = false;
|
||||
} else {
|
||||
// Doctor command might fail but config could still be valid
|
||||
// Just log it and do basic validation
|
||||
console.warn('Doctor command failed:', errorMessage);
|
||||
|
||||
const config = readOpenClawConfig();
|
||||
const config = await readOpenClawConfig();
|
||||
if (config.channels?.[channelType]) {
|
||||
result.valid = true;
|
||||
} else {
|
||||
@@ -689,4 +570,4 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@
|
||||
* OpenClaw Gateway 2026.2.15+ requires a signed device identity in the
|
||||
* connect handshake to grant scopes (operator.read, operator.write, etc.).
|
||||
* Without a device, the gateway strips all requested scopes.
|
||||
*
|
||||
* All file I/O uses async fs/promises to avoid blocking the main thread.
|
||||
* Key generation (Ed25519) uses the async crypto.generateKeyPair API.
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import { access, readFile, writeFile, mkdir, chmod } from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export interface DeviceIdentity {
|
||||
@@ -49,8 +53,21 @@ function fingerprintPublicKey(publicKeyPem: string): string {
|
||||
return crypto.createHash('sha256').update(raw).digest('hex');
|
||||
}
|
||||
|
||||
function generateIdentity(): DeviceIdentity {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
||||
/** Non-throwing async existence check. */
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try { await access(p, constants.F_OK); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
/** Generate a new Ed25519 identity (async key generation). */
|
||||
async function generateIdentity(): Promise<DeviceIdentity> {
|
||||
const { publicKey, privateKey } = await new Promise<crypto.KeyPairKeyObjectResult>(
|
||||
(resolve, reject) => {
|
||||
crypto.generateKeyPair('ed25519', (err, publicKey, privateKey) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ publicKey, privateKey });
|
||||
});
|
||||
},
|
||||
);
|
||||
const publicKeyPem = (publicKey.export({ type: 'spki', format: 'pem' }) as Buffer).toString();
|
||||
const privateKeyPem = (privateKey.export({ type: 'pkcs8', format: 'pem' }) as Buffer).toString();
|
||||
return {
|
||||
@@ -63,11 +80,13 @@ function generateIdentity(): DeviceIdentity {
|
||||
/**
|
||||
* Load device identity from disk, or create and persist a new one.
|
||||
* The identity file is stored at `filePath` with mode 0o600.
|
||||
*
|
||||
* Fully async — no synchronous file I/O or crypto.
|
||||
*/
|
||||
export function loadOrCreateDeviceIdentity(filePath: string): DeviceIdentity {
|
||||
export async function loadOrCreateDeviceIdentity(filePath: string): Promise<DeviceIdentity> {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
if (await fileExists(filePath)) {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (
|
||||
parsed?.version === 1 &&
|
||||
@@ -78,7 +97,7 @@ export function loadOrCreateDeviceIdentity(filePath: string): DeviceIdentity {
|
||||
const derivedId = fingerprintPublicKey(parsed.publicKeyPem);
|
||||
if (derivedId && derivedId !== parsed.deviceId) {
|
||||
const updated = { ...parsed, deviceId: derivedId };
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
|
||||
await writeFile(filePath, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
|
||||
return { deviceId: derivedId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
|
||||
}
|
||||
return { deviceId: parsed.deviceId, publicKeyPem: parsed.publicKeyPem, privateKeyPem: parsed.privateKeyPem };
|
||||
@@ -88,12 +107,12 @@ export function loadOrCreateDeviceIdentity(filePath: string): DeviceIdentity {
|
||||
// fall through to create a new identity
|
||||
}
|
||||
|
||||
const identity = generateIdentity();
|
||||
const identity = await generateIdentity();
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
if (!(await fileExists(dir))) await mkdir(dir, { recursive: true });
|
||||
const stored = { version: 1, ...identity, createdAtMs: Date.now() };
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
|
||||
try { fs.chmodSync(filePath, 0o600); } catch { /* ignore */ }
|
||||
await writeFile(filePath, `${JSON.stringify(stored, null, 2)}\n`, { mode: 0o600 });
|
||||
try { await chmod(filePath, 0o600); } catch { /* ignore */ }
|
||||
return identity;
|
||||
}
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ class DeviceOAuthManager extends EventEmitter {
|
||||
// so OpenClaw's gateway auto-refresher knows how to find it.
|
||||
try {
|
||||
const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType;
|
||||
saveOAuthTokenToOpenClaw(tokenProviderId, {
|
||||
await saveOAuthTokenToOpenClaw(tokenProviderId, {
|
||||
access: token.access,
|
||||
refresh: token.refresh,
|
||||
expires: token.expires,
|
||||
@@ -230,7 +230,7 @@ class DeviceOAuthManager extends EventEmitter {
|
||||
|
||||
try {
|
||||
const tokenProviderId = providerType.startsWith('minimax-portal') ? 'minimax-portal' : providerType;
|
||||
setOpenClawDefaultModelWithOverride(tokenProviderId, undefined, {
|
||||
await setOpenClawDefaultModelWithOverride(tokenProviderId, undefined, {
|
||||
baseUrl,
|
||||
api: token.api,
|
||||
// Tells OpenClaw's anthropic adapter to use `Authorization: Bearer` instead of `x-api-key`
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/**
|
||||
* Logger Utility
|
||||
* Centralized logging with levels, file output, and log retrieval for UI
|
||||
* Centralized logging with levels, file output, and log retrieval for UI.
|
||||
*
|
||||
* File writes use an async buffered writer so that high-frequency logging
|
||||
* (e.g. during gateway startup) never blocks the Electron main thread.
|
||||
* Only the final `process.on('exit')` handler uses synchronous I/O to
|
||||
* guarantee the last few messages are flushed before the process exits.
|
||||
*/
|
||||
import { app } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { existsSync, mkdirSync, appendFileSync } from 'fs';
|
||||
import { appendFile, readFile, readdir, stat } from 'fs/promises';
|
||||
|
||||
/**
|
||||
* Log levels
|
||||
@@ -19,7 +25,11 @@ export enum LogLevel {
|
||||
/**
|
||||
* Current log level (can be changed at runtime)
|
||||
*/
|
||||
let currentLevel = LogLevel.DEBUG; // Default to DEBUG for better diagnostics
|
||||
// Default to INFO in packaged builds to reduce sync-like overhead from
|
||||
// high-volume DEBUG logging. In dev mode, keep DEBUG for diagnostics.
|
||||
// Note: app.isPackaged may not be available before app.isReady(), but the
|
||||
// logger is initialised after that point so this is safe.
|
||||
let currentLevel = LogLevel.DEBUG;
|
||||
|
||||
/**
|
||||
* Log file path
|
||||
@@ -33,11 +43,58 @@ let logDir: string | null = null;
|
||||
const RING_BUFFER_SIZE = 500;
|
||||
const recentLogs: string[] = [];
|
||||
|
||||
// ── Async write buffer ───────────────────────────────────────────
|
||||
|
||||
/** Pending log lines waiting to be flushed to disk. */
|
||||
let writeBuffer: string[] = [];
|
||||
/** Timer for the next scheduled flush. */
|
||||
let flushTimer: NodeJS.Timeout | null = null;
|
||||
/** Whether a flush is currently in progress. */
|
||||
let flushing = false;
|
||||
|
||||
const FLUSH_INTERVAL_MS = 500;
|
||||
const FLUSH_SIZE_THRESHOLD = 20;
|
||||
|
||||
async function flushBuffer(): Promise<void> {
|
||||
if (flushing || writeBuffer.length === 0 || !logFilePath) return;
|
||||
flushing = true;
|
||||
const batch = writeBuffer.join('');
|
||||
writeBuffer = [];
|
||||
try {
|
||||
await appendFile(logFilePath, batch);
|
||||
} catch {
|
||||
// Silently fail if we can't write to file
|
||||
} finally {
|
||||
flushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Synchronous flush for the `exit` handler — guaranteed to write. */
|
||||
function flushBufferSync(): void {
|
||||
if (writeBuffer.length === 0 || !logFilePath) return;
|
||||
try {
|
||||
appendFileSync(logFilePath, writeBuffer.join(''));
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
writeBuffer = [];
|
||||
}
|
||||
|
||||
// Ensure all buffered data reaches disk before the process exits.
|
||||
process.on('exit', flushBufferSync);
|
||||
|
||||
// ── Initialisation ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Initialize logger — safe to call before app.isReady()
|
||||
*/
|
||||
export function initLogger(): void {
|
||||
try {
|
||||
// In production, default to INFO to reduce log volume and overhead.
|
||||
if (app.isPackaged && currentLevel < LogLevel.INFO) {
|
||||
currentLevel = LogLevel.INFO;
|
||||
}
|
||||
|
||||
logDir = join(app.getPath('userData'), 'logs');
|
||||
|
||||
if (!existsSync(logDir)) {
|
||||
@@ -47,7 +104,7 @@ export function initLogger(): void {
|
||||
const timestamp = new Date().toISOString().split('T')[0];
|
||||
logFilePath = join(logDir, `clawx-${timestamp}.log`);
|
||||
|
||||
// Write a separator for new session
|
||||
// Write a separator for new session (sync is OK — happens once at startup)
|
||||
const sessionHeader = `\n${'='.repeat(80)}\n[${new Date().toISOString()}] === ClawX Session Start (v${app.getVersion()}) ===\n${'='.repeat(80)}\n`;
|
||||
appendFileSync(logFilePath, sessionHeader);
|
||||
} catch (error) {
|
||||
@@ -55,30 +112,22 @@ export function initLogger(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set log level
|
||||
*/
|
||||
// ── Level / path accessors ───────────────────────────────────────
|
||||
|
||||
export function setLogLevel(level: LogLevel): void {
|
||||
currentLevel = level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get log file directory path
|
||||
*/
|
||||
export function getLogDir(): string | null {
|
||||
return logDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current log file path
|
||||
*/
|
||||
export function getLogFilePath(): string | null {
|
||||
return logFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format log message
|
||||
*/
|
||||
// ── Formatting ───────────────────────────────────────────────────
|
||||
|
||||
function formatMessage(level: string, message: string, ...args: unknown[]): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
const formattedArgs = args.length > 0 ? ' ' + args.map(arg => {
|
||||
@@ -98,29 +147,36 @@ function formatMessage(level: string, message: string, ...args: unknown[]): stri
|
||||
return `[${timestamp}] [${level.padEnd(5)}] ${message}${formattedArgs}`;
|
||||
}
|
||||
|
||||
// ── Core write ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write to log file and ring buffer
|
||||
* Write to ring buffer + schedule an async flush to disk.
|
||||
*/
|
||||
function writeLog(formatted: string): void {
|
||||
// Ring buffer
|
||||
// Ring buffer (always synchronous — in-memory only)
|
||||
recentLogs.push(formatted);
|
||||
if (recentLogs.length > RING_BUFFER_SIZE) {
|
||||
recentLogs.shift();
|
||||
}
|
||||
|
||||
// File
|
||||
// Async file write via buffer
|
||||
if (logFilePath) {
|
||||
try {
|
||||
appendFileSync(logFilePath, formatted + '\n');
|
||||
} catch {
|
||||
// Silently fail if we can't write to file
|
||||
writeBuffer.push(formatted + '\n');
|
||||
if (writeBuffer.length >= FLUSH_SIZE_THRESHOLD) {
|
||||
// Buffer is large enough — flush immediately (non-blocking)
|
||||
void flushBuffer();
|
||||
} else if (!flushTimer) {
|
||||
// Schedule a flush after a short delay
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = null;
|
||||
void flushBuffer();
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log debug message
|
||||
*/
|
||||
// ── Public log methods ───────────────────────────────────────────
|
||||
|
||||
export function debug(message: string, ...args: unknown[]): void {
|
||||
if (currentLevel <= LogLevel.DEBUG) {
|
||||
const formatted = formatMessage('DEBUG', message, ...args);
|
||||
@@ -129,9 +185,6 @@ export function debug(message: string, ...args: unknown[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message
|
||||
*/
|
||||
export function info(message: string, ...args: unknown[]): void {
|
||||
if (currentLevel <= LogLevel.INFO) {
|
||||
const formatted = formatMessage('INFO', message, ...args);
|
||||
@@ -140,9 +193,6 @@ export function info(message: string, ...args: unknown[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning message
|
||||
*/
|
||||
export function warn(message: string, ...args: unknown[]): void {
|
||||
if (currentLevel <= LogLevel.WARN) {
|
||||
const formatted = formatMessage('WARN', message, ...args);
|
||||
@@ -151,9 +201,6 @@ export function warn(message: string, ...args: unknown[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error message
|
||||
*/
|
||||
export function error(message: string, ...args: unknown[]): void {
|
||||
if (currentLevel <= LogLevel.ERROR) {
|
||||
const formatted = formatMessage('ERROR', message, ...args);
|
||||
@@ -162,11 +209,8 @@ export function error(message: string, ...args: unknown[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent logs from ring buffer (for UI display)
|
||||
* @param count Number of recent log lines to return (default: all)
|
||||
* @param minLevel Minimum log level to include (default: DEBUG)
|
||||
*/
|
||||
// ── Log retrieval (for UI / diagnostics) ─────────────────────────
|
||||
|
||||
export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] {
|
||||
const filtered = minLevel != null
|
||||
? recentLogs.filter(line => {
|
||||
@@ -181,14 +225,13 @@ export function getRecentLogs(count?: number, minLevel?: LogLevel): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current day's log file content (last N lines)
|
||||
* Read the current day's log file content (last N lines).
|
||||
* Uses async I/O to avoid blocking.
|
||||
*/
|
||||
export function readLogFile(tailLines = 200): string {
|
||||
if (!logFilePath || !existsSync(logFilePath)) {
|
||||
return '(No log file found)';
|
||||
}
|
||||
export async function readLogFile(tailLines = 200): Promise<string> {
|
||||
if (!logFilePath) return '(No log file found)';
|
||||
try {
|
||||
const content = readFileSync(logFilePath, 'utf-8');
|
||||
const content = await readFile(logFilePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
if (lines.length <= tailLines) return content;
|
||||
return lines.slice(-tailLines).join('\n');
|
||||
@@ -198,24 +241,26 @@ export function readLogFile(tailLines = 200): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* List available log files
|
||||
* List available log files.
|
||||
* Uses async I/O to avoid blocking.
|
||||
*/
|
||||
export function listLogFiles(): Array<{ name: string; path: string; size: number; modified: string }> {
|
||||
if (!logDir || !existsSync(logDir)) return [];
|
||||
export async function listLogFiles(): Promise<Array<{ name: string; path: string; size: number; modified: string }>> {
|
||||
if (!logDir) return [];
|
||||
try {
|
||||
return readdirSync(logDir)
|
||||
.filter(f => f.endsWith('.log'))
|
||||
.map(f => {
|
||||
const fullPath = join(logDir!, f);
|
||||
const stat = statSync(fullPath);
|
||||
return {
|
||||
name: f,
|
||||
path: fullPath,
|
||||
size: stat.size,
|
||||
modified: stat.mtime.toISOString(),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.modified.localeCompare(a.modified));
|
||||
const files = await readdir(logDir);
|
||||
const results: Array<{ name: string; path: string; size: number; modified: string }> = [];
|
||||
for (const f of files) {
|
||||
if (!f.endsWith('.log')) continue;
|
||||
const fullPath = join(logDir, f);
|
||||
const s = await stat(fullPath);
|
||||
results.push({
|
||||
name: f,
|
||||
path: fullPath,
|
||||
size: s.size,
|
||||
modified: s.mtime.toISOString(),
|
||||
});
|
||||
}
|
||||
return results.sort((a, b) => b.modified.localeCompare(a.modified));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
* OpenClaw Auth Profiles Utility
|
||||
* Writes API keys to ~/.openclaw/agents/main/agent/auth-profiles.json
|
||||
* so the OpenClaw Gateway can load them for AI provider calls.
|
||||
*
|
||||
* All file I/O is asynchronous (fs/promises) to avoid blocking the
|
||||
* Electron main thread. On Windows + NTFS + Defender the synchronous
|
||||
* equivalents could stall for 500 ms – 2 s+ per call, causing "Not
|
||||
* Responding" hangs.
|
||||
*/
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs';
|
||||
import { access, mkdir, readFile, writeFile, readdir } from 'fs/promises';
|
||||
import { constants, Dirent } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import {
|
||||
@@ -15,18 +21,50 @@ import {
|
||||
const AUTH_STORE_VERSION = 1;
|
||||
const AUTH_PROFILE_FILENAME = 'auth-profiles.json';
|
||||
|
||||
/**
|
||||
* Auth profile entry for an API key
|
||||
*/
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Non-throwing async existence check (replaces existsSync). */
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await access(p, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure a directory exists (replaces mkdirSync). */
|
||||
async function ensureDir(dir: string): Promise<void> {
|
||||
if (!(await fileExists(dir))) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a JSON file, returning `null` on any error. */
|
||||
async function readJsonFile<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
if (!(await fileExists(filePath))) return null;
|
||||
const raw = await readFile(filePath, 'utf-8');
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Write a JSON file, creating parent directories if needed. */
|
||||
async function writeJsonFile(filePath: string, data: unknown): Promise<void> {
|
||||
await ensureDir(join(filePath, '..'));
|
||||
await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
interface AuthProfileEntry {
|
||||
type: 'api_key';
|
||||
provider: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth profile entry for an OAuth token (matches OpenClaw plugin format)
|
||||
*/
|
||||
interface OAuthProfileEntry {
|
||||
type: 'oauth';
|
||||
provider: string;
|
||||
@@ -35,9 +73,6 @@ interface OAuthProfileEntry {
|
||||
expires: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth profiles store format
|
||||
*/
|
||||
interface AuthProfilesStore {
|
||||
version: number;
|
||||
profiles: Record<string, AuthProfileEntry | OAuthProfileEntry>;
|
||||
@@ -45,90 +80,78 @@ interface AuthProfilesStore {
|
||||
lastGood?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the auth-profiles.json for a given agent
|
||||
*/
|
||||
// ── Auth Profiles I/O ────────────────────────────────────────────
|
||||
|
||||
function getAuthProfilesPath(agentId = 'main'): string {
|
||||
return join(homedir(), '.openclaw', 'agents', agentId, 'agent', AUTH_PROFILE_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read existing auth profiles store, or create an empty one
|
||||
*/
|
||||
function readAuthProfiles(agentId = 'main'): AuthProfilesStore {
|
||||
async function readAuthProfiles(agentId = 'main'): Promise<AuthProfilesStore> {
|
||||
const filePath = getAuthProfilesPath(agentId);
|
||||
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
const raw = readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(raw) as AuthProfilesStore;
|
||||
// Validate basic structure
|
||||
if (data.version && data.profiles && typeof data.profiles === 'object') {
|
||||
return data;
|
||||
}
|
||||
const data = await readJsonFile<AuthProfilesStore>(filePath);
|
||||
if (data?.version && data.profiles && typeof data.profiles === 'object') {
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to read auth-profiles.json, creating fresh store:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {},
|
||||
};
|
||||
return { version: AUTH_STORE_VERSION, profiles: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write auth profiles store to disk
|
||||
*/
|
||||
function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): void {
|
||||
const filePath = getAuthProfilesPath(agentId);
|
||||
const dir = join(filePath, '..');
|
||||
|
||||
// Ensure directory exists
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(filePath, JSON.stringify(store, null, 2), 'utf-8');
|
||||
async function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): Promise<void> {
|
||||
await writeJsonFile(getAuthProfilesPath(agentId), store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all agent IDs that have an agent/ subdirectory.
|
||||
*/
|
||||
function discoverAgentIds(): string[] {
|
||||
// ── Agent Discovery ──────────────────────────────────────────────
|
||||
|
||||
async function discoverAgentIds(): Promise<string[]> {
|
||||
const agentsDir = join(homedir(), '.openclaw', 'agents');
|
||||
try {
|
||||
if (!existsSync(agentsDir)) return ['main'];
|
||||
return readdirSync(agentsDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory() && existsSync(join(agentsDir, d.name, 'agent')))
|
||||
.map((d) => d.name);
|
||||
if (!(await fileExists(agentsDir))) return ['main'];
|
||||
const entries: Dirent[] = await readdir(agentsDir, { withFileTypes: true });
|
||||
const ids: string[] = [];
|
||||
for (const d of entries) {
|
||||
if (d.isDirectory() && await fileExists(join(agentsDir, d.name, 'agent'))) {
|
||||
ids.push(d.name);
|
||||
}
|
||||
}
|
||||
return ids.length > 0 ? ids : ['main'];
|
||||
} catch {
|
||||
return ['main'];
|
||||
}
|
||||
}
|
||||
|
||||
// ── OpenClaw Config Helpers ──────────────────────────────────────
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
|
||||
async function readOpenClawJson(): Promise<Record<string, unknown>> {
|
||||
return (await readJsonFile<Record<string, unknown>>(OPENCLAW_CONFIG_PATH)) ?? {};
|
||||
}
|
||||
|
||||
async function writeOpenClawJson(config: Record<string, unknown>): Promise<void> {
|
||||
await writeJsonFile(OPENCLAW_CONFIG_PATH, config);
|
||||
}
|
||||
|
||||
// ── Exported Functions (all async) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Save an OAuth token to OpenClaw's auth-profiles.json.
|
||||
* Writes in OpenClaw's native OAuth credential format (type: 'oauth'),
|
||||
* matching exactly what `openclaw models auth login` (upsertAuthProfile) writes.
|
||||
*
|
||||
* @param provider - Provider type (e.g. 'minimax-portal', 'qwen-portal')
|
||||
* @param token - OAuth token from the provider's login function
|
||||
* @param agentId - Optional single agent ID. When omitted, writes to every agent.
|
||||
*/
|
||||
export function saveOAuthTokenToOpenClaw(
|
||||
export async function saveOAuthTokenToOpenClaw(
|
||||
provider: string,
|
||||
token: { access: string; refresh: string; expires: number },
|
||||
agentId?: string
|
||||
): void {
|
||||
const agentIds = agentId ? [agentId] : discoverAgentIds();
|
||||
): Promise<void> {
|
||||
const agentIds = agentId ? [agentId] : await discoverAgentIds();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
|
||||
for (const id of agentIds) {
|
||||
const store = readAuthProfiles(id);
|
||||
const store = await readAuthProfiles(id);
|
||||
const profileId = `${provider}:default`;
|
||||
|
||||
const entry: OAuthProfileEntry = {
|
||||
store.profiles[profileId] = {
|
||||
type: 'oauth',
|
||||
provider,
|
||||
access: token.access,
|
||||
@@ -136,8 +159,6 @@ export function saveOAuthTokenToOpenClaw(
|
||||
expires: token.expires,
|
||||
};
|
||||
|
||||
store.profiles[profileId] = entry;
|
||||
|
||||
if (!store.order) store.order = {};
|
||||
if (!store.order[provider]) store.order[provider] = [];
|
||||
if (!store.order[provider].includes(profileId)) {
|
||||
@@ -147,9 +168,8 @@ export function saveOAuthTokenToOpenClaw(
|
||||
if (!store.lastGood) store.lastGood = {};
|
||||
store.lastGood[provider] = profileId;
|
||||
|
||||
writeAuthProfiles(store, id);
|
||||
await writeAuthProfiles(store, id);
|
||||
}
|
||||
|
||||
console.log(`Saved OAuth token for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||
}
|
||||
|
||||
@@ -161,12 +181,12 @@ export function saveOAuthTokenToOpenClaw(
|
||||
* @param agentId - Optional single agent ID to read from, defaults to 'main'
|
||||
* @returns The OAuth token access string or null if not found
|
||||
*/
|
||||
export function getOAuthTokenFromOpenClaw(
|
||||
export async function getOAuthTokenFromOpenClaw(
|
||||
provider: string,
|
||||
agentId = 'main'
|
||||
): string | null {
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const store = readAuthProfiles(agentId);
|
||||
const store = await readAuthProfiles(agentId);
|
||||
const profileId = `${provider}:default`;
|
||||
const profile = store.profiles[profileId];
|
||||
|
||||
@@ -181,65 +201,36 @@ export function getOAuthTokenFromOpenClaw(
|
||||
|
||||
/**
|
||||
* Save a provider API key to OpenClaw's auth-profiles.json
|
||||
* This writes the key in the format OpenClaw expects so the gateway
|
||||
* can use it for AI provider calls.
|
||||
*
|
||||
* Writes to ALL discovered agent directories so every agent
|
||||
* (including non-"main" agents like "dev") stays in sync.
|
||||
*
|
||||
* @param provider - Provider type (e.g., 'anthropic', 'openrouter', 'openai', 'google')
|
||||
* @param apiKey - The API key to store
|
||||
* @param agentId - Optional single agent ID. When omitted, writes to every agent.
|
||||
*/
|
||||
export function saveProviderKeyToOpenClaw(
|
||||
|
||||
export async function saveProviderKeyToOpenClaw(
|
||||
provider: string,
|
||||
apiKey: string,
|
||||
agentId?: string
|
||||
): void {
|
||||
// OAuth providers (qwen-portal, minimax-portal) typically have their credentials
|
||||
// managed by OpenClaw plugins via `openclaw models auth login`.
|
||||
// Skip only if there's no explicit API key — meaning the user is using OAuth.
|
||||
// If the user provided an actual API key, write it normally.
|
||||
): Promise<void> {
|
||||
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
||||
if (OAUTH_PROVIDERS.includes(provider) && !apiKey) {
|
||||
console.log(`Skipping auth-profiles write for OAuth provider "${provider}" (no API key provided, using OAuth)`);
|
||||
return;
|
||||
}
|
||||
const agentIds = agentId ? [agentId] : discoverAgentIds();
|
||||
const agentIds = agentId ? [agentId] : await discoverAgentIds();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
|
||||
for (const id of agentIds) {
|
||||
const store = readAuthProfiles(id);
|
||||
|
||||
// Profile ID follows OpenClaw convention: <provider>:default
|
||||
const store = await readAuthProfiles(id);
|
||||
const profileId = `${provider}:default`;
|
||||
|
||||
// Upsert the profile entry
|
||||
store.profiles[profileId] = {
|
||||
type: 'api_key',
|
||||
provider,
|
||||
key: apiKey,
|
||||
};
|
||||
store.profiles[profileId] = { type: 'api_key', provider, key: apiKey };
|
||||
|
||||
// Update order to include this profile
|
||||
if (!store.order) {
|
||||
store.order = {};
|
||||
}
|
||||
if (!store.order[provider]) {
|
||||
store.order[provider] = [];
|
||||
}
|
||||
if (!store.order) store.order = {};
|
||||
if (!store.order[provider]) store.order[provider] = [];
|
||||
if (!store.order[provider].includes(profileId)) {
|
||||
store.order[provider].push(profileId);
|
||||
}
|
||||
|
||||
// Set as last good
|
||||
if (!store.lastGood) {
|
||||
store.lastGood = {};
|
||||
}
|
||||
if (!store.lastGood) store.lastGood = {};
|
||||
store.lastGood[provider] = profileId;
|
||||
|
||||
writeAuthProfiles(store, id);
|
||||
await writeAuthProfiles(store, id);
|
||||
}
|
||||
console.log(`Saved API key for provider "${provider}" to OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||
}
|
||||
@@ -247,38 +238,31 @@ export function saveProviderKeyToOpenClaw(
|
||||
/**
|
||||
* Remove a provider API key from OpenClaw auth-profiles.json
|
||||
*/
|
||||
export function removeProviderKeyFromOpenClaw(
|
||||
export async function removeProviderKeyFromOpenClaw(
|
||||
provider: string,
|
||||
agentId?: string
|
||||
): void {
|
||||
// OAuth providers have their credentials managed by OpenClaw plugins.
|
||||
// Do NOT delete their auth-profiles entries.
|
||||
): Promise<void> {
|
||||
const OAUTH_PROVIDERS = ['qwen-portal', 'minimax-portal', 'minimax-portal-cn'];
|
||||
if (OAUTH_PROVIDERS.includes(provider)) {
|
||||
console.log(`Skipping auth-profiles removal for OAuth provider "${provider}" (managed by OpenClaw plugin)`);
|
||||
return;
|
||||
}
|
||||
const agentIds = agentId ? [agentId] : discoverAgentIds();
|
||||
const agentIds = agentId ? [agentId] : await discoverAgentIds();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
|
||||
for (const id of agentIds) {
|
||||
const store = readAuthProfiles(id);
|
||||
const store = await readAuthProfiles(id);
|
||||
const profileId = `${provider}:default`;
|
||||
|
||||
delete store.profiles[profileId];
|
||||
|
||||
if (store.order?.[provider]) {
|
||||
store.order[provider] = store.order[provider].filter((aid) => aid !== profileId);
|
||||
if (store.order[provider].length === 0) {
|
||||
delete store.order[provider];
|
||||
}
|
||||
if (store.order[provider].length === 0) delete store.order[provider];
|
||||
}
|
||||
if (store.lastGood?.[provider] === profileId) delete store.lastGood[provider];
|
||||
|
||||
if (store.lastGood?.[provider] === profileId) {
|
||||
delete store.lastGood[provider];
|
||||
}
|
||||
|
||||
writeAuthProfiles(store, id);
|
||||
await writeAuthProfiles(store, id);
|
||||
}
|
||||
console.log(`Removed API key for provider "${provider}" from OpenClaw auth-profiles (agents: ${agentIds.join(', ')})`);
|
||||
}
|
||||
@@ -286,12 +270,12 @@ export function removeProviderKeyFromOpenClaw(
|
||||
/**
|
||||
* Remove a provider completely from OpenClaw (delete config, disable plugins, delete keys)
|
||||
*/
|
||||
export function removeProviderFromOpenClaw(provider: string): void {
|
||||
export async function removeProviderFromOpenClaw(provider: string): Promise<void> {
|
||||
// 1. Remove from auth-profiles.json
|
||||
const agentIds = discoverAgentIds();
|
||||
const agentIds = await discoverAgentIds();
|
||||
if (agentIds.length === 0) agentIds.push('main');
|
||||
for (const id of agentIds) {
|
||||
const store = readAuthProfiles(id);
|
||||
const store = await readAuthProfiles(id);
|
||||
const profileId = `${provider}:default`;
|
||||
if (store.profiles[profileId]) {
|
||||
delete store.profiles[profileId];
|
||||
@@ -300,55 +284,55 @@ export function removeProviderFromOpenClaw(provider: string): void {
|
||||
if (store.order[provider].length === 0) delete store.order[provider];
|
||||
}
|
||||
if (store.lastGood?.[provider] === profileId) delete store.lastGood[provider];
|
||||
writeAuthProfiles(store, id);
|
||||
await writeAuthProfiles(store, id);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remove from models.json (per-agent model registry used by pi-ai directly)
|
||||
for (const agentId of agentIds) {
|
||||
const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json');
|
||||
for (const id of agentIds) {
|
||||
const modelsPath = join(homedir(), '.openclaw', 'agents', id, 'agent', 'models.json');
|
||||
try {
|
||||
if (existsSync(modelsPath)) {
|
||||
const data = JSON.parse(readFileSync(modelsPath, 'utf-8')) as Record<string, unknown>;
|
||||
if (await fileExists(modelsPath)) {
|
||||
const raw = await readFile(modelsPath, 'utf-8');
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
const providers = data.providers as Record<string, unknown> | undefined;
|
||||
if (providers && providers[provider]) {
|
||||
delete providers[provider];
|
||||
writeFileSync(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
console.log(`Removed models.json entry for provider "${provider}" (agent "${agentId}")`);
|
||||
await writeFile(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
console.log(`Removed models.json entry for provider "${provider}" (agent "${id}")`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to remove provider ${provider} from models.json (agent "${agentId}"):`, err);
|
||||
console.warn(`Failed to remove provider ${provider} from models.json (agent "${id}"):`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remove from openclaw.json
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
let modified = false;
|
||||
const config = await readOpenClawJson();
|
||||
let modified = false;
|
||||
|
||||
// Disable plugin (for OAuth like qwen-portal-auth)
|
||||
if (config.plugins?.entries) {
|
||||
const pluginName = `${provider}-auth`;
|
||||
if (config.plugins.entries[pluginName]) {
|
||||
config.plugins.entries[pluginName].enabled = false;
|
||||
modified = true;
|
||||
console.log(`Disabled OpenClaw plugin: ${pluginName}`);
|
||||
}
|
||||
}
|
||||
// Disable plugin (for OAuth like qwen-portal-auth)
|
||||
const plugins = config.plugins as Record<string, unknown> | undefined;
|
||||
const entries = (plugins?.entries ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const pluginName = `${provider}-auth`;
|
||||
if (entries[pluginName]) {
|
||||
entries[pluginName].enabled = false;
|
||||
modified = true;
|
||||
console.log(`Disabled OpenClaw plugin: ${pluginName}`);
|
||||
}
|
||||
|
||||
// Remove from models.providers
|
||||
if (config.models?.providers?.[provider]) {
|
||||
delete config.models.providers[provider];
|
||||
modified = true;
|
||||
console.log(`Removed OpenClaw provider config: ${provider}`);
|
||||
}
|
||||
// Remove from models.providers
|
||||
const models = config.models as Record<string, unknown> | undefined;
|
||||
const providers = (models?.providers ?? {}) as Record<string, unknown>;
|
||||
if (providers[provider]) {
|
||||
delete providers[provider];
|
||||
modified = true;
|
||||
console.log(`Removed OpenClaw provider config: ${provider}`);
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
}
|
||||
if (modified) {
|
||||
await writeOpenClawJson(config);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Failed to remove provider ${provider} from openclaw.json:`, err);
|
||||
@@ -361,37 +345,21 @@ export function removeProviderFromOpenClaw(provider: string): void {
|
||||
*/
|
||||
export function buildProviderEnvVars(providers: Array<{ type: string; apiKey: string }>): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
for (const { type, apiKey } of providers) {
|
||||
const envVar = getProviderEnvVar(type);
|
||||
if (envVar && apiKey) {
|
||||
env[envVar] = apiKey;
|
||||
}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the OpenClaw config to use the given provider and model
|
||||
* Writes to ~/.openclaw/openclaw.json
|
||||
*
|
||||
* @param provider - Provider type (e.g. 'anthropic', 'siliconflow')
|
||||
* @param modelOverride - Optional model string to use instead of the registry default.
|
||||
* For siliconflow this is the user-supplied model ID prefixed with "siliconflow/".
|
||||
*/
|
||||
export function setOpenClawDefaultModel(provider: string, modelOverride?: string): void {
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to read openclaw.json, creating fresh config:', err);
|
||||
}
|
||||
export async function setOpenClawDefaultModel(provider: string, modelOverride?: string): Promise<void> {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
const model = modelOverride || getProviderDefaultModel(provider);
|
||||
if (!model) {
|
||||
@@ -404,7 +372,6 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
||||
: model;
|
||||
|
||||
// Set the default model for the agents
|
||||
// model must be an object: { primary: "provider/model", fallbacks?: [] }
|
||||
const agents = (config.agents || {}) as Record<string, unknown>;
|
||||
const defaults = (agents.defaults || {}) as Record<string, unknown>;
|
||||
defaults.model = { primary: model };
|
||||
@@ -412,8 +379,6 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
||||
config.agents = agents;
|
||||
|
||||
// Configure models.providers for providers that need explicit registration.
|
||||
// Built-in providers (anthropic, google) are part of OpenClaw's pi-ai catalog
|
||||
// and must NOT have a models.providers entry — it would override the built-in.
|
||||
const providerCfg = getProviderConfig(provider);
|
||||
if (providerCfg) {
|
||||
const models = (config.models || {}) as Record<string, unknown>;
|
||||
@@ -456,9 +421,7 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
||||
models.providers = providers;
|
||||
config.models = models;
|
||||
} else {
|
||||
// Built-in provider: remove any stale models.providers entry that may
|
||||
// have been written by an earlier version. Leaving it in place would
|
||||
// override the native pi-ai catalog and can break streaming/auth.
|
||||
// Built-in provider: remove any stale models.providers entry
|
||||
const models = (config.models || {}) as Record<string, unknown>;
|
||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
||||
if (providers[provider]) {
|
||||
@@ -471,18 +434,10 @@ export function setOpenClawDefaultModel(provider: string, modelOverride?: string
|
||||
|
||||
// Ensure gateway mode is set
|
||||
const gateway = (config.gateway || {}) as Record<string, unknown>;
|
||||
if (!gateway.mode) {
|
||||
gateway.mode = 'local';
|
||||
}
|
||||
if (!gateway.mode) gateway.mode = 'local';
|
||||
config.gateway = gateway;
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = join(configPath, '..');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
await writeOpenClawJson(config);
|
||||
console.log(`Set OpenClaw default model to "${model}" for provider "${provider}"`);
|
||||
}
|
||||
|
||||
@@ -498,39 +453,26 @@ interface RuntimeProviderConfigOverride {
|
||||
* Register or update a provider's configuration in openclaw.json
|
||||
* without changing the current default model.
|
||||
*/
|
||||
export function syncProviderConfigToOpenClaw(
|
||||
export async function syncProviderConfigToOpenClaw(
|
||||
provider: string,
|
||||
modelId: string | undefined,
|
||||
override: RuntimeProviderConfigOverride
|
||||
): void {
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to read openclaw.json, creating fresh config:', err);
|
||||
}
|
||||
): Promise<void> {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
if (override.baseUrl && override.api) {
|
||||
const models = (config.models || {}) as Record<string, unknown>;
|
||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
||||
|
||||
const nextModels: Array<Record<string, unknown>> = [];
|
||||
if (modelId) {
|
||||
nextModels.push({ id: modelId, name: modelId });
|
||||
}
|
||||
if (modelId) nextModels.push({ id: modelId, name: modelId });
|
||||
|
||||
const nextProvider: Record<string, unknown> = {
|
||||
baseUrl: override.baseUrl,
|
||||
api: override.api,
|
||||
models: nextModels,
|
||||
};
|
||||
if (override.apiKeyEnv) {
|
||||
nextProvider.apiKey = override.apiKeyEnv;
|
||||
}
|
||||
if (override.apiKeyEnv) nextProvider.apiKey = override.apiKeyEnv;
|
||||
if (override.headers && Object.keys(override.headers).length > 0) {
|
||||
nextProvider.headers = override.headers;
|
||||
}
|
||||
@@ -543,40 +485,24 @@ export function syncProviderConfigToOpenClaw(
|
||||
// Ensure extension is enabled for oauth providers to prevent gateway wiping config
|
||||
if (provider === 'minimax-portal' || provider === 'qwen-portal') {
|
||||
const plugins = (config.plugins || {}) as Record<string, unknown>;
|
||||
const entries = (plugins.entries || {}) as Record<string, unknown>;
|
||||
entries[`${provider}-auth`] = { enabled: true };
|
||||
plugins.entries = entries;
|
||||
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
|
||||
pEntries[`${provider}-auth`] = { enabled: true };
|
||||
plugins.entries = pEntries;
|
||||
config.plugins = plugins;
|
||||
}
|
||||
|
||||
const dir = join(configPath, '..');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
await writeOpenClawJson(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update OpenClaw model + provider config using runtime config values.
|
||||
* Useful for user-configurable providers (custom/ollama-like) where
|
||||
* baseUrl/model are not in the static registry.
|
||||
*/
|
||||
export function setOpenClawDefaultModelWithOverride(
|
||||
export async function setOpenClawDefaultModelWithOverride(
|
||||
provider: string,
|
||||
modelOverride: string | undefined,
|
||||
override: RuntimeProviderConfigOverride
|
||||
): void {
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to read openclaw.json, creating fresh config:', err);
|
||||
}
|
||||
): Promise<void> {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
const model = modelOverride || getProviderDefaultModel(provider);
|
||||
if (!model) {
|
||||
@@ -598,23 +524,15 @@ export function setOpenClawDefaultModelWithOverride(
|
||||
const models = (config.models || {}) as Record<string, unknown>;
|
||||
const providers = (models.providers || {}) as Record<string, unknown>;
|
||||
|
||||
// Replace the provider entry entirely rather than merging.
|
||||
// Different custom/ollama provider instances have different baseUrls,
|
||||
// so merging models from a previous instance creates an inconsistent
|
||||
// config (models pointing at the wrong endpoint).
|
||||
const nextModels: Array<Record<string, unknown>> = [];
|
||||
if (modelId) {
|
||||
nextModels.push({ id: modelId, name: modelId });
|
||||
}
|
||||
if (modelId) nextModels.push({ id: modelId, name: modelId });
|
||||
|
||||
const nextProvider: Record<string, unknown> = {
|
||||
baseUrl: override.baseUrl,
|
||||
api: override.api,
|
||||
models: nextModels,
|
||||
};
|
||||
if (override.apiKeyEnv) {
|
||||
nextProvider.apiKey = override.apiKeyEnv;
|
||||
}
|
||||
if (override.apiKeyEnv) nextProvider.apiKey = override.apiKeyEnv;
|
||||
if (override.headers && Object.keys(override.headers).length > 0) {
|
||||
nextProvider.headers = override.headers;
|
||||
}
|
||||
@@ -628,48 +546,48 @@ export function setOpenClawDefaultModelWithOverride(
|
||||
}
|
||||
|
||||
const gateway = (config.gateway || {}) as Record<string, unknown>;
|
||||
if (!gateway.mode) {
|
||||
gateway.mode = 'local';
|
||||
}
|
||||
if (!gateway.mode) gateway.mode = 'local';
|
||||
config.gateway = gateway;
|
||||
|
||||
// Ensure the extension plugin is marked as enabled in openclaw.json
|
||||
// Without this, the OpenClaw Gateway will silently wipe the provider config on startup
|
||||
if (provider === 'minimax-portal' || provider === 'qwen-portal') {
|
||||
const plugins = (config.plugins || {}) as Record<string, unknown>;
|
||||
const entries = (plugins.entries || {}) as Record<string, unknown>;
|
||||
entries[`${provider}-auth`] = { enabled: true };
|
||||
plugins.entries = entries;
|
||||
const pEntries = (plugins.entries || {}) as Record<string, unknown>;
|
||||
pEntries[`${provider}-auth`] = { enabled: true };
|
||||
plugins.entries = pEntries;
|
||||
config.plugins = plugins;
|
||||
}
|
||||
|
||||
const dir = join(configPath, '..');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
await writeOpenClawJson(config);
|
||||
console.log(
|
||||
`Set OpenClaw default model to "${model}" for provider "${provider}" (runtime override)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a set of all active provider IDs configured in openclaw.json and auth-profiles.json.
|
||||
* This is used to sync ClawX's local provider list with the actual OpenClaw engine state.
|
||||
* Get a set of all active provider IDs configured in openclaw.json.
|
||||
* Reads the file ONCE and extracts both models.providers and plugins.entries.
|
||||
*/
|
||||
export function getActiveOpenClawProviders(): Set<string> {
|
||||
export async function getActiveOpenClawProviders(): Promise<Set<string>> {
|
||||
const activeProviders = new Set<string>();
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
|
||||
// 1. Read openclaw.json models.providers
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
const providers = config.models?.providers;
|
||||
if (providers && typeof providers === 'object') {
|
||||
for (const key of Object.keys(providers)) {
|
||||
activeProviders.add(key);
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
// 1. models.providers
|
||||
const providers = (config.models as Record<string, unknown> | undefined)?.providers;
|
||||
if (providers && typeof providers === 'object') {
|
||||
for (const key of Object.keys(providers as Record<string, unknown>)) {
|
||||
activeProviders.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. plugins.entries for OAuth providers
|
||||
const plugins = (config.plugins as Record<string, unknown> | undefined)?.entries;
|
||||
if (plugins && typeof plugins === 'object') {
|
||||
for (const [pluginId, meta] of Object.entries(plugins as Record<string, unknown>)) {
|
||||
if (pluginId.endsWith('-auth') && (meta as Record<string, unknown>).enabled) {
|
||||
activeProviders.add(pluginId.replace(/-auth$/, ''));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -677,48 +595,14 @@ export function getActiveOpenClawProviders(): Set<string> {
|
||||
console.warn('Failed to read openclaw.json for active providers:', err);
|
||||
}
|
||||
|
||||
// 2. Read openclaw.json plugins.entries for OAuth providers
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
const plugins = config.plugins?.entries;
|
||||
if (plugins && typeof plugins === 'object') {
|
||||
for (const [pluginId, meta] of Object.entries(plugins)) {
|
||||
// If the plugin ends with -auth and is enabled, it's an OAuth provider
|
||||
// e.g. 'qwen-portal-auth' implies provider 'qwen-portal'
|
||||
if (pluginId.endsWith('-auth') && (meta as Record<string, unknown>).enabled) {
|
||||
const providerId = pluginId.replace(/-auth$/, '');
|
||||
activeProviders.add(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to read openclaw.json for active plugins:', err);
|
||||
}
|
||||
|
||||
return activeProviders;
|
||||
}
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
/**
|
||||
* Write the ClawX gateway token into ~/.openclaw/openclaw.json so the
|
||||
* gateway process reads the same token we use for the WebSocket handshake.
|
||||
*
|
||||
* Without this, openclaw.json may contain a stale token written by the
|
||||
* system-managed gateway service (launchctl), causing a "token mismatch"
|
||||
* auth failure when ClawX connects to the process it just spawned.
|
||||
* Write the ClawX gateway token into ~/.openclaw/openclaw.json.
|
||||
*/
|
||||
export function syncGatewayTokenToConfig(token: string): void {
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// start from a blank config if the file is corrupt
|
||||
}
|
||||
export async function syncGatewayTokenToConfig(token: string): Promise<void> {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
const gateway = (
|
||||
config.gateway && typeof config.gateway === 'object'
|
||||
@@ -738,31 +622,15 @@ export function syncGatewayTokenToConfig(token: string): void {
|
||||
if (!gateway.mode) gateway.mode = 'local';
|
||||
config.gateway = gateway;
|
||||
|
||||
const dir = join(configPath, '..');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
await writeOpenClawJson(config);
|
||||
console.log('Synced gateway token to openclaw.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure browser automation is enabled in ~/.openclaw/openclaw.json with the
|
||||
* "openclaw" managed profile as the default.
|
||||
*
|
||||
* Only sets values that are not already present so existing user
|
||||
* customisation (e.g. switching to a remote CDP profile) is preserved.
|
||||
* Ensure browser automation is enabled in ~/.openclaw/openclaw.json.
|
||||
*/
|
||||
export function syncBrowserConfigToOpenClaw(): void {
|
||||
const configPath = join(homedir(), '.openclaw', 'openclaw.json');
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// start from a blank config if the file is corrupt
|
||||
}
|
||||
export async function syncBrowserConfigToOpenClaw(): Promise<void> {
|
||||
const config = await readOpenClawJson();
|
||||
|
||||
const browser = (
|
||||
config.browser && typeof config.browser === 'object'
|
||||
@@ -785,29 +653,14 @@ export function syncBrowserConfigToOpenClaw(): void {
|
||||
if (!changed) return;
|
||||
|
||||
config.browser = browser;
|
||||
|
||||
const dir = join(configPath, '..');
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
||||
await writeOpenClawJson(config);
|
||||
console.log('Synced browser config to openclaw.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a provider entry in every discovered agent's models.json.
|
||||
*
|
||||
* The gateway caches resolved provider configs in
|
||||
* ~/.openclaw/agents/<id>/agent/models.json and serves requests from
|
||||
* that file (not from openclaw.json directly). We must update it
|
||||
* whenever the active provider changes so the gateway immediately picks
|
||||
* up the new baseUrl / apiKey without requiring a full restart.
|
||||
*
|
||||
* Existing model-level metadata (contextWindow, cost, etc.) is preserved
|
||||
* when the model ID matches; only the top-level provider fields and the
|
||||
* models list are updated.
|
||||
*/
|
||||
export function updateAgentModelProvider(
|
||||
export async function updateAgentModelProvider(
|
||||
providerType: string,
|
||||
entry: {
|
||||
baseUrl?: string;
|
||||
@@ -817,15 +670,13 @@ export function updateAgentModelProvider(
|
||||
/** When true, pi-ai sends Authorization: Bearer instead of x-api-key */
|
||||
authHeader?: boolean;
|
||||
}
|
||||
): void {
|
||||
const agentIds = discoverAgentIds();
|
||||
): Promise<void> {
|
||||
const agentIds = await discoverAgentIds();
|
||||
for (const agentId of agentIds) {
|
||||
const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json');
|
||||
let data: Record<string, unknown> = {};
|
||||
try {
|
||||
if (existsSync(modelsPath)) {
|
||||
data = JSON.parse(readFileSync(modelsPath, 'utf-8')) as Record<string, unknown>;
|
||||
}
|
||||
data = (await readJsonFile<Record<string, unknown>>(modelsPath)) ?? {};
|
||||
} catch {
|
||||
// corrupt / missing – start with an empty object
|
||||
}
|
||||
@@ -839,8 +690,6 @@ export function updateAgentModelProvider(
|
||||
? { ...providers[providerType] }
|
||||
: {};
|
||||
|
||||
// Preserve per-model metadata (reasoning, cost, contextWindow…) for
|
||||
// models that already exist; use a minimal stub for new models.
|
||||
const existingModels = Array.isArray(existing.models)
|
||||
? (existing.models as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
@@ -860,7 +709,7 @@ export function updateAgentModelProvider(
|
||||
data.providers = providers;
|
||||
|
||||
try {
|
||||
writeFileSync(modelsPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
await writeJsonFile(modelsPath, data);
|
||||
console.log(`Updated models.json for agent "${agentId}" provider "${providerType}"`);
|
||||
} catch (err) {
|
||||
console.warn(`Failed to update models.json for agent "${agentId}":`, err);
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, unlinkSync } from 'fs';
|
||||
/**
|
||||
* OpenClaw workspace context utilities.
|
||||
*
|
||||
* All file I/O is async (fs/promises) to avoid blocking the Electron
|
||||
* main thread.
|
||||
*/
|
||||
import { access, readFile, writeFile, readdir, mkdir, unlink } from 'fs/promises';
|
||||
import { constants, Dirent } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { logger } from './logger';
|
||||
@@ -7,6 +14,20 @@ import { getResourcesDir } from './paths';
|
||||
const CLAWX_BEGIN = '<!-- clawx:begin -->';
|
||||
const CLAWX_END = '<!-- clawx:end -->';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try { await access(p, constants.F_OK); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
async function ensureDir(dir: string): Promise<void> {
|
||||
if (!(await fileExists(dir))) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pure helpers (no I/O) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Merge a ClawX context section into an existing file's content.
|
||||
* If markers already exist, replaces the section in-place.
|
||||
@@ -22,62 +43,21 @@ export function mergeClawXSection(existing: string, section: string): string {
|
||||
return existing.trimEnd() + '\n\n' + wrapped + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect and remove bootstrap .md files that contain only ClawX markers
|
||||
* with no meaningful OpenClaw content outside them. This repairs a race
|
||||
* condition where ensureClawXContext() created the file before the gateway
|
||||
* could seed the full template. Deleting the hollow file lets the gateway
|
||||
* re-seed the complete template on next start.
|
||||
*/
|
||||
export function repairClawXOnlyBootstrapFiles(): void {
|
||||
const workspaceDirs = resolveAllWorkspaceDirs();
|
||||
for (const workspaceDir of workspaceDirs) {
|
||||
if (!existsSync(workspaceDir)) continue;
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(workspaceDir).filter((f) => f.endsWith('.md'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const file of entries) {
|
||||
const filePath = join(workspaceDir, file);
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const beginIdx = content.indexOf(CLAWX_BEGIN);
|
||||
const endIdx = content.indexOf(CLAWX_END);
|
||||
if (beginIdx === -1 || endIdx === -1) continue;
|
||||
|
||||
const before = content.slice(0, beginIdx).trim();
|
||||
const after = content.slice(endIdx + CLAWX_END.length).trim();
|
||||
if (before === '' && after === '') {
|
||||
try {
|
||||
unlinkSync(filePath);
|
||||
logger.info(`Removed ClawX-only bootstrap file for re-seeding: ${file} (${workspaceDir})`);
|
||||
} catch {
|
||||
logger.warn(`Failed to remove ClawX-only bootstrap file: ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// ── Workspace directory resolution ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Collect all unique workspace directories from the openclaw config:
|
||||
* the defaults workspace, each agent's workspace, and any workspace-*
|
||||
* directories that already exist under ~/.openclaw/.
|
||||
*/
|
||||
function resolveAllWorkspaceDirs(): string[] {
|
||||
async function resolveAllWorkspaceDirs(): Promise<string[]> {
|
||||
const openclawDir = join(homedir(), '.openclaw');
|
||||
const dirs = new Set<string>();
|
||||
|
||||
const configPath = join(openclawDir, 'openclaw.json');
|
||||
try {
|
||||
if (existsSync(configPath)) {
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
if (await fileExists(configPath)) {
|
||||
const config = JSON.parse(await readFile(configPath, 'utf-8'));
|
||||
|
||||
const defaultWs = config?.agents?.defaults?.workspace;
|
||||
if (typeof defaultWs === 'string' && defaultWs.trim()) {
|
||||
@@ -99,7 +79,8 @@ function resolveAllWorkspaceDirs(): string[] {
|
||||
}
|
||||
|
||||
try {
|
||||
for (const entry of readdirSync(openclawDir, { withFileTypes: true })) {
|
||||
const entries: Dirent[] = await readdir(openclawDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && entry.name.startsWith('workspace')) {
|
||||
dirs.add(join(openclawDir, entry.name));
|
||||
}
|
||||
@@ -115,49 +96,93 @@ function resolveAllWorkspaceDirs(): string[] {
|
||||
return [...dirs];
|
||||
}
|
||||
|
||||
// ── Bootstrap file repair ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Synchronously merge ClawX context snippets into workspace bootstrap
|
||||
* files that already exist on disk. Returns the number of target files
|
||||
* that were skipped because they don't exist yet.
|
||||
* Detect and remove bootstrap .md files that contain only ClawX markers
|
||||
* with no meaningful OpenClaw content outside them.
|
||||
*/
|
||||
function mergeClawXContextOnce(): number {
|
||||
export async function repairClawXOnlyBootstrapFiles(): Promise<void> {
|
||||
const workspaceDirs = await resolveAllWorkspaceDirs();
|
||||
for (const workspaceDir of workspaceDirs) {
|
||||
if (!(await fileExists(workspaceDir))) continue;
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = (await readdir(workspaceDir)).filter((f) => f.endsWith('.md'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of entries) {
|
||||
const filePath = join(workspaceDir, file);
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const beginIdx = content.indexOf(CLAWX_BEGIN);
|
||||
const endIdx = content.indexOf(CLAWX_END);
|
||||
if (beginIdx === -1 || endIdx === -1) continue;
|
||||
|
||||
const before = content.slice(0, beginIdx).trim();
|
||||
const after = content.slice(endIdx + CLAWX_END.length).trim();
|
||||
if (before === '' && after === '') {
|
||||
try {
|
||||
await unlink(filePath);
|
||||
logger.info(`Removed ClawX-only bootstrap file for re-seeding: ${file} (${workspaceDir})`);
|
||||
} catch {
|
||||
logger.warn(`Failed to remove ClawX-only bootstrap file: ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Context merging ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Merge ClawX context snippets into workspace bootstrap files that
|
||||
* already exist on disk. Returns the number of target files that were
|
||||
* skipped because they don't exist yet.
|
||||
*/
|
||||
async function mergeClawXContextOnce(): Promise<number> {
|
||||
const contextDir = join(getResourcesDir(), 'context');
|
||||
if (!existsSync(contextDir)) {
|
||||
if (!(await fileExists(contextDir))) {
|
||||
logger.debug('ClawX context directory not found, skipping context merge');
|
||||
return 0;
|
||||
}
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = readdirSync(contextDir).filter((f) => f.endsWith('.clawx.md'));
|
||||
files = (await readdir(contextDir)).filter((f) => f.endsWith('.clawx.md'));
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const workspaceDirs = resolveAllWorkspaceDirs();
|
||||
const workspaceDirs = await resolveAllWorkspaceDirs();
|
||||
let skipped = 0;
|
||||
|
||||
for (const workspaceDir of workspaceDirs) {
|
||||
if (!existsSync(workspaceDir)) {
|
||||
mkdirSync(workspaceDir, { recursive: true });
|
||||
}
|
||||
await ensureDir(workspaceDir);
|
||||
|
||||
for (const file of files) {
|
||||
const targetName = file.replace('.clawx.md', '.md');
|
||||
const targetPath = join(workspaceDir, targetName);
|
||||
|
||||
if (!existsSync(targetPath)) {
|
||||
if (!(await fileExists(targetPath))) {
|
||||
logger.debug(`Skipping ${targetName} in ${workspaceDir} (file does not exist yet, will be seeded by gateway)`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const section = readFileSync(join(contextDir, file), 'utf-8');
|
||||
const existing = readFileSync(targetPath, 'utf-8');
|
||||
const section = await readFile(join(contextDir, file), 'utf-8');
|
||||
const existing = await readFile(targetPath, 'utf-8');
|
||||
|
||||
const merged = mergeClawXSection(existing, section);
|
||||
if (merged !== existing) {
|
||||
writeFileSync(targetPath, merged, 'utf-8');
|
||||
await writeFile(targetPath, merged, 'utf-8');
|
||||
logger.info(`Merged ClawX context into ${targetName} (${workspaceDir})`);
|
||||
}
|
||||
}
|
||||
@@ -171,22 +196,15 @@ const MAX_RETRIES = 15;
|
||||
|
||||
/**
|
||||
* Ensure ClawX context snippets are merged into the openclaw workspace
|
||||
* bootstrap files. Reads `*.clawx.md` templates from resources/context/
|
||||
* and injects them as marker-delimited sections into the corresponding
|
||||
* workspace `.md` files (e.g. AGENTS.clawx.md -> AGENTS.md).
|
||||
*
|
||||
* The gateway seeds workspace files asynchronously after its HTTP server
|
||||
* starts, so the target files may not exist yet when this is first called.
|
||||
* When files are missing, retries with a delay until all targets are merged
|
||||
* or the retry budget is exhausted.
|
||||
* bootstrap files.
|
||||
*/
|
||||
export async function ensureClawXContext(): Promise<void> {
|
||||
let skipped = mergeClawXContextOnce();
|
||||
let skipped = await mergeClawXContextOnce();
|
||||
if (skipped === 0) return;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
await new Promise((r) => setTimeout(r, RETRY_INTERVAL_MS));
|
||||
skipped = mergeClawXContextOnce();
|
||||
skipped = await mergeClawXContextOnce();
|
||||
if (skipped === 0) {
|
||||
logger.info(`ClawX context merge completed after ${attempt} retry(ies)`);
|
||||
return;
|
||||
|
||||
@@ -214,7 +214,7 @@ export async function getAllProvidersWithKeyInfo(): Promise<
|
||||
> {
|
||||
const providers = await getAllProviders();
|
||||
const results: Array<ProviderConfig & { hasKey: boolean; keyMasked: string | null }> = [];
|
||||
const activeOpenClawProviders = getActiveOpenClawProviders();
|
||||
const activeOpenClawProviders = await getActiveOpenClawProviders();
|
||||
|
||||
// We need to avoid deleting native ones like 'anthropic' or 'google'
|
||||
// that don't need to exist in openclaw.json models.providers
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* Skill Config Utilities
|
||||
* Direct read/write access to skill configuration in ~/.openclaw/openclaw.json
|
||||
* This bypasses the Gateway RPC for faster and more reliable config updates
|
||||
* This bypasses the Gateway RPC for faster and more reliable config updates.
|
||||
*
|
||||
* All file I/O uses async fs/promises to avoid blocking the main thread.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { readFile, writeFile, access } from 'fs/promises';
|
||||
import { constants } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
@@ -23,15 +26,19 @@ interface OpenClawConfig {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function fileExists(p: string): Promise<boolean> {
|
||||
try { await access(p, constants.F_OK); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current OpenClaw config
|
||||
*/
|
||||
function readConfig(): OpenClawConfig {
|
||||
if (!existsSync(OPENCLAW_CONFIG_PATH)) {
|
||||
async function readConfig(): Promise<OpenClawConfig> {
|
||||
if (!(await fileExists(OPENCLAW_CONFIG_PATH))) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(OPENCLAW_CONFIG_PATH, 'utf-8');
|
||||
const raw = await readFile(OPENCLAW_CONFIG_PATH, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error('Failed to read openclaw config:', err);
|
||||
@@ -42,28 +49,28 @@ function readConfig(): OpenClawConfig {
|
||||
/**
|
||||
* Write the OpenClaw config
|
||||
*/
|
||||
function writeConfig(config: OpenClawConfig): void {
|
||||
async function writeConfig(config: OpenClawConfig): Promise<void> {
|
||||
const json = JSON.stringify(config, null, 2);
|
||||
writeFileSync(OPENCLAW_CONFIG_PATH, json, 'utf-8');
|
||||
await writeFile(OPENCLAW_CONFIG_PATH, json, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skill config
|
||||
*/
|
||||
export function getSkillConfig(skillKey: string): SkillEntry | undefined {
|
||||
const config = readConfig();
|
||||
export async function getSkillConfig(skillKey: string): Promise<SkillEntry | undefined> {
|
||||
const config = await readConfig();
|
||||
return config.skills?.entries?.[skillKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update skill config (apiKey and env)
|
||||
*/
|
||||
export function updateSkillConfig(
|
||||
export async function updateSkillConfig(
|
||||
skillKey: string,
|
||||
updates: { apiKey?: string; env?: Record<string, string> }
|
||||
): { success: boolean; error?: string } {
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const config = readConfig();
|
||||
const config = await readConfig();
|
||||
|
||||
// Ensure skills.entries exists
|
||||
if (!config.skills) {
|
||||
@@ -90,7 +97,6 @@ export function updateSkillConfig(
|
||||
if (updates.env !== undefined) {
|
||||
const newEnv: Record<string, string> = {};
|
||||
|
||||
// Process all keys from the update
|
||||
for (const [key, value] of Object.entries(updates.env)) {
|
||||
const trimmedKey = key.trim();
|
||||
if (!trimmedKey) continue;
|
||||
@@ -99,10 +105,8 @@ export function updateSkillConfig(
|
||||
if (trimmedVal) {
|
||||
newEnv[trimmedKey] = trimmedVal;
|
||||
}
|
||||
// Empty value = don't include (delete)
|
||||
}
|
||||
|
||||
// Only set env if there are values, otherwise delete
|
||||
if (Object.keys(newEnv).length > 0) {
|
||||
entry.env = newEnv;
|
||||
} else {
|
||||
@@ -113,7 +117,7 @@ export function updateSkillConfig(
|
||||
// Save entry back
|
||||
config.skills.entries[skillKey] = entry;
|
||||
|
||||
writeConfig(config);
|
||||
await writeConfig(config);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Failed to update skill config:', err);
|
||||
@@ -124,7 +128,7 @@ export function updateSkillConfig(
|
||||
/**
|
||||
* Get all skill configs (for syncing to frontend)
|
||||
*/
|
||||
export function getAllSkillConfigs(): Record<string, SkillEntry> {
|
||||
const config = readConfig();
|
||||
export async function getAllSkillConfigs(): Promise<Record<string, SkillEntry>> {
|
||||
const config = await readConfig();
|
||||
return config.skills?.entries || {};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user