From 2676400e0cab6d803487ff84dfb1bcabfdd733ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 16 Apr 2026 21:43:44 -0400 Subject: [PATCH] fix(ipc-credentials): serialize testCredential env mutations (S-03) Pi-ai's `streamSimple`/`SimpleStreamOptions` has no per-request env escape hatch; its Azure/AWS/GCP ADC providers read `process.env` directly. The previous implementation wrote `extraEnv` into `process.env` globally and relied on a `finally` after `stream.result()` to clean up, so two concurrent `testCredential` calls would clobber each other's env, expose the wrong credential to in-flight streams, and on error paths fail to restore previously-set values. Wrap the env-mutating branch in a module-level promise chain so at most one `extraEnv` test runs at a time, snapshot the prior value of every key we overwrite, and restore (or delete when unset) in `finally`. Tests that resolve to `extraEnv: undefined` bypass the mutex entirely. --- src/main/ipc-credentials-handlers.ts | 204 +++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 src/main/ipc-credentials-handlers.ts diff --git a/src/main/ipc-credentials-handlers.ts b/src/main/ipc-credentials-handlers.ts new file mode 100644 index 00000000..b10730ae --- /dev/null +++ b/src/main/ipc-credentials-handlers.ts @@ -0,0 +1,204 @@ +import { ipcMain } from "electron"; +import { streamSimple, type SimpleStreamOptions } from "@mariozechner/pi-ai"; +import { IPC_CHANNELS } from "@shared/contracts"; +import { + removeCredentialInputSchema, + saveCredentialInputSchema, + testCredentialInputSchema, + type CredentialSummary, +} from "@shared/contracts/schemas-credentials"; +import type { DesktopEvent } from "@shared/types"; +import { getConnectorDefinition } from "./connectors"; +import { resolveModel, resolveExtraStreamHeaders } from "./model-resolver"; +import type { CredentialResolver } from "./credentials/credential-resolver"; +import type { CredentialStore } from "./credentials/credential-store"; +import type { StoredCredentialPayload } from "./credentials/types"; + +interface DesktopOk { ok: true; data: T } +interface DesktopErr { ok: false; error: { code: string; message: string } } +type DesktopResult = DesktopOk | DesktopErr; + +function ok(data: T): DesktopOk { return { ok: true, data }; } +function err(code: string, message: string): DesktopErr { return { ok: false, error: { code, message } }; } + +/** Strip credential-shaped substrings from an error message before sending it + * back to the renderer. Catches the obvious patterns: Bearer tokens, OpenAI + * `sk-` and Anthropic `sk-ant-` keys, AWS access key IDs, JWTs, and any ≥32-char + * hex/base64 blob. */ +function redactSecrets(message: string): string { + return message + .replace(/Bearer\s+[A-Za-z0-9\-_.~+/=]+/gi, "Bearer [REDACTED]") + .replace(/sk-(ant|proj)-[A-Za-z0-9\-_]+/g, "sk-$1-[REDACTED]") + .replace(/sk-[A-Za-z0-9]{20,}/g, "sk-[REDACTED]") + .replace(/AKIA[0-9A-Z]{16}/g, "AKIA[REDACTED]") + .replace(/eyJ[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+/g, "[REDACTED-JWT]") + .replace(/[A-Fa-f0-9]{40,}/g, "[REDACTED-HEX]") + .replace(/[A-Za-z0-9+/]{48,}={0,2}/g, "[REDACTED-B64]"); +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +interface CredentialHandlerDeps { + store: CredentialStore; + resolver: CredentialResolver; + emitEvent: (event: DesktopEvent) => void; + /** Refresh the connector's health row after a save/remove so its `status` + * flips between `auth_required` and `ready` immediately, instead of waiting + * for the next bootstrap pass. */ + refreshConnectorHealth: (connectorId: string) => Promise; + logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void }; +} + +/** Serializes `credentialsTest` calls that must mutate `process.env` to inject + * per-provider configuration for pi-ai (Azure / AWS / GCP ADC). pi-ai's + * `SimpleStreamOptions` has no per-request env escape hatch — its providers + * read `process.env` directly — so two concurrent tests would clobber each + * other's env and surface cross-credential failures. A module-level promise + * chain guarantees at most one `extraEnv`-mutating test runs at a time. */ +let credentialTestChain: Promise = Promise.resolve(); + +function serializeCredentialTest(task: () => Promise): Promise { + const run = credentialTestChain.then(task, task); + credentialTestChain = run.then( + () => undefined, + () => undefined, + ); + return run; +} + +export function registerCredentialIpcHandlers(deps: CredentialHandlerDeps): () => void { + const handlers: Array<[string, (...args: unknown[]) => unknown]> = []; + + function handle(channel: string, handler: (input: unknown) => Promise>) { + const wrapped = async (_event: unknown, input: unknown) => handler(input); + ipcMain.handle(channel, wrapped); + handlers.push([channel, wrapped as never]); + } + + handle(IPC_CHANNELS.credentialsList, async () => { + return ok(deps.store.list()); + }); + + handle(IPC_CHANNELS.credentialsSave, async (raw) => { + const parsed = saveCredentialInputSchema.safeParse(raw); + if (!parsed.success) return err("INVALID_INPUT", parsed.error.message); + const definition = (() => { + try { return getConnectorDefinition(parsed.data.connectorId); } + catch (e) { return e instanceof Error ? e.message : String(e); } + })(); + if (typeof definition === "string") return err("UNKNOWN_CONNECTOR", definition); + + const payload = parsed.data.payload; + if (definition.auth.type === "oauth") { + return err("UNSUPPORTED_PAYLOAD", "Use OAuth login flow for OAuth connectors."); + } + if (definition.auth.type !== payload.kind) { + return err("MISMATCHED_KIND", `Connector ${parsed.data.connectorId} expects ${definition.auth.type} credential.`); + } + try { + const stored = deps.store.save(parsed.data.connectorId, payload as StoredCredentialPayload, null); + await deps.refreshConnectorHealth(parsed.data.connectorId); + deps.emitEvent({ type: "credentials-changed", connectorId: parsed.data.connectorId }); + return ok(stored); + } catch (error) { + deps.logger.error(`[credentials] save failed for ${parsed.data.connectorId}: ${describeError(error)}`); + return err("SAVE_FAILED", "Could not save credential. Check application logs for details."); + } + }); + + handle(IPC_CHANNELS.credentialsRemove, async (raw) => { + const parsed = removeCredentialInputSchema.safeParse(raw); + if (!parsed.success) return err("INVALID_INPUT", parsed.error.message); + try { + deps.store.remove(parsed.data.connectorId); + await deps.refreshConnectorHealth(parsed.data.connectorId); + deps.emitEvent({ type: "credentials-changed", connectorId: parsed.data.connectorId }); + return ok(true); + } catch (error) { + deps.logger.error(`[credentials] remove failed for ${parsed.data.connectorId}: ${describeError(error)}`); + return err("REMOVE_FAILED", "Could not remove credential. Check application logs for details."); + } + }); + + handle<{ ok: boolean; message: string }>(IPC_CHANNELS.credentialsTest, async (raw) => { + const parsed = testCredentialInputSchema.safeParse(raw); + if (!parsed.success) return err("INVALID_INPUT", parsed.error.message); + let definition; + try { + definition = getConnectorDefinition(parsed.data.connectorId); + } catch (error) { + deps.logger.error(`[credentials] test failed lookup for ${parsed.data.connectorId}: ${describeError(error)}`); + return err("UNKNOWN_CONNECTOR", "Unknown connector."); + } + let resolved; + try { + resolved = await deps.resolver.resolve(parsed.data.connectorId); + } catch (error) { + const message = describeError(error); + deps.logger.error(`[credentials] test resolve failed for ${parsed.data.connectorId}: ${message}`); + return ok({ ok: false, message: redactSecrets(message) }); + } + + const runTest = async (): Promise> => { + try { + const model = resolveModel(definition.provider, definition.defaultModel); + if (!model) return ok({ ok: false, message: `Unknown model ${definition.defaultModel} for provider ${definition.provider}` }); + const extraHeaders = resolveExtraStreamHeaders(definition.provider, resolved.apiKey); + const options: SimpleStreamOptions = { + apiKey: resolved.apiKey, + maxTokens: 1, + ...(extraHeaders ? { headers: extraHeaders } : {}), + }; + const stream = streamSimple( + model, + { + systemPrompt: "Reply with the single character 'k'.", + messages: [{ role: "user", content: "ping", timestamp: Date.now() }], + }, + options, + ); + const final = await stream.result(); + if (final.stopReason === "error" || final.stopReason === "aborted") { + const message = final.errorMessage ?? `Provider returned ${final.stopReason}.`; + deps.logger.warn(`[credentials] test failed for ${parsed.data.connectorId}: ${message}`); + return ok({ ok: false, message: redactSecrets(message) }); + } + return ok({ ok: true, message: `OK (${model.id})` }); + } catch (error) { + const message = describeError(error); + deps.logger.error(`[credentials] test threw for ${parsed.data.connectorId}: ${message}`); + return ok({ ok: false, message: redactSecrets(message) }); + } + }; + + // Providers that require per-request env (Azure/AWS/GCP ADC) must mutate + // `process.env` because pi-ai reads it directly. Serialize those tests so + // concurrent invocations cannot observe each other's env. + if (resolved.extraEnv) { + const envPatch = resolved.extraEnv; + return serializeCredentialTest(async () => { + const saved = new Map(); + for (const [k, v] of Object.entries(envPatch)) { + saved.set(k, process.env[k]); + process.env[k] = v; + } + try { + return await runTest(); + } finally { + for (const [k, prev] of saved) { + if (prev === undefined) delete process.env[k]; + else process.env[k] = prev; + } + } + }); + } + + return runTest(); + }); + + return () => { + for (const [channel] of handlers) ipcMain.removeHandler(channel); + }; +}