From 60acbabb52146d89e88bd0d06d9500bbd72e35c3 Mon Sep 17 00:00:00 2001 From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:48:36 +0900 Subject: [PATCH] feat(keys): store Vertex project and region in the vault Add provider config secret ids (provider:vertex:project and provider:vertex:region) beside the api_key id, a pure buildProbeContext helper that prefers explicit probe fields over stored vault values, and IPC wiring that passes stored project and region into runProbe. Bedrock (probeKind aws) no longer needs a vault key to probe, since it reads the AWS env chain. The renderer Keys step now lists Vertex in Add key, asks for project id, region and access token before Save, writes the sibling secrets on save, deletes them with the key, and never renders config entries as key rows. Co-authored-by: Muse Code --- package.json | 2 +- scripts/test-probe-context.mjs | 66 +++++++++++++++++++++++++++++++++ scripts/test-secrets.mjs | 12 +++++- src/main/ipc.ts | 22 ++++++++++- src/main/probes/context.ts | 37 +++++++++++++++++++ src/preload/api.ts | 6 +++ src/renderer/App.tsx | 67 +++++++++++++++++++++++++++------- src/shared/secrets.ts | 21 +++++++++++ 8 files changed, 215 insertions(+), 18 deletions(-) create mode 100644 scripts/test-probe-context.mjs create mode 100644 src/main/probes/context.ts 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({