Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
66 changes: 66 additions & 0 deletions scripts/test-probe-context.mjs
Original file line number Diff line number Diff line change
@@ -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')
12 changes: 11 additions & 1 deletion scripts/test-secrets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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`)
Expand Down
22 changes: 20 additions & 2 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -43,6 +45,8 @@ interface ProbeRequest {
secretId?: string
apiKey?: string
baseUrl?: string
project?: string
region?: string
}

type UsageRequest =
Expand Down Expand Up @@ -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) }
Expand Down
37 changes: 37 additions & 0 deletions src/main/probes/context.ts
Original file line number Diff line number Diff line change
@@ -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'
}
6 changes: 6 additions & 0 deletions src/preload/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,8 @@ export interface ProviderSummary {
featured?: boolean
envKeys: string[]
baseUrlEnv?: string
authType?: string
probeKind?: string
defaultBaseUrl?: string
notes?: string
}
Expand Down Expand Up @@ -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 {
Expand Down
67 changes: 53 additions & 14 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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)
}}
/>
Expand Down Expand Up @@ -2087,17 +2101,20 @@ function AddKeyModal({
providers: ProviderSummary[]
existingIds: Set<string>
onClose: () => void
onSave: (providerId: string, value: string, label: string) => Promise<void>
onSave: (providerId: string, value: string, label: string, config?: { project: string; region: string }) => Promise<void>
}) {
const [step, setStep] = useState<'pick' | 'enter'>('pick')
const [picked, setPicked] = useState<ProviderSummary | null>(null)
const [query, setQuery] = useState('')
const [value, setValue] = useState('')
const [project, setProject] = useState('')
const [region, setRegion] = useState('')
const [error, setError] = useState<string | null>(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()
Expand All @@ -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)
Expand Down Expand Up @@ -2151,7 +2169,7 @@ function AddKeyModal({
{p.label}
{existingIds.has(p.id) && <span className="badge" style={{ marginLeft: 6 }}>stored</span>}
</div>
<div className="hoist-catalogue-tile-sub muted mono">{p.envKeys[0]}</div>
<div className="hoist-catalogue-tile-sub muted mono">{p.envKeys[0] ?? 'PROJECT + REGION'}</div>
</div>
</button>
))}
Expand All @@ -2161,14 +2179,35 @@ function AddKeyModal({
<div className="hoist-addkey-form">
<p className="muted" style={{ marginTop: 0 }}>
Stored as <span className="mono">{secretIdForProvider(picked!.id)}</span>.
Env: <span className="mono">{picked!.envKeys[0]}</span>
{isVertex ? ' Project and region are stored beside the token.' : <> Env: <span className="mono">{picked!.envKeys[0]}</span></>}
</p>
<label className="hoist-field-label" htmlFor="key-value">API key</label>
{isVertex && (
<>
<label className="hoist-field-label" htmlFor="vertex-project">GCP project id</label>
<input
id="vertex-project"
className="input input-lg"
autoFocus
placeholder="my-gcp-project"
value={project}
onChange={(e) => setProject(e.target.value)}
/>
<label className="hoist-field-label" htmlFor="vertex-region">Region</label>
<input
id="vertex-region"
className="input input-lg"
placeholder="us-central1"
value={region}
onChange={(e) => setRegion(e.target.value)}
/>
</>
)}
<label className="hoist-field-label" htmlFor="key-value">{isVertex ? 'Access token' : 'API key'}</label>
<input
id="key-value"
className="input input-lg"
type="password"
autoFocus
autoFocus={!isVertex}
placeholder="Paste secret…"
value={value}
onChange={(e) => setValue(e.target.value)}
Expand All @@ -2184,7 +2223,7 @@ function AddKeyModal({
<button
type="button"
className="btn btn-primary btn-sm btn-pill"
disabled={saving || !value.trim()}
disabled={saving || !value.trim() || (isVertex && (!project.trim() || !region.trim()))}
onClick={() => void submit()}
>
{saving ? 'Saving…' : 'Save key'}
Expand Down
21 changes: 21 additions & 0 deletions src/shared/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,24 @@ export function providerIdFromSecretId(secretId: string): string | null {
const m = /^provider:([^:]+):api_key$/.exec(secretId)
return m ? m[1] : null
}

/** Non-credential config a provider probe needs, stored beside the API key. */
export type ProviderConfigField = 'project' | 'region'

/** Canonical vault secret id for a provider config field, for example `provider:vertex:project`. */
export function secretIdForProviderConfig(providerId: string, field: ProviderConfigField): string {
return `provider:${providerId}:${field}`
}

/** Extract provider id and field from a provider config secret id, if it matches. */
export function providerConfigFromSecretId(
secretId: string,
): { providerId: string; field: ProviderConfigField } | null {
const m = /^provider:([^:]+):(project|region)$/.exec(secretId)
return m ? { providerId: m[1], field: m[2] as ProviderConfigField } : null
}

/** True when the secret id holds provider config, not a credential. */
export function isProviderConfigSecretId(secretId: string): boolean {
return providerConfigFromSecretId(secretId) !== null
}
Loading