fix(extensions): support project-scoped api keys
This commit is contained in:
parent
489e3eb967
commit
33917a3161
15 changed files with 334 additions and 32 deletions
|
|
@ -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
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 <></>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
120
test/main/services/extensions/ApiKeyService.test.ts
Normal file
120
test/main/services/extensions/ApiKeyService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue