diff --git a/electron/utils/agent-config.ts b/electron/utils/agent-config.ts index 29a05515b..fa2bd5d63 100644 --- a/electron/utils/agent-config.ts +++ b/electron/utils/agent-config.ts @@ -1,6 +1,6 @@ -import { access, copyFile, mkdir, readdir } from 'fs/promises'; +import { access, copyFile, mkdir, readdir, rm } from 'fs/promises'; import { constants } from 'fs'; -import { join } from 'path'; +import { join, normalize } from 'path'; import { listConfiguredChannels, readOpenClawConfig, writeOpenClawConfig } from './channel-config'; import { expandPath, getOpenClawConfigDir } from './paths'; import * as logger from './logger'; @@ -208,7 +208,8 @@ function getSimpleChannelBindingMap(bindings: unknown): Map { for (const binding of bindings) { if (!isSimpleChannelBinding(binding)) continue; const agentId = normalizeAgentIdForBinding(binding.agentId!); - if (agentId) owners.set(binding.match.channel!, agentId); + const channel = binding.match?.channel; + if (agentId && channel) owners.set(channel, agentId); } return owners; @@ -221,7 +222,7 @@ function upsertBindingsForChannel( ): BindingConfig[] | undefined { const nextBindings = Array.isArray(bindings) ? [...bindings as BindingConfig[]].filter((binding) => !( - isSimpleChannelBinding(binding) && binding.match.channel === channelType + isSimpleChannelBinding(binding) && binding.match?.channel === channelType )) : []; @@ -252,6 +253,55 @@ async function listExistingAgentIdsOnDisk(): Promise> { return ids; } +async function removeAgentRuntimeDirectory(agentId: string): Promise { + const runtimeDir = join(getOpenClawConfigDir(), 'agents', agentId); + try { + await rm(runtimeDir, { recursive: true, force: true }); + } catch (error) { + logger.warn('Failed to remove agent runtime directory', { + agentId, + runtimeDir, + error: String(error), + }); + } +} + +function trimTrailingSeparators(path: string): string { + return path.replace(/[\\/]+$/, ''); +} + +function getManagedWorkspaceDirectory(agent: AgentListEntry): string | null { + if (agent.id === MAIN_AGENT_ID) return null; + + const configuredWorkspace = expandPath(agent.workspace || `~/.openclaw/workspace-${agent.id}`); + const managedWorkspace = join(getOpenClawConfigDir(), `workspace-${agent.id}`); + const normalizedConfigured = trimTrailingSeparators(normalize(configuredWorkspace)); + const normalizedManaged = trimTrailingSeparators(normalize(managedWorkspace)); + + return normalizedConfigured === normalizedManaged ? configuredWorkspace : null; +} + +async function removeAgentWorkspaceDirectory(agent: AgentListEntry): Promise { + const workspaceDir = getManagedWorkspaceDirectory(agent); + if (!workspaceDir) { + logger.warn('Skipping agent workspace deletion for unmanaged path', { + agentId: agent.id, + workspace: agent.workspace, + }); + return; + } + + try { + await rm(workspaceDir, { recursive: true, force: true }); + } catch (error) { + logger.warn('Failed to remove agent workspace directory', { + agentId: agent.id, + workspaceDir, + error: String(error), + }); + } +} + async function copyBootstrapFiles(sourceWorkspace: string, targetWorkspace: string): Promise { await ensureDir(targetWorkspace); @@ -336,6 +386,13 @@ export async function listAgentsSnapshot(): Promise { return buildSnapshotFromConfig(config); } +export async function listConfiguredAgentIds(): Promise { + const config = await readOpenClawConfig() as AgentConfigDocument; + const { entries } = normalizeAgentsConfig(config); + const ids = [...new Set(entries.map((entry) => entry.id.trim()).filter(Boolean))]; + return ids.length > 0 ? ids : [MAIN_AGENT_ID]; +} + export async function createAgent(name: string): Promise { const config = await readOpenClawConfig() as AgentConfigDocument; const { agentsConfig, entries, syntheticMain } = normalizeAgentsConfig(config); @@ -350,7 +407,7 @@ export async function createAgent(name: string): Promise { suffix += 1; } - const nextEntries = syntheticMain ? [createImplicitMainEntry(config), ...entries.filter((entry, index) => index > 0)] : [...entries]; + const nextEntries = syntheticMain ? [createImplicitMainEntry(config), ...entries.filter((_, index) => index > 0)] : [...entries]; const newAgent: AgentListEntry = { id: nextId, name: normalizedName, @@ -405,8 +462,9 @@ export async function deleteAgentConfig(agentId: string): Promise entry.id === agentId); const nextEntries = entries.filter((entry) => entry.id !== agentId); - if (nextEntries.length === entries.length) { + if (!removedEntry || nextEntries.length === entries.length) { throw new Error(`Agent "${agentId}" not found`); } @@ -426,6 +484,8 @@ export async function deleteAgentConfig(agentId: string): Promise { const agentsDir = join(homedir(), '.openclaw', 'agents'); try { 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']; + return await listConfiguredAgentIds(); } catch { return ['main']; } diff --git a/electron/utils/token-usage.ts b/electron/utils/token-usage.ts index 9a29132f7..6dcc84e16 100644 --- a/electron/utils/token-usage.ts +++ b/electron/utils/token-usage.ts @@ -2,6 +2,7 @@ import { readdir, readFile, stat } from 'fs/promises'; import { join } from 'path'; import { getOpenClawConfigDir } from './paths'; import { logger } from './logger'; +import { listConfiguredAgentIds } from './agent-config'; import { parseUsageEntriesFromJsonl, type TokenUsageHistoryEntry } from './token-usage-core'; export { parseUsageEntriesFromJsonl, type TokenUsageHistoryEntry } from './token-usage-core'; @@ -11,7 +12,7 @@ async function listRecentSessionFiles(): Promise = []; for (const agentId of agentEntries) { diff --git a/src/i18n/locales/en/agents.json b/src/i18n/locales/en/agents.json index dfb09b4f4..4f631ea20 100644 --- a/src/i18n/locales/en/agents.json +++ b/src/i18n/locales/en/agents.json @@ -20,7 +20,7 @@ }, "deleteDialog": { "title": "Delete Agent", - "message": "Delete \"{{name}}\" from ClawX? Existing workspace and session files will be left on disk." + "message": "Delete \"{{name}}\" from ClawX? This permanently deletes the agent and its ClawX-managed workspace, runtime, and session files from disk." }, "settingsDialog": { "title": "{{name}} Settings", diff --git a/src/i18n/locales/ja/agents.json b/src/i18n/locales/ja/agents.json index 8ed8230ba..7c57b7a82 100644 --- a/src/i18n/locales/ja/agents.json +++ b/src/i18n/locales/ja/agents.json @@ -20,7 +20,7 @@ }, "deleteDialog": { "title": "Agent を削除", - "message": "ClawX から「{{name}}」を削除しますか?既存のワークスペースとセッションファイルはディスク上に残ります。" + "message": "ClawX から「{{name}}」を削除しますか?この操作により Agent と、その ClawX 管理下のワークスペース、実行時ファイル、セッションファイルがディスクから完全に削除されます。" }, "settingsDialog": { "title": "{{name}} Settings", diff --git a/src/i18n/locales/zh/agents.json b/src/i18n/locales/zh/agents.json index 44a53e57f..99a7ff501 100644 --- a/src/i18n/locales/zh/agents.json +++ b/src/i18n/locales/zh/agents.json @@ -20,7 +20,7 @@ }, "deleteDialog": { "title": "删除 Agent", - "message": "确认从 ClawX 删除 “{{name}}”?已有的工作区和会话文件会保留在磁盘上。" + "message": "确认从 ClawX 删除 “{{name}}”?这会永久删除该 Agent 及其由 ClawX 管理的工作区、运行时和会话文件。" }, "settingsDialog": { "title": "{{name}} 设置", diff --git a/tests/unit/agent-config.test.ts b/tests/unit/agent-config.test.ts new file mode 100644 index 000000000..1efd8439e --- /dev/null +++ b/tests/unit/agent-config.test.ts @@ -0,0 +1,191 @@ +import { access, mkdir, readFile, rm, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { testHome, testUserData } = vi.hoisted(() => { + const suffix = Math.random().toString(36).slice(2); + return { + testHome: `/tmp/clawx-agent-config-${suffix}`, + testUserData: `/tmp/clawx-agent-config-user-data-${suffix}`, + }; +}); + +vi.mock('os', async () => { + const actual = await vi.importActual('os'); + const mocked = { + ...actual, + homedir: () => testHome, + }; + return { + ...mocked, + default: mocked, + }; +}); + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => testUserData, + getVersion: () => '0.0.0-test', + }, +})); + +async function writeOpenClawJson(config: unknown): Promise { + const openclawDir = join(testHome, '.openclaw'); + await mkdir(openclawDir, { recursive: true }); + await writeFile(join(openclawDir, 'openclaw.json'), JSON.stringify(config, null, 2), 'utf8'); +} + +async function readOpenClawJson(): Promise> { + const content = await readFile(join(testHome, '.openclaw', 'openclaw.json'), 'utf8'); + return JSON.parse(content) as Record; +} + +describe('agent config lifecycle', () => { + beforeEach(async () => { + vi.resetModules(); + vi.restoreAllMocks(); + await rm(testHome, { recursive: true, force: true }); + await rm(testUserData, { recursive: true, force: true }); + }); + + it('lists configured agent ids from openclaw.json', async () => { + await writeOpenClawJson({ + agents: { + list: [ + { id: 'main', name: 'Main', default: true }, + { id: 'test3', name: 'test3' }, + ], + }, + }); + + const { listConfiguredAgentIds } = await import('@electron/utils/agent-config'); + + await expect(listConfiguredAgentIds()).resolves.toEqual(['main', 'test3']); + }); + + it('falls back to the implicit main agent when no list exists', async () => { + await writeOpenClawJson({}); + + const { listConfiguredAgentIds } = await import('@electron/utils/agent-config'); + + await expect(listConfiguredAgentIds()).resolves.toEqual(['main']); + }); + + it('deletes the config entry, bindings, runtime directory, and managed workspace for a removed agent', async () => { + await writeOpenClawJson({ + agents: { + defaults: { + model: { + primary: 'custom-custom27/MiniMax-M2.5', + fallbacks: [], + }, + }, + list: [ + { + id: 'main', + name: 'Main', + default: true, + workspace: '~/.openclaw/workspace', + agentDir: '~/.openclaw/agents/main/agent', + }, + { + id: 'test2', + name: 'test2', + workspace: '~/.openclaw/workspace-test2', + agentDir: '~/.openclaw/agents/test2/agent', + }, + { + id: 'test3', + name: 'test3', + workspace: '~/.openclaw/workspace-test3', + agentDir: '~/.openclaw/agents/test3/agent', + }, + ], + }, + channels: { + feishu: { + enabled: true, + }, + }, + bindings: [ + { + agentId: 'test2', + match: { + channel: 'feishu', + }, + }, + ], + }); + + const test2RuntimeDir = join(testHome, '.openclaw', 'agents', 'test2'); + const test2WorkspaceDir = join(testHome, '.openclaw', 'workspace-test2'); + await mkdir(join(test2RuntimeDir, 'agent'), { recursive: true }); + await mkdir(join(test2RuntimeDir, 'sessions'), { recursive: true }); + await mkdir(join(test2WorkspaceDir, '.openclaw'), { recursive: true }); + await writeFile( + join(test2RuntimeDir, 'agent', 'auth-profiles.json'), + JSON.stringify({ version: 1, profiles: {} }, null, 2), + 'utf8', + ); + await writeFile(join(test2WorkspaceDir, 'AGENTS.md'), '# test2', 'utf8'); + + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + const { deleteAgentConfig } = await import('@electron/utils/agent-config'); + + const snapshot = await deleteAgentConfig('test2'); + + expect(snapshot.agents.map((agent) => agent.id)).toEqual(['main', 'test3']); + expect(snapshot.channelOwners.feishu).toBe('main'); + + const config = await readOpenClawJson(); + expect((config.agents as { list: Array<{ id: string }> }).list.map((agent) => agent.id)).toEqual([ + 'main', + 'test3', + ]); + expect(config.bindings).toEqual([]); + await expect(access(test2RuntimeDir)).rejects.toThrow(); + await expect(access(test2WorkspaceDir)).rejects.toThrow(); + + infoSpy.mockRestore(); + }); + + it('preserves unmanaged custom workspaces when deleting an agent', async () => { + const customWorkspaceDir = join(testHome, 'custom-workspace-test2'); + + await writeOpenClawJson({ + agents: { + list: [ + { + id: 'main', + name: 'Main', + default: true, + workspace: '~/.openclaw/workspace', + agentDir: '~/.openclaw/agents/main/agent', + }, + { + id: 'test2', + name: 'test2', + workspace: customWorkspaceDir, + agentDir: '~/.openclaw/agents/test2/agent', + }, + ], + }, + }); + + await mkdir(join(testHome, '.openclaw', 'agents', 'test2', 'agent'), { recursive: true }); + await mkdir(customWorkspaceDir, { recursive: true }); + await writeFile(join(customWorkspaceDir, 'AGENTS.md'), '# custom', 'utf8'); + + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); + const { deleteAgentConfig } = await import('@electron/utils/agent-config'); + + await deleteAgentConfig('test2'); + + await expect(access(customWorkspaceDir)).resolves.toBeUndefined(); + + warnSpy.mockRestore(); + infoSpy.mockRestore(); + }); +}); diff --git a/tests/unit/openclaw-auth.test.ts b/tests/unit/openclaw-auth.test.ts new file mode 100644 index 000000000..cc1bd20fe --- /dev/null +++ b/tests/unit/openclaw-auth.test.ts @@ -0,0 +1,113 @@ +import { mkdir, readFile, rm, writeFile } from 'fs/promises'; +import { join } from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { testHome, testUserData } = vi.hoisted(() => { + const suffix = Math.random().toString(36).slice(2); + return { + testHome: `/tmp/clawx-openclaw-auth-${suffix}`, + testUserData: `/tmp/clawx-openclaw-auth-user-data-${suffix}`, + }; +}); + +vi.mock('os', async () => { + const actual = await vi.importActual('os'); + const mocked = { + ...actual, + homedir: () => testHome, + }; + return { + ...mocked, + default: mocked, + }; +}); + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => testUserData, + getVersion: () => '0.0.0-test', + }, +})); + +async function writeOpenClawJson(config: unknown): Promise { + const openclawDir = join(testHome, '.openclaw'); + await mkdir(openclawDir, { recursive: true }); + await writeFile(join(openclawDir, 'openclaw.json'), JSON.stringify(config, null, 2), 'utf8'); +} + +async function readAuthProfiles(agentId: string): Promise> { + const content = await readFile(join(testHome, '.openclaw', 'agents', agentId, 'agent', 'auth-profiles.json'), 'utf8'); + return JSON.parse(content) as Record; +} + +describe('saveProviderKeyToOpenClaw', () => { + beforeEach(async () => { + vi.resetModules(); + vi.restoreAllMocks(); + await rm(testHome, { recursive: true, force: true }); + await rm(testUserData, { recursive: true, force: true }); + }); + + it('only syncs auth profiles for configured agents', async () => { + await writeOpenClawJson({ + agents: { + list: [ + { + id: 'main', + name: 'Main', + default: true, + workspace: '~/.openclaw/workspace', + agentDir: '~/.openclaw/agents/main/agent', + }, + { + id: 'test3', + name: 'test3', + workspace: '~/.openclaw/workspace-test3', + agentDir: '~/.openclaw/agents/test3/agent', + }, + ], + }, + }); + + await mkdir(join(testHome, '.openclaw', 'agents', 'test2', 'agent'), { recursive: true }); + await writeFile( + join(testHome, '.openclaw', 'agents', 'test2', 'agent', 'auth-profiles.json'), + JSON.stringify({ + version: 1, + profiles: { + 'legacy:default': { + type: 'api_key', + provider: 'legacy', + key: 'legacy-key', + }, + }, + }, null, 2), + 'utf8', + ); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { saveProviderKeyToOpenClaw } = await import('@electron/utils/openclaw-auth'); + + await saveProviderKeyToOpenClaw('openrouter', 'sk-test'); + + const mainProfiles = await readAuthProfiles('main'); + const test3Profiles = await readAuthProfiles('test3'); + const staleProfiles = await readAuthProfiles('test2'); + + expect((mainProfiles.profiles as Record)['openrouter:default'].key).toBe('sk-test'); + expect((test3Profiles.profiles as Record)['openrouter:default'].key).toBe('sk-test'); + expect(staleProfiles.profiles).toEqual({ + 'legacy:default': { + type: 'api_key', + provider: 'legacy', + key: 'legacy-key', + }, + }); + expect(logSpy).toHaveBeenCalledWith( + 'Saved API key for provider "openrouter" to OpenClaw auth-profiles (agents: main, test3)', + ); + + logSpy.mockRestore(); + }); +});