diff --git a/src/main/ipc/extensions.ts b/src/main/ipc/extensions.ts index 7577c112..3a3cccc0 100644 --- a/src/main/ipc/extensions.ts +++ b/src/main/ipc/extensions.ts @@ -421,11 +421,15 @@ async function handleApiKeysDelete( async function handleApiKeysLookup( _event: IpcMainInvokeEvent, - envVarNames?: string[] + envVarNames?: string[], + projectPath?: string ): Promise> { return wrapHandler('apiKeysLookup', () => { if (!Array.isArray(envVarNames)) throw new Error('envVarNames array is required'); - return getApiKeyService().lookup(envVarNames); + return getApiKeyService().lookup( + envVarNames, + typeof projectPath === 'string' ? projectPath : undefined + ); }); } diff --git a/src/main/services/extensions/apikeys/ApiKeyService.ts b/src/main/services/extensions/apikeys/ApiKeyService.ts index 35760292..19c4500a 100644 --- a/src/main/services/extensions/apikeys/ApiKeyService.ts +++ b/src/main/services/extensions/apikeys/ApiKeyService.ts @@ -39,6 +39,7 @@ interface StoredApiKey { encrypted?: boolean; encryptionMethod?: EncryptionMethod; scope: 'user' | 'project'; + projectPath?: string; createdAt: string; updatedAt?: string; } @@ -73,6 +74,7 @@ export class ApiKeyService { envVarName: k.envVarName, maskedValue: this.mask(this.decrypt(k)), scope: k.scope, + projectPath: k.projectPath, createdAt: k.createdAt, })); } @@ -86,6 +88,9 @@ export class ApiKeyService { ); } if (!request.value) throw new Error('Key value is required'); + if (request.scope === 'project' && (!request.projectPath || !request.projectPath.trim())) { + throw new Error('Project-scoped API keys require a project path'); + } const keys = await this.readStore(); const now = new Date().toISOString(); @@ -101,6 +106,7 @@ export class ApiKeyService { encryptedValue: value, encryptionMethod: method, scope: request.scope, + projectPath: request.scope === 'project' ? request.projectPath?.trim() : undefined, updatedAt: now, }; delete keys[idx].encrypted; @@ -112,6 +118,7 @@ export class ApiKeyService { encryptedValue: value, encryptionMethod: method, scope: request.scope, + projectPath: request.scope === 'project' ? request.projectPath?.trim() : undefined, createdAt: now, }); } @@ -124,6 +131,7 @@ export class ApiKeyService { envVarName: saved.envVarName, maskedValue: this.mask(request.value), scope: saved.scope, + projectPath: saved.projectPath, createdAt: saved.createdAt, }; } @@ -135,25 +143,36 @@ export class ApiKeyService { await this.writeStore(filtered); } - async lookup(envVarNames: string[]): Promise { + async lookup(envVarNames: string[], projectPath?: string): Promise { if (!envVarNames.length) return []; const keys = await this.readStore(); - const nameSet = new Set(envVarNames); - return keys - .filter((k) => nameSet.has(k.envVarName)) - .map((k) => ({ - envVarName: k.envVarName, - value: this.decrypt(k), - })); + return Array.from(new Set(envVarNames)).flatMap((envVarName) => { + const preferred = this.pickPreferredKey( + keys.filter((key) => key.envVarName === envVarName), + projectPath + ); + if (!preferred) { + return []; + } + + return [ + { + envVarName: preferred.envVarName, + value: this.decrypt(preferred), + }, + ]; + }); } - async lookupPreferred(envVarName: string): Promise { + async lookupPreferred( + envVarName: string, + projectPath?: string + ): Promise { const keys = await this.readStore(); - const matching = keys.filter((key) => key.envVarName === envVarName); - const preferred = - matching.find((key) => key.scope === 'user') ?? - matching.find((key) => key.scope === 'project') ?? - null; + const preferred = this.pickPreferredKey( + keys.filter((key) => key.envVarName === envVarName), + projectPath + ); if (!preferred) { return null; @@ -280,6 +299,20 @@ export class ApiKeyService { return stored.encrypted ? 'safeStorage' : 'base64'; } + private pickPreferredKey(matching: StoredApiKey[], projectPath?: string): StoredApiKey | null { + const normalizedProjectPath = projectPath?.trim(); + if (normalizedProjectPath) { + const projectMatch = matching.find( + (key) => key.scope === 'project' && key.projectPath === normalizedProjectPath + ); + if (projectMatch) { + return projectMatch; + } + } + + return matching.find((key) => key.scope === 'user') ?? null; + } + // ── AES-256-GCM local encryption ─────────────────────────────────────── /** diff --git a/src/preload/index.ts b/src/preload/index.ts index eb2ffbf3..772422e1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1623,8 +1623,8 @@ const electronAPI: ElectronAPI = { list: () => invokeIpcWithResult(API_KEYS_LIST), save: (request: ApiKeySaveRequest) => invokeIpcWithResult(API_KEYS_SAVE, request), delete: (id: string) => invokeIpcWithResult(API_KEYS_DELETE, id), - lookup: (envVarNames: string[]) => - invokeIpcWithResult(API_KEYS_LOOKUP, envVarNames), + lookup: (envVarNames: string[], projectPath?: string) => + invokeIpcWithResult(API_KEYS_LOOKUP, envVarNames, projectPath), getStorageStatus: () => invokeIpcWithResult(API_KEYS_STORAGE_STATUS), }, diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 516727d3..396271ab 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -431,7 +431,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { - + diff --git a/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx b/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx index 653f0fa2..a946adec 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeyCard.tsx @@ -66,6 +66,12 @@ export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Eleme + {apiKey.scope === 'project' && apiKey.projectPath && ( +

+ {apiKey.projectPath} +

+ )} + {/* Env var name */}
diff --git a/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx b/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx index 72e07225..f2b53396 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeyFormDialog.tsx @@ -32,6 +32,8 @@ const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]{0,100}$/i; interface ApiKeyFormDialogProps { open: boolean; editingKey: ApiKeyEntry | null; + currentProjectPath: string | null; + currentProjectLabel: string | null; onClose: () => void; } @@ -45,6 +47,8 @@ const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ export const ApiKeyFormDialog = ({ open, editingKey, + currentProjectPath, + currentProjectLabel, onClose, }: ApiKeyFormDialogProps): React.JSX.Element => { const saveApiKey = useStore((s) => s.saveApiKey); @@ -57,6 +61,14 @@ export const ApiKeyFormDialog = ({ const [scope, setScope] = useState('user'); const [error, setError] = useState(null); const [envVarError, setEnvVarError] = useState(null); + const editingProjectPath = + editingKey?.scope === 'project' ? (editingKey.projectPath ?? null) : null; + const effectiveProjectPath = editingProjectPath ?? currentProjectPath; + const effectiveProjectLabel = + effectiveProjectPath && effectiveProjectPath === currentProjectPath + ? currentProjectLabel + : effectiveProjectPath; + const canUseProjectScope = Boolean(effectiveProjectPath); // Reset form when dialog opens/closes or editing key changes useEffect(() => { @@ -77,6 +89,12 @@ export const ApiKeyFormDialog = ({ } }, [open, editingKey]); + useEffect(() => { + if (open && scope === 'project' && !canUseProjectScope) { + setScope('user'); + } + }, [canUseProjectScope, open, scope]); + const validateEnvVar = (v: string) => { if (!v.trim()) { setEnvVarError(null); @@ -109,6 +127,10 @@ export const ApiKeyFormDialog = ({ setError('Key value is required'); return; } + if (scope === 'project' && !effectiveProjectPath) { + setError('Project-scoped API keys require an active project'); + return; + } try { await saveApiKey({ @@ -117,6 +139,7 @@ export const ApiKeyFormDialog = ({ envVarName: envVarName.trim(), value, scope, + projectPath: scope === 'project' ? (effectiveProjectPath ?? undefined) : undefined, }); onClose(); } catch (err) { @@ -125,7 +148,13 @@ export const ApiKeyFormDialog = ({ }; const isEdit = editingKey !== null; - const canSubmit = name.trim() && envVarName.trim() && value && !envVarError && !apiKeySaving; + const canSubmit = + name.trim() && + envVarName.trim() && + value && + !envVarError && + !apiKeySaving && + (scope !== 'project' || canUseProjectScope); return ( !o && onClose()}> @@ -212,12 +241,23 @@ export const ApiKeyFormDialog = ({ {SCOPE_OPTIONS.map((opt) => ( - - {opt.label} + + {opt.value === 'project' + ? effectiveProjectPath + ? `Project: ${effectiveProjectLabel}` + : 'Project unavailable' + : opt.label} ))} + {scope === 'project' && effectiveProjectPath && ( +

Bound to {effectiveProjectPath}

+ )}
{/* Error display */} diff --git a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx index a45b0e58..3352fb3c 100644 --- a/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx +++ b/src/renderer/components/extensions/apikeys/ApiKeysPanel.tsx @@ -15,7 +15,15 @@ import { ApiKeyFormDialog } from './ApiKeyFormDialog'; import type { ApiKeyEntry } from '@shared/types/extensions'; -export const ApiKeysPanel = (): React.JSX.Element => { +interface ApiKeysPanelProps { + projectPath: string | null; + projectLabel: string | null; +} + +export const ApiKeysPanel = ({ + projectPath, + projectLabel, +}: ApiKeysPanelProps): React.JSX.Element => { const { apiKeys, apiKeysLoading, apiKeysError, storageStatus, fetchStorageStatus, cliStatus } = useStore( useShallow((s) => ({ @@ -213,7 +221,13 @@ export const ApiKeysPanel = (): React.JSX.Element => { )} {/* Form dialog */} - + ); }; diff --git a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx index 59c28f6f..bba98439 100644 --- a/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx +++ b/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx @@ -92,6 +92,11 @@ export const CustomMcpServerDialog = ({ const [envVars, setEnvVars] = useState([]); const [error, setError] = useState(null); const [installing, setInstalling] = useState(false); + const envVarLookupNames = envVars + .map((entry) => entry.key.trim()) + .filter(Boolean) + .sort() + .join('\0'); // Reset on open useEffect(() => { @@ -120,10 +125,10 @@ export const CustomMcpServerDialog = ({ useEffect(() => { if (!open || envVars.length === 0 || !api.apiKeys) return; - const envVarNames = envVars.map((e) => e.key).filter(Boolean); + const envVarNames = envVars.map((e) => e.key.trim()).filter(Boolean); if (envVarNames.length === 0) return; - void api.apiKeys.lookup(envVarNames).then( + void api.apiKeys.lookup(envVarNames, projectPath ?? undefined).then( (results) => { if (results.length === 0) return; const lookup = new Map(results.map((r) => [r.envVarName, r.value])); @@ -135,7 +140,7 @@ export const CustomMcpServerDialog = ({ // Silently fail } ); - }, [open, envVars.length]); // eslint-disable-line react-hooks/exhaustive-deps + }, [envVarLookupNames, envVars, open, projectPath]); // eslint-disable-line react-hooks/exhaustive-deps const handleInstall = async () => { setError(null); diff --git a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx index 87ef0a08..8ac44ee7 100644 --- a/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx +++ b/src/renderer/components/extensions/mcp/McpServerDetailDialog.tsx @@ -110,6 +110,11 @@ export const McpServerDetailDialog = ({ const selectedInstalledEntry = normalizedInstalledEntries.find((entry) => entry.scope === scope) ?? null; const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries); + const envVarLookupNames = + server?.envVars + .map((entry) => entry.name) + .sort() + .join('\0') ?? ''; // Initialize form when dialog opens or server changes useEffect(() => { @@ -160,7 +165,7 @@ export const McpServerDetailDialog = ({ if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return; const envVarNames = server.envVars.map((e) => e.name); - void api.apiKeys.lookup(envVarNames).then( + void api.apiKeys.lookup(envVarNames, projectPath ?? undefined).then( (results) => { if (results.length === 0) return; const filled = new Set(); @@ -176,7 +181,7 @@ export const McpServerDetailDialog = ({ // Silently fail — auto-fill is supplementary } ); - }, [server?.id, open]); // eslint-disable-line react-hooks/exhaustive-deps + }, [envVarLookupNames, open, projectPath, server?.id]); // eslint-disable-line react-hooks/exhaustive-deps if (!server) return <>; diff --git a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx index 62f80d62..dcdb4855 100644 --- a/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx +++ b/src/renderer/components/runtime/ProviderRuntimeSettingsDialog.tsx @@ -105,7 +105,7 @@ function isApiKeyProviderId(providerId: CliProviderId): providerId is ApiKeyProv function findPreferredApiKeyEntry(apiKeys: ApiKeyEntry[], envVarName: string): ApiKeyEntry | null { const matches = apiKeys.filter((entry) => entry.envVarName === envVarName); - return matches.find((entry) => entry.scope === 'user') ?? matches[0] ?? null; + return matches.find((entry) => entry.scope === 'user') ?? null; } function getConnectionDescription(provider: CliProviderStatus): string { diff --git a/src/shared/types/extensions/api.ts b/src/shared/types/extensions/api.ts index c72935c1..e6d9735e 100644 --- a/src/shared/types/extensions/api.ts +++ b/src/shared/types/extensions/api.ts @@ -80,6 +80,6 @@ export interface ApiKeysAPI { list: () => Promise; save: (request: ApiKeySaveRequest) => Promise; delete: (id: string) => Promise; - lookup: (envVarNames: string[]) => Promise; + lookup: (envVarNames: string[], projectPath?: string) => Promise; getStorageStatus: () => Promise; } diff --git a/src/shared/types/extensions/apikey.ts b/src/shared/types/extensions/apikey.ts index c36f11b9..d69af4f6 100644 --- a/src/shared/types/extensions/apikey.ts +++ b/src/shared/types/extensions/apikey.ts @@ -9,6 +9,7 @@ export interface ApiKeyEntry { envVarName: string; maskedValue: string; scope: 'user' | 'project'; + projectPath?: string; createdAt: string; } @@ -19,6 +20,7 @@ export interface ApiKeySaveRequest { envVarName: string; value: string; scope: 'user' | 'project'; + projectPath?: string; } /** Decrypted key lookup result (for auto-fill) */ diff --git a/test/main/services/extensions/ApiKeyService.test.ts b/test/main/services/extensions/ApiKeyService.test.ts new file mode 100644 index 00000000..71e27ec9 --- /dev/null +++ b/test/main/services/extensions/ApiKeyService.test.ts @@ -0,0 +1,120 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: vi.fn(() => false), + getSelectedStorageBackend: vi.fn(() => 'basic_text'), + encryptString: vi.fn(), + decryptString: vi.fn(), + }, +})); + +import { ApiKeyService } from '@main/services/extensions/apikeys/ApiKeyService'; + +describe('ApiKeyService', () => { + let tempDir: string; + let service: ApiKeyService; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apikey-service-')); + service = new ApiKeyService(tempDir); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('persists projectPath for project-scoped API keys', async () => { + const saved = await service.save({ + name: 'Project Tavily', + envVarName: 'TAVILY_API_KEY', + value: 'secret', + scope: 'project', + projectPath: '/tmp/project-a', + }); + + expect(saved.scope).toBe('project'); + expect(saved.projectPath).toBe('/tmp/project-a'); + + await expect(service.list()).resolves.toEqual([ + expect.objectContaining({ + scope: 'project', + projectPath: '/tmp/project-a', + }), + ]); + }); + + it('rejects project-scoped keys without a project path', async () => { + await expect( + service.save({ + name: 'Broken key', + envVarName: 'TAVILY_API_KEY', + value: 'secret', + scope: 'project', + }) + ).rejects.toThrow('project path'); + }); + + it('prefers exact project matches over user keys during lookup', async () => { + await service.save({ + name: 'Shared Tavily', + envVarName: 'TAVILY_API_KEY', + value: 'user-secret', + scope: 'user', + }); + await service.save({ + name: 'Project Tavily', + envVarName: 'TAVILY_API_KEY', + value: 'project-secret', + scope: 'project', + projectPath: '/tmp/project-a', + }); + + await expect(service.lookup(['TAVILY_API_KEY'], '/tmp/project-a')).resolves.toEqual([ + { + envVarName: 'TAVILY_API_KEY', + value: 'project-secret', + }, + ]); + }); + + it('falls back to user keys when project-specific matches do not exist', async () => { + await service.save({ + name: 'Shared Tavily', + envVarName: 'TAVILY_API_KEY', + value: 'user-secret', + scope: 'user', + }); + await service.save({ + name: 'Other project Tavily', + envVarName: 'TAVILY_API_KEY', + value: 'project-secret', + scope: 'project', + projectPath: '/tmp/project-b', + }); + + await expect(service.lookup(['TAVILY_API_KEY'], '/tmp/project-a')).resolves.toEqual([ + { + envVarName: 'TAVILY_API_KEY', + value: 'user-secret', + }, + ]); + }); + + it('does not leak project-scoped keys without project context', async () => { + await service.save({ + name: 'Project only key', + envVarName: 'TAVILY_API_KEY', + value: 'project-secret', + scope: 'project', + projectPath: '/tmp/project-a', + }); + + await expect(service.lookup(['TAVILY_API_KEY'])).resolves.toEqual([]); + await expect(service.lookupPreferred('TAVILY_API_KEY')).resolves.toBeNull(); + }); +}); diff --git a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts index 9947c28d..29e23faa 100644 --- a/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts +++ b/test/renderer/components/extensions/mcp/CustomMcpServerDialog.test.ts @@ -180,6 +180,47 @@ describe('CustomMcpServerDialog project scope', () => { }); }); + it('passes projectPath into API key lookup for project-aware autofill', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CustomMcpServerDialog, { + open: true, + onClose: vi.fn(), + projectPath: '/tmp/custom-mcp-project', + }) + ); + await Promise.resolve(); + }); + + const addEnvButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Add') + ) as HTMLButtonElement; + await act(async () => { + addEnvButton.click(); + await Promise.resolve(); + }); + + const envKeyInput = host.querySelector( + 'input[placeholder="ENV_VAR_NAME"]' + ) as HTMLInputElement; + await act(async () => { + setNativeValue(envKeyInput, 'CONTEXT7_API_KEY', 'input'); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], '/tmp/custom-mcp-project'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('passes projectPath for project-scoped custom installs', async () => { const host = document.createElement('div'); document.body.appendChild(host); diff --git a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts index 7e1b32e9..18c78c6d 100644 --- a/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts +++ b/test/renderer/components/extensions/mcp/McpServerDetailDialog.test.ts @@ -266,7 +266,7 @@ describe('McpServerDetailDialog installed entry handling', () => { }); expect(lookupMock).toHaveBeenCalledTimes(1); - expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY']); + expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], undefined); const projectOption = host.querySelector('option[value="project"]') as HTMLOptionElement; const localOption = host.querySelector('option[value="local"]') as HTMLOptionElement; expect(projectOption.disabled).toBe(true); @@ -278,6 +278,38 @@ describe('McpServerDetailDialog installed entry handling', () => { }); }); + it('passes projectPath into API key lookup for project-aware autofill', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const server = makeServer(); + server.envVars = [{ name: 'CONTEXT7_API_KEY', isSecret: true }]; + + await act(async () => { + root.render( + React.createElement(McpServerDetailDialog, { + server, + isInstalled: false, + installedEntry: null, + diagnostic: null, + diagnosticsLoading: false, + projectPath: '/tmp/project-context7', + open: true, + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lookupMock).toHaveBeenCalledWith(['CONTEXT7_API_KEY'], '/tmp/project-context7'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('defaults to global scope in multimodel mode', async () => { storeState.cliStatus = { flavor: 'agent_teams_orchestrator' }; const host = document.createElement('div');