diff --git a/electron/gateway/manager.ts b/electron/gateway/manager.ts index 94507457..c4ae930f 100644 --- a/electron/gateway/manager.ts +++ b/electron/gateway/manager.ts @@ -11,6 +11,8 @@ import { PORTS } from '../utils/config'; import { getOpenClawDir, getOpenClawEntryPath, + getOpenClawConfigDir, + isOpenClawBuilt, isOpenClawPresent, appendNodeRequireToNodeOptions, } from '../utils/paths'; @@ -1054,6 +1056,7 @@ export class GatewayManager extends EventEmitter { ...process.env, PATH: finalPath, ...uvEnv, + OPENCLAW_STATE_DIR: getOpenClawConfigDir(), OPENCLAW_NO_RESPAWN: '1', }; @@ -1299,6 +1302,7 @@ export class GatewayManager extends EventEmitter { ...uvEnv, ...proxyEnv, OPENCLAW_GATEWAY_TOKEN: gatewayToken, + OPENCLAW_STATE_DIR: getOpenClawConfigDir(), OPENCLAW_SKIP_CHANNELS: '', CLAWDBOT_SKIP_CHANNELS: '', // Prevent OpenClaw from respawning itself inside the utility process diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 4436c7ba..4802f697 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -1493,7 +1493,7 @@ function registerGatewayHandlers( */ function registerOpenClawHandlers(gatewayManager: GatewayManager): void { async function ensureDingTalkPluginInstalled(): Promise<{ installed: boolean; warning?: string }> { - const targetDir = join(homedir(), '.openclaw', 'extensions', 'dingtalk'); + const targetDir = join(getOpenClawConfigDir(), 'extensions', 'dingtalk'); const targetManifest = join(targetDir, 'openclaw.plugin.json'); if (existsSync(targetManifest)) { @@ -1523,7 +1523,7 @@ function registerOpenClawHandlers(gatewayManager: GatewayManager): void { } try { - mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true }); + mkdirSync(join(getOpenClawConfigDir(), 'extensions'), { recursive: true }); rmSync(targetDir, { recursive: true, force: true }); cpSync(sourceDir, targetDir, { recursive: true, dereference: true }); @@ -2782,7 +2782,7 @@ function mimeToExt(mimeType: string): string { return ''; } -const OUTBOUND_DIR = join(homedir(), '.openclaw', 'media', 'outbound'); +const OUTBOUND_DIR = join(getOpenClawConfigDir(), 'media', 'outbound'); /** * Generate a preview data URL for image files. diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index f9a335f0..898dca80 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -7,12 +7,11 @@ 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'; +import { getOpenClawResolvedDir, getOpenClawConfigDir } from './paths'; import * as logger from './logger'; import { proxyAwareFetch } from './proxy-fetch'; -const OPENCLAW_DIR = join(homedir(), '.openclaw'); +const OPENCLAW_DIR = getOpenClawConfigDir(); const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json'); // Channels that are managed as plugins (config goes under plugins.entries, not channels) @@ -316,7 +315,7 @@ export async function deleteChannelConfig(channelType: string): Promise { // Special handling for WhatsApp credentials if (channelType === 'whatsapp') { try { - const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp'); + const whatsappDir = join(getOpenClawConfigDir(), 'credentials', 'whatsapp'); if (await fileExists(whatsappDir)) { await rm(whatsappDir, { recursive: true, force: true }); console.log('Deleted WhatsApp credentials directory'); @@ -339,7 +338,7 @@ export async function listConfiguredChannels(): Promise { // Check for WhatsApp credentials directory try { - const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp'); + const whatsappDir = join(getOpenClawConfigDir(), 'credentials', 'whatsapp'); if (await fileExists(whatsappDir)) { const entries = await readdir(whatsappDir); const hasSession = await (async () => { diff --git a/electron/utils/config.ts b/electron/utils/config.ts index 0e837c0c..2cadd3ba 100644 --- a/electron/utils/config.ts +++ b/electron/utils/config.ts @@ -30,8 +30,8 @@ export function getPort(key: keyof typeof PORTS): number { * Application paths */ export const APP_PATHS = { - /** OpenClaw configuration directory */ - OPENCLAW_CONFIG: '~/.openclaw', + /** OpenClaw configuration directory (isolated from system-wide ~/.openclaw) */ + OPENCLAW_CONFIG: '~/.clawx/openclaw', /** ClawX configuration directory */ CLAWX_CONFIG: '~/.clawx', diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index fd887783..f16a31c5 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -11,12 +11,12 @@ import { access, mkdir, readFile, writeFile, readdir } from 'fs/promises'; import { constants, Dirent } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; import { getProviderEnvVar, getProviderDefaultModel, getProviderConfig, } from './provider-registry'; +import { getOpenClawConfigDir } from './paths'; import { OPENCLAW_PROVIDER_KEY_MOONSHOT, isOAuthProviderType, @@ -88,7 +88,7 @@ interface AuthProfilesStore { // ── Auth Profiles I/O ──────────────────────────────────────────── function getAuthProfilesPath(agentId = 'main'): string { - return join(homedir(), '.openclaw', 'agents', agentId, 'agent', AUTH_PROFILE_FILENAME); + return join(getOpenClawConfigDir(), 'agents', agentId, 'agent', AUTH_PROFILE_FILENAME); } async function readAuthProfiles(agentId = 'main'): Promise { @@ -111,7 +111,7 @@ async function writeAuthProfiles(store: AuthProfilesStore, agentId = 'main'): Pr // ── Agent Discovery ────────────────────────────────────────────── async function discoverAgentIds(): Promise { - const agentsDir = join(homedir(), '.openclaw', 'agents'); + const agentsDir = join(getOpenClawConfigDir(), 'agents'); try { if (!(await fileExists(agentsDir))) return ['main']; const entries: Dirent[] = await readdir(agentsDir, { withFileTypes: true }); @@ -129,7 +129,7 @@ async function discoverAgentIds(): Promise { // ── OpenClaw Config Helpers ────────────────────────────────────── -const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); +const OPENCLAW_CONFIG_PATH = join(getOpenClawConfigDir(), 'openclaw.json'); async function readOpenClawJson(): Promise> { return (await readJsonFile>(OPENCLAW_CONFIG_PATH)) ?? {}; @@ -302,7 +302,7 @@ export async function removeProviderFromOpenClaw(provider: string): Promise { const agentIds = await discoverAgentIds(); for (const agentId of agentIds) { - const modelsPath = join(homedir(), '.openclaw', 'agents', agentId, 'agent', 'models.json'); + const modelsPath = join(getOpenClawConfigDir(), 'agents', agentId, 'agent', 'models.json'); let data: Record = {}; try { data = (await readJsonFile>(modelsPath)) ?? {}; diff --git a/electron/utils/openclaw-workspace.ts b/electron/utils/openclaw-workspace.ts index 673dee1c..2ec44b91 100644 --- a/electron/utils/openclaw-workspace.ts +++ b/electron/utils/openclaw-workspace.ts @@ -9,7 +9,7 @@ import { constants, Dirent } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; import { logger } from './logger'; -import { getResourcesDir } from './paths'; +import { getResourcesDir, getOpenClawConfigDir } from './paths'; const CLAWX_BEGIN = ''; const CLAWX_END = ''; @@ -51,7 +51,7 @@ export function mergeClawXSection(existing: string, section: string): string { * directories that already exist under ~/.openclaw/. */ async function resolveAllWorkspaceDirs(): Promise { - const openclawDir = join(homedir(), '.openclaw'); + const openclawDir = getOpenClawConfigDir(); const dirs = new Set(); const configPath = join(openclawDir, 'openclaw.json'); diff --git a/electron/utils/paths.ts b/electron/utils/paths.ts index 47173ef1..5bada90c 100644 --- a/electron/utils/paths.ts +++ b/electron/utils/paths.ts @@ -27,10 +27,14 @@ export function expandPath(path: string): string { } /** - * Get OpenClaw config directory + * Get OpenClaw config directory. + * + * Uses a ClawX-specific directory (~/.clawx/openclaw) so that the + * embedded Gateway's config does not collide with a system-wide + * OpenClaw CLI installation at ~/.openclaw. */ export function getOpenClawConfigDir(): string { - return join(homedir(), '.openclaw'); + return join(homedir(), '.clawx', 'openclaw'); } /** diff --git a/electron/utils/skill-config.ts b/electron/utils/skill-config.ts index 015f4f4f..23845d94 100644 --- a/electron/utils/skill-config.ts +++ b/electron/utils/skill-config.ts @@ -9,11 +9,10 @@ import { readFile, writeFile, access, cp, mkdir } from 'fs/promises'; import { existsSync } from 'fs'; import { constants } from 'fs'; import { join } from 'path'; -import { homedir } from 'os'; -import { getOpenClawDir } from './paths'; +import { getOpenClawDir, getOpenClawConfigDir } from './paths'; import { logger } from './logger'; -const OPENCLAW_CONFIG_PATH = join(homedir(), '.openclaw', 'openclaw.json'); +const OPENCLAW_CONFIG_PATH = join(getOpenClawConfigDir(), 'openclaw.json'); interface SkillEntry { enabled?: boolean; @@ -155,7 +154,7 @@ const BUILTIN_SKILLS = [ * block the normal startup flow. */ export async function ensureBuiltinSkillsInstalled(): Promise { - const skillsRoot = join(homedir(), '.openclaw', 'skills'); + const skillsRoot = join(getOpenClawConfigDir(), 'skills'); for (const { slug, sourceExtension } of BUILTIN_SKILLS) { const targetDir = join(skillsRoot, slug); diff --git a/electron/utils/whatsapp-login.ts b/electron/utils/whatsapp-login.ts index 1ab83194..25f39564 100644 --- a/electron/utils/whatsapp-login.ts +++ b/electron/utils/whatsapp-login.ts @@ -1,10 +1,9 @@ import { dirname, join } from 'path'; -import { homedir } from 'os'; import { createRequire } from 'module'; import { EventEmitter } from 'events'; import { existsSync, mkdirSync, rmSync } from 'fs'; import { deflateSync } from 'zlib'; -import { getOpenClawDir, getOpenClawResolvedDir } from './paths'; +import { getOpenClawDir, getOpenClawResolvedDir, getOpenClawConfigDir } from './paths'; const require = createRequire(import.meta.url); @@ -232,7 +231,7 @@ export class WhatsAppLoginManager extends EventEmitter { try { // Path where OpenClaw expects WhatsApp credentials - const authDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp', accountId); + const authDir = join(getOpenClawConfigDir(), 'credentials', 'whatsapp', accountId); // Ensure directory exists if (!existsSync(authDir)) { diff --git a/tests/unit/config-isolation.test.ts b/tests/unit/config-isolation.test.ts new file mode 100644 index 00000000..fb0df2a8 --- /dev/null +++ b/tests/unit/config-isolation.test.ts @@ -0,0 +1,54 @@ +/** + * Tests for OpenClaw config path isolation. + * + * Verifies that ClawX uses ~/.clawx/openclaw instead of ~/.openclaw + * for all OpenClaw configuration, ensuring no conflict with a + * system-wide OpenClaw CLI installation. + */ +import { describe, it, expect, vi } from 'vitest'; +import { join } from 'path'; + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: (name: string) => { + if (name === 'userData') return '/tmp/clawx-test-userdata'; + return '/tmp/clawx-test'; + }, + getAppPath: () => '/tmp/clawx-test-app', + getName: () => 'ClawX', + }, +})); + +describe('OpenClaw config path isolation', () => { + it('getOpenClawConfigDir() returns ~/.clawx/openclaw instead of ~/.openclaw', async () => { + const { getOpenClawConfigDir } = await import('@electron/utils/paths'); + const os = await import('os'); + const expected = join(os.homedir(), '.clawx', 'openclaw'); + expect(getOpenClawConfigDir()).toBe(expected); + }); + + it('getOpenClawConfigDir() does NOT return ~/.openclaw', async () => { + const { getOpenClawConfigDir } = await import('@electron/utils/paths'); + const os = await import('os'); + const systemPath = join(os.homedir(), '.openclaw'); + expect(getOpenClawConfigDir()).not.toBe(systemPath); + }); + + it('getOpenClawSkillsDir() is under the isolated config dir', async () => { + const { getOpenClawConfigDir, getOpenClawSkillsDir } = await import('@electron/utils/paths'); + expect(getOpenClawSkillsDir()).toBe(join(getOpenClawConfigDir(), 'skills')); + }); + + it('getClawXConfigDir() returns ~/.clawx (parent of isolated openclaw config)', async () => { + const { getClawXConfigDir } = await import('@electron/utils/paths'); + const os = await import('os'); + expect(getClawXConfigDir()).toBe(join(os.homedir(), '.clawx')); + }); + + it('APP_PATHS.OPENCLAW_CONFIG points to the isolated path', async () => { + const { APP_PATHS } = await import('@electron/utils/config'); + expect(APP_PATHS.OPENCLAW_CONFIG).toBe('~/.clawx/openclaw'); + expect(APP_PATHS.OPENCLAW_CONFIG).not.toBe('~/.openclaw'); + }); +});