diff --git a/package.json b/package.json index 513a989..6860eaf 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "prebuild:renderer": "npm run gen:catalog", "catalog:check": "node scripts/check-catalog-stale.mjs", "test": "npm run build:main && npm run test:unit", - "test:unit": "node scripts/test-extract-base-url.mjs && node scripts/test-doctor.mjs && node scripts/test-versions.mjs && node scripts/test-secrets.mjs && node scripts/test-homebrew.mjs && node scripts/test-install-methods.mjs && node scripts/test-codex-auth.mjs && node scripts/test-probes-cloud.mjs", + "test:unit": "node scripts/test-extract-base-url.mjs && node scripts/test-doctor.mjs && node scripts/test-versions.mjs && node scripts/test-secrets.mjs && node scripts/test-homebrew.mjs && node scripts/test-install-methods.mjs && node scripts/test-codex-auth.mjs && node scripts/test-probes-cloud.mjs && node scripts/test-probe-context.mjs", "test:extractor": "node scripts/test-extract-base-url.mjs", "package": "npm run build && electron-builder" }, diff --git a/scripts/test-probe-context.mjs b/scripts/test-probe-context.mjs new file mode 100644 index 0000000..e4db1d6 --- /dev/null +++ b/scripts/test-probe-context.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +/** + * Unit tests for probe context resolution (src/main/probes/context.ts). + * Pure logic, no Electron and no live provider keys. + */ +import { createRequire } from 'node:module' +import { existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const contextJs = join(root, 'dist/main/probes/context.js') +if (!existsSync(contextJs)) { + console.error('dist/main/probes/context.js missing - run npm run build:main first') + process.exit(1) +} + +const require = createRequire(import.meta.url) +const { buildProbeContext, probeRequiresApiKey } = require(contextJs) + +let failed = 0 +function assert(cond, msg) { + if (cond) console.log(` ✓ ${msg}`) + else { + failed++ + console.error(` ✗ ${msg}`) + } +} + +{ + const ctx = buildProbeContext({ providerId: 'vertex', apiKey: 'tok', storedProject: 'stored-proj', storedRegion: 'europe-west1' }) + assert(ctx.project === 'stored-proj', 'stored project is used when explicit is missing') + assert(ctx.region === 'europe-west1', 'stored region is used when explicit is missing') + assert(ctx.apiKey === 'tok' && ctx.providerId === 'vertex', 'api key and provider pass through') +} +{ + const ctx = buildProbeContext({ providerId: 'vertex', apiKey: 'tok', project: 'explicit-proj', region: 'us-central1', storedProject: 'stored-proj', storedRegion: 'europe-west1' }) + assert(ctx.project === 'explicit-proj', 'explicit project wins over stored') + assert(ctx.region === 'us-central1', 'explicit region wins over stored') +} +{ + const ctx = buildProbeContext({ providerId: 'vertex', apiKey: 'tok', project: ' ', region: '', storedProject: ' stored-proj ', storedRegion: ' europe-west1 ' }) + assert(ctx.project === 'stored-proj', 'blank explicit project falls back to trimmed stored') + assert(ctx.region === 'europe-west1', 'blank explicit region falls back to trimmed stored') +} +{ + const ctx = buildProbeContext({ providerId: 'vertex', apiKey: 'tok', storedProject: ' ', storedRegion: '' }) + assert(ctx.project === undefined, 'blank stored project becomes undefined') + assert(ctx.region === undefined, 'blank stored region becomes undefined') +} +{ + const ctx = buildProbeContext({ providerId: 'anthropic' }) + assert(ctx.apiKey === '', 'missing api key becomes empty string') + assert(ctx.project === undefined && ctx.region === undefined, 'non-cloud provider has no project or region') +} +assert(probeRequiresApiKey('aws') === false, 'aws probe does not require a vault api key') +assert(probeRequiresApiKey('gcp') === true, 'gcp probe requires a token') +assert(probeRequiresApiKey('azure') === true, 'azure probe requires a key') +assert(probeRequiresApiKey('anthropicModels') === true, 'anthropic probe requires a key') +assert(probeRequiresApiKey(undefined) === true, 'unknown probe kind requires a key by default') + +if (failed > 0) { + console.error(`\n${failed} probe context case(s) failed`) + process.exit(1) +} +console.log('\nall probe context cases passed') diff --git a/scripts/test-secrets.mjs b/scripts/test-secrets.mjs index e8c9800..fa42dce 100644 --- a/scripts/test-secrets.mjs +++ b/scripts/test-secrets.mjs @@ -16,7 +16,7 @@ if (!existsSync(secretsJs)) { } const require = createRequire(import.meta.url) -const { secretIdForProvider, providerIdFromSecretId } = require(secretsJs) +const { secretIdForProvider, providerIdFromSecretId, secretIdForProviderConfig, providerConfigFromSecretId, isProviderConfigSecretId } = require(secretsJs) let failed = 0 function assert(cond, msg) { @@ -33,6 +33,16 @@ assert(providerIdFromSecretId('provider:anthropic:api_key') === 'anthropic', 'pa assert(providerIdFromSecretId('provider:openai:api_key') === 'openai', 'parse openai') assert(providerIdFromSecretId('random') === null, 'reject non-canonical') assert(providerIdFromSecretId(secretIdForProvider('groq')) === 'groq', 'round-trip') +assert(secretIdForProviderConfig('vertex', 'project') === 'provider:vertex:project', 'vertex project secret id') +assert(secretIdForProviderConfig('vertex', 'region') === 'provider:vertex:region', 'vertex region secret id') +assert(JSON.stringify(providerConfigFromSecretId('provider:vertex:project')) === JSON.stringify({ providerId: 'vertex', field: 'project' }), 'parse vertex project config id') +assert(JSON.stringify(providerConfigFromSecretId('provider:vertex:region')) === JSON.stringify({ providerId: 'vertex', field: 'region' }), 'parse vertex region config id') +assert(providerConfigFromSecretId('provider:vertex:api_key') === null, 'api_key is not a config id') +assert(providerConfigFromSecretId('provider:vertex:other') === null, 'unknown field is not a config id') +assert(providerConfigFromSecretId('random') === null, 'reject non-canonical config id') +assert(isProviderConfigSecretId('provider:vertex:project') === true, 'isProviderConfigSecretId accepts project') +assert(isProviderConfigSecretId('provider:vertex:api_key') === false, 'isProviderConfigSecretId rejects api_key') +assert(providerIdFromSecretId('provider:vertex:project') === null, 'config id does not parse as api_key') if (failed > 0) { console.error(`\n${failed} secrets assertion(s) failed`) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 36456e3..d6b1dff 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -4,6 +4,8 @@ import { createSafeStorageBackend } from './secrets/safestorage' import { maskSecret } from './secrets/backend' import type { SecretBackend } from './secrets/backend' import { runProbe } from './probes' +import { buildProbeContext, probeRequiresApiKey } from './probes/context' +import { secretIdForProviderConfig } from '../shared/secrets' import { runHarnessUsage, runProviderUsage } from './usage' import { libraryDiscover } from './library' import { discoverAll, installHarness, uninstallHarness } from './installer' @@ -43,6 +45,8 @@ interface ProbeRequest { secretId?: string apiKey?: string baseUrl?: string + project?: string + region?: string } type UsageRequest = @@ -312,14 +316,28 @@ export function registerIpcHandlers(): void { ipcMain.handle(CHANNELS.probeRun, async (_evt, req: ProbeRequest) => { try { + const provider = findProvider(req.providerId) let apiKey = req.apiKey if (!apiKey && req.secretId) { apiKey = (await getBackend().get(req.secretId)) ?? undefined } - if (!apiKey) { + if (!apiKey && probeRequiresApiKey(provider?.probeKind)) { return { ok: false as const, error: 'No API key available for probe.' } } - const result = await runProbe({ providerId: req.providerId, apiKey, baseUrl: req.baseUrl }) + const backend = getBackend() + const storedProject = req.project ? undefined : await backend.get(secretIdForProviderConfig(req.providerId, 'project')).catch(() => null) + const storedRegion = req.region ? undefined : await backend.get(secretIdForProviderConfig(req.providerId, 'region')).catch(() => null) + const result = await runProbe( + buildProbeContext({ + providerId: req.providerId, + apiKey, + baseUrl: req.baseUrl, + project: req.project, + region: req.region, + storedProject, + storedRegion, + }), + ) return { ok: true as const, result } } catch (err) { return { ok: false as const, error: errMsg(err) } diff --git a/src/main/probes/context.ts b/src/main/probes/context.ts new file mode 100644 index 0000000..07cd4a8 --- /dev/null +++ b/src/main/probes/context.ts @@ -0,0 +1,37 @@ +import type { ProbeContext } from './index' +import type { ProbeKind } from './types' + +export interface ProbeContextInput { + providerId: string + apiKey?: string + baseUrl?: string + /** Explicit project from the probe request. Wins over the stored value. */ + project?: string + /** Explicit region from the probe request. Wins over the stored value. */ + region?: string + /** Project read from the vault beside the API key. */ + storedProject?: string | null + /** Region read from the vault beside the API key. */ + storedRegion?: string | null +} + +function trimmed(value: string | null | undefined): string | undefined { + const next = value?.trim() + return next ? next : undefined +} + +/** Build a probe context, preferring explicit fields over stored vault config. */ +export function buildProbeContext(input: ProbeContextInput): ProbeContext { + return { + providerId: input.providerId, + apiKey: input.apiKey ?? '', + baseUrl: input.baseUrl, + project: trimmed(input.project) ?? trimmed(input.storedProject), + region: trimmed(input.region) ?? trimmed(input.storedRegion), + } +} + +/** The aws probe reads the AWS env chain, so it needs no vault API key. */ +export function probeRequiresApiKey(probeKind: ProbeKind | string | undefined): boolean { + return probeKind !== 'aws' +} diff --git a/src/preload/api.ts b/src/preload/api.ts index ca76613..0be8b8c 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -186,6 +186,8 @@ export interface ProviderSummary { featured?: boolean envKeys: string[] baseUrlEnv?: string + authType?: string + probeKind?: string defaultBaseUrl?: string notes?: string } @@ -243,6 +245,10 @@ export interface ProbeRequest { secretId?: string apiKey?: string baseUrl?: string + /** GCP project id for the Vertex probe. Falls back to the vault value in main. */ + project?: string + /** Cloud region for the Vertex and Bedrock probes. Falls back to the vault value in main. */ + region?: string } export interface ProbeResponse { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6fbe35e..d1ef69c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -39,7 +39,7 @@ import { type DoctorAction, type DoctorFinding, } from '../shared/doctor' -import { providerIdFromSecretId, secretIdForProvider } from '../shared/secrets' +import { isProviderConfigSecretId, providerIdFromSecretId, secretIdForProvider, secretIdForProviderConfig } from '../shared/secrets' declare global { interface Window { @@ -394,7 +394,7 @@ export function App() { return } const byId = new Map(providerList.map((p) => [p.id, p])) - const rows: KeyRow[] = vaultRes.entries.map((e: VaultEntry) => { + const rows: KeyRow[] = vaultRes.entries.filter((e: VaultEntry) => !isProviderConfigSecretId(e.id)).map((e: VaultEntry) => { const pid = providerIdFromSecretId(e.id) ?? e.id const prov = byId.get(pid) return { @@ -504,7 +504,15 @@ export function App() { try { const res = await window.hoist.vault.delete(secretId) if (!res.ok) showToast(res.error ?? 'Delete failed') - else showToast('Key deleted') + else { + const pid = providerIdFromSecretId(secretId) + if (pid) { + for (const field of ['project', 'region'] as const) { + await window.hoist.vault.delete(secretIdForProviderConfig(pid, field)).catch(() => undefined) + } + } + showToast('Key deleted') + } await refreshKeys() } catch (err) { showToast(err instanceof Error ? err.message : String(err)) @@ -523,10 +531,16 @@ export function App() { } }, [showToast]) - const saveKey = useCallback(async (providerId: string, value: string, label: string) => { + const saveKey = useCallback(async (providerId: string, value: string, label: string, config?: { project: string; region: string }) => { const id = secretIdForProvider(providerId) const res = await window.hoist.vault.set({ id, value, label }) if (!res.ok) throw new Error(res.error ?? 'Failed to save key') + if (config) { + const projectRes = await window.hoist.vault.set({ id: secretIdForProviderConfig(providerId, 'project'), value: config.project, label: `${label} project` }) + if (!projectRes.ok) throw new Error(projectRes.error ?? 'Failed to save project') + const regionRes = await window.hoist.vault.set({ id: secretIdForProviderConfig(providerId, 'region'), value: config.region, label: `${label} region` }) + if (!regionRes.ok) throw new Error(regionRes.error ?? 'Failed to save region') + } await refreshKeys() setSelectedKeyId(id) // Best-effort probe after save @@ -775,8 +789,8 @@ export function App() { providers={providers} existingIds={new Set(keys.map((k) => k.providerId))} onClose={() => setAddKeyOpen(false)} - onSave={async (providerId, value, label) => { - await saveKey(providerId, value, label) + onSave={async (providerId, value, label, config) => { + await saveKey(providerId, value, label, config) setAddKeyOpen(false) }} /> @@ -2087,17 +2101,20 @@ function AddKeyModal({ providers: ProviderSummary[] existingIds: Set onClose: () => void - onSave: (providerId: string, value: string, label: string) => Promise + onSave: (providerId: string, value: string, label: string, config?: { project: string; region: string }) => Promise }) { const [step, setStep] = useState<'pick' | 'enter'>('pick') const [picked, setPicked] = useState(null) const [query, setQuery] = useState('') const [value, setValue] = useState('') + const [project, setProject] = useState('') + const [region, setRegion] = useState('') const [error, setError] = useState(null) const [saving, setSaving] = useState(false) + const isVertex = picked?.id === 'vertex' const list = providers - .filter((p) => p.envKeys && p.envKeys.length > 0) + .filter((p) => (p.envKeys && p.envKeys.length > 0) || p.id === 'vertex') .filter((p) => { if (!query) return true const q = query.toLowerCase() @@ -2107,10 +2124,11 @@ function AddKeyModal({ const submit = async () => { if (!picked || !value.trim()) return + if (isVertex && (!project.trim() || !region.trim())) return setSaving(true) setError(null) try { - await onSave(picked.id, value.trim(), picked.label) + await onSave(picked.id, value.trim(), picked.label, isVertex ? { project: project.trim(), region: region.trim() } : undefined) } catch (err) { setError(err instanceof Error ? err.message : String(err)) setSaving(false) @@ -2151,7 +2169,7 @@ function AddKeyModal({ {p.label} {existingIds.has(p.id) && stored} -
{p.envKeys[0]}
+
{p.envKeys[0] ?? 'PROJECT + REGION'}
))} @@ -2161,14 +2179,35 @@ function AddKeyModal({

Stored as {secretIdForProvider(picked!.id)}. - Env: {picked!.envKeys[0]} + {isVertex ? ' Project and region are stored beside the token.' : <> Env: {picked!.envKeys[0]}}

- + {isVertex && ( + <> + + setProject(e.target.value)} + /> + + setRegion(e.target.value)} + /> + + )} + setValue(e.target.value)} @@ -2184,7 +2223,7 @@ function AddKeyModal({