fix(extensions): support project-scoped api keys

This commit is contained in:
777genius 2026-04-17 14:34:46 +03:00
parent 489e3eb967
commit 33917a3161
15 changed files with 334 additions and 32 deletions

View file

@ -421,11 +421,15 @@ async function handleApiKeysDelete(
async function handleApiKeysLookup(
_event: IpcMainInvokeEvent,
envVarNames?: string[]
envVarNames?: string[],
projectPath?: string
): Promise<IpcResult<ApiKeyLookupResult[]>> {
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
);
});
}

View file

@ -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<ApiKeyLookupResult[]> {
async lookup(envVarNames: string[], projectPath?: string): Promise<ApiKeyLookupResult[]> {
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<ApiKeyLookupResult | null> {
async lookupPreferred(
envVarName: string,
projectPath?: string
): Promise<ApiKeyLookupResult | null> {
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 ───────────────────────────────────────
/**

View file

@ -1623,8 +1623,8 @@ const electronAPI: ElectronAPI = {
list: () => invokeIpcWithResult<ApiKeyEntry[]>(API_KEYS_LIST),
save: (request: ApiKeySaveRequest) => invokeIpcWithResult<ApiKeyEntry>(API_KEYS_SAVE, request),
delete: (id: string) => invokeIpcWithResult<void>(API_KEYS_DELETE, id),
lookup: (envVarNames: string[]) =>
invokeIpcWithResult<ApiKeyLookupResult[]>(API_KEYS_LOOKUP, envVarNames),
lookup: (envVarNames: string[], projectPath?: string) =>
invokeIpcWithResult<ApiKeyLookupResult[]>(API_KEYS_LOOKUP, envVarNames, projectPath),
getStorageStatus: () => invokeIpcWithResult<ApiKeyStorageStatus>(API_KEYS_STORAGE_STATUS),
},

View file

@ -431,7 +431,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
</TabsContent>
<TabsContent value="api-keys" className="mt-0 pt-4">
<ApiKeysPanel />
<ApiKeysPanel projectPath={projectPath} projectLabel={projectLabel} />
</TabsContent>
<TabsContent value="skills" className="mt-0 pt-4">

View file

@ -66,6 +66,12 @@ export const ApiKeyCard = ({ apiKey, onEdit }: ApiKeyCardProps): React.JSX.Eleme
</Badge>
</div>
{apiKey.scope === 'project' && apiKey.projectPath && (
<p className="truncate text-xs text-text-muted" title={apiKey.projectPath}>
{apiKey.projectPath}
</p>
)}
{/* Env var name */}
<div className="flex items-center gap-1.5">
<code className="rounded bg-surface-raised px-1.5 py-0.5 text-xs text-blue-400">

View file

@ -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<Scope>('user');
const [error, setError] = useState<string | null>(null);
const [envVarError, setEnvVarError] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
@ -212,12 +241,23 @@ export const ApiKeyFormDialog = ({
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value === 'project' && !canUseProjectScope}
>
{opt.value === 'project'
? effectiveProjectPath
? `Project: ${effectiveProjectLabel}`
: 'Project unavailable'
: opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{scope === 'project' && effectiveProjectPath && (
<p className="text-xs text-text-muted">Bound to {effectiveProjectPath}</p>
)}
</div>
{/* Error display */}

View file

@ -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 */}
<ApiKeyFormDialog open={dialogOpen} editingKey={editingKey} onClose={handleDialogClose} />
<ApiKeyFormDialog
open={dialogOpen}
editingKey={editingKey}
currentProjectPath={projectPath}
currentProjectLabel={projectLabel}
onClose={handleDialogClose}
/>
</div>
);
};

View file

@ -92,6 +92,11 @@ export const CustomMcpServerDialog = ({
const [envVars, setEnvVars] = useState<EnvEntry[]>([]);
const [error, setError] = useState<string | null>(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);

View file

@ -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<string>();
@ -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 <></>;

View file

@ -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 {

View file

@ -80,6 +80,6 @@ export interface ApiKeysAPI {
list: () => Promise<ApiKeyEntry[]>;
save: (request: ApiKeySaveRequest) => Promise<ApiKeyEntry>;
delete: (id: string) => Promise<void>;
lookup: (envVarNames: string[]) => Promise<ApiKeyLookupResult[]>;
lookup: (envVarNames: string[], projectPath?: string) => Promise<ApiKeyLookupResult[]>;
getStorageStatus: () => Promise<ApiKeyStorageStatus>;
}

View file

@ -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) */

View file

@ -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();
});
});

View file

@ -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);

View file

@ -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');