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
1,973 changes: 1,973 additions & 0 deletions docs/superpowers/plans/2026-09-07-api-key-vault.md

Large diffs are not rendered by default.

56 changes: 56 additions & 0 deletions docs/superpowers/specs/2026-09-07-api-key-vault-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# API Key Vault + Session Text Delivery — Design

**Status:** approved design, 2026-09-07
**Issues:** #830 (session text delivery), #831 (API key vault)
**Plan:** `docs/superpowers/plans/2026-09-07-api-key-vault.md`

## Problem

1. Models routinely need third-party API keys (Brave Search, OpenAI, …). Users paste them straight into prompts because they are low-stakes, but every use means a round-trip to the provider dashboard to copy the key again.
2. Prompt templates only target agent composers (`targetSession.ts` returns null for terminal-kind sessions), so templates — and any future key-insertion feature — are useless in plain terminal panes where users run unsupported agent harnesses, and in agent panes switched to terminal view.

## Requirements (confirmed with user)

- Vault: user-created providers (empty start, no seed list), each holding keys with name + secret + note.
- Encryption at rest via the existing safeStorage discipline (`src/main/dictation/apiKeyStore.ts`).
- Unlock gate: Touch ID / Mac login-password prompt once per app launch; "Lock now" re-arms; cancel/unavailable fails closed.
- Consumption: insert into focused pane (composer or any PTY), copy to clipboard, and `{{key:Provider/Key}}` references in prompt templates.
- Terminal delivery is always paste-without-submit (review then Enter), mirroring the templates' "prefill, don't replay" contract.

## Decisions

### D1 — Per-key safeStorage blobs + plaintext metadata index (chosen over whole-vault blob / native Keychain items)

`STATE_DIR/key-vault/index.json` carries providers, key names, notes, timestamps, and last-4 hints (non-secret). Each secret lives in `keys/<keyId>.bin` as its own safeStorage ciphertext. Rationale identical to `apiKeyStore.ts:27-35`: a corrupt blob after a Keychain reset costs exactly one key, never the vault; the provider list renders without touching decryption; edits rewrite one blob, not a monolith. Whole-vault encryption hides metadata but bricks everything on one decrypt failure. Native Keychain items per key were rejected: ACL prompts fire unpredictably and conflict with the once-per-launch gate.

### D2 — Unlock gate in main via `systemPreferences.promptTouchID`, once per run

Every secret-leaving path (reveal, copy, template ref resolution) funnels through one `ensureUnlocked()`. Electron 43.1.0's `promptTouchID` uses `SecAccessControlUserPresence`; unlike `canPromptTouchID`, it is not a biometric-only capability check. Attempt the supported macOS API and fail closed on rejection. The OS authentication UI still needs manual verification on signed/unsigned builds and password-only Macs. Concurrent requests share one prompt. Lock invalidates pending auth and disk reads and broadcasts cache invalidation to all windows. Metadata CRUD/list IPC is gated too.

The vault encrypts stored key values, not the prompt pipeline. Inserted keys follow normal draft autosave into plaintext `workspace.json`, terminal scrollback, and provider transcript retention after submission; copying puts the value on the system clipboard. The modal explicitly discloses this boundary. Names and notes are plaintext metadata; short values do not get a hint that would expose the entire secret. No export, transparent secret-token draft system, or general transcript redaction is promised.

### D3 — Renderer-owned `deliverTextToSession` (chosen over main-owned paste IPC)

One helper dispatches by normalized session kind and effective surface. Rendered panes edit the existing draft; bare key insertion concatenates text, while templates retain their append/replace policy. Terminal insertion uses the mounted leaf's paste target, respecting visibility, attach/replay state and xterm's current bracketed-paste mode. Single-line text works without bracketed-paste support; multiline text is refused unless the program has enabled that mode. Embedded control sequences are refused. No Enter is added, and rejected writes keep the picker open rather than silently retrying into a replacement backend. Wake never follows a disposed or changed terminal target.

### D4 — Template key references by name, resolved at insertion

`{{key:Provider Name/Key Name}}` is disjoint from ordinary `{{variable}}` fields. The fill pane retains the dynamic or saved body with unresolved key references, then `prepareTemplateText` fills ordinary variables and resolves keys only at final insertion. Cancellation or a missing key aborts insertion. Capture the original session, reject changes during asynchronous preparation, and never deliver after closing the palette. Provider/key names exclude syntax delimiters. Renaming breaks references loudly. The external control template API remains a draft-only API and does not expose the vault.

## Rejected / out of scope

- Auto-submit after terminal paste (user review required before execution).
- Remote phone client vault access; MCP/agent access to keys; export/import or cross-device migration (safeStorage blobs are device-scoped by design).
- Seeded provider lists (go stale; user creates what they need).

## Testing

- Unit: vault store (file discipline, corrupt-blob isolation), service (gate semantics, CRUD rules, fail-closed), key-reference collect/resolve/abort.
- Renderer: delivery helper dispatch matrix with stubbed `window.api.sendInput`.
- Manual smoke: Touch ID cancel path, insert into composer vs terminal, template ref resolution in a raw terminal.

## Risks

- `promptTouchID` behavior on unsigned builds: password fallback expected to work; if a platform ever cannot prompt, the vault degrades to unusable-but-safe (fail closed), surfaced in the modal status line.
- Programs without bracketed-paste support cannot safely receive multiline templates; the helper refuses rather than risking shell execution.
- Renaming vault providers/keys breaks template references by design; failures are loud, never silent.
22 changes: 21 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { createExternalControlSettings } from './settings/externalControl'
import { createExternalCodexIntegration } from './settings/externalCodexIntegration'
import operatorSkillSource from '../../operator-skills/agent-code-computer-execution/SKILL.md?raw'

import { app, clipboard, crashReporter, dialog, Menu } from 'electron'
import { app, clipboard, crashReporter, dialog, Menu, systemPreferences } from 'electron'
import { existsSync } from 'fs'
import { readFile } from 'fs/promises'
import { join } from 'path'
Expand Down Expand Up @@ -95,6 +95,9 @@ import { AgentManagementBridge } from '@main/agentManagement/AgentManagementBrid
import { AiWorkspaceRegistry } from '@main/aiWorkspace/AiWorkspaceRegistry.js'
import { RemoteController } from '@main/remote/RemoteController.js'
import { CaffeinateController } from '@main/caffeinate/CaffeinateController.js'
import { createFileVaultStore } from '@main/keyVault/vaultStore.js'
import { createSafeStorageCodec } from '@main/keyVault/safeStorageCodec.js'
import { VaultService } from '@main/keyVault/VaultService.js'
import { buildAppMenu } from '@main/menu/appMenu.js'
import { AppRunJournal } from '@main/incident/AppRunJournal.js'
import { installProcessCrashHooks } from '@main/incident/installCrashHooks.js'
Expand Down Expand Up @@ -194,6 +197,22 @@ const aiWorkspaceRegistry = new AiWorkspaceRegistry()
aiWorkspaceRegistry.on('changed', event => broadcastToWindows('ai-workspace:changed', event))
const caffeinateController = new CaffeinateController()

// API Key Vault (#831). promptTouchID presents Touch ID with the user's
// login password as fallback, which is the "mac password" gate. Unsigned
// dev builds may skip the biometric option but the password path still
// works; if neither is available the service fails closed on unlock.
// Constructed top-level (like caffeinate) because it owns no window and
// no async boot step — only the per-run unlock boolean.
const vaultService = new VaultService({
store: createFileVaultStore(join(STATE_DIR, 'key-vault'), createSafeStorageCodec()),
promptAuth: reason => systemPreferences.promptTouchID(reason),
// canPromptTouchID checks biometrics, not user-presence/password auth.
// Electron 43's promptTouchID uses SecAccessControlUserPresence; attempt
// that supported macOS API and let rejection keep the vault locked.
canPromptAuth: () => process.platform === 'darwin' && typeof systemPreferences.promptTouchID === 'function',
copyToClipboard: text => clipboard.writeText(text),
})

// SessionManager is constructed inside whenReady so we can await
// TmuxRegistry.detectAvailability() first — terminal sessions need
// to know during spawn whether a tmux backend is available, and
Expand Down Expand Up @@ -944,6 +963,7 @@ async function startApp(): Promise<void> {
agentManagementBridge,
aiWorkspaceRegistry,
caffeinateController,
vaultService,
appRunJournal,
cliUpdateOrchestrator,
workflowBridge: activeWorkflowBridge,
Expand Down
4 changes: 4 additions & 0 deletions src/main/ipc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import { registerAgentManagementIpc } from '@main/ipc/agentManagement.js'
import { registerAiWorkspaceIpc } from '@main/ipc/aiWorkspace.js'
import { registerRenderedContentIpc } from '@main/ipc/renderedContent.js'
import { registerCaffeinateIpc } from '@main/ipc/caffeinate.js'
import { registerKeyVaultIpc } from '@main/ipc/keyVault.js'
import type { VaultService } from '@main/keyVault/VaultService.js'
import { registerRemoteIpc } from '@main/ipc/remote.js'
import type { OrchestrationBridge } from '@main/orchestration/OrchestrationBridge.js'
import type { AgentManagementBridge } from '@main/agentManagement/AgentManagementBridge.js'
Expand Down Expand Up @@ -75,6 +77,7 @@ export type IpcDeps = {
agentManagementBridge: AgentManagementBridge
aiWorkspaceRegistry: AiWorkspaceRegistry
caffeinateController: CaffeinateController
vaultService: VaultService
remoteController: RemoteController
appRunJournal: AppRunJournal
cliUpdateOrchestrator: CliUpdateOrchestrator
Expand Down Expand Up @@ -111,6 +114,7 @@ export function registerAllIpc(deps: IpcDeps): void {
registerAiWorkspaceIpc(deps.aiWorkspaceRegistry)
registerRenderedContentIpc()
registerCaffeinateIpc(deps.caffeinateController)
registerKeyVaultIpc({ vaultService: deps.vaultService })
registerRemoteIpc(deps.remoteController)
registerIncidentIpc(deps.appRunJournal)
// Debug export needs the lifecycle limiter's monotonic completeness state.
Expand Down
44 changes: 44 additions & 0 deletions src/main/ipc/keyVault.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ipcMain } from 'electron'

import type { VaultService } from '@main/keyVault/VaultService.js'
import type { KeyVaultKeyInput } from '@shared/types/keyVault'
import { broadcastToWindows } from '@main/window/windowRegistry.js'
import { z } from 'zod'

// IPC validates wire shapes and gates metadata operations; the service owns
// secret-read authorization and file transactions. Clipboard copy stays in
// main, while reveal and template resolution return a gated secret to the UI.
export function registerKeyVaultIpc({ vaultService }: { vaultService: VaultService }): void {
vaultService.on('locked', () => broadcastToWindows('key-vault:locked', {}))
// Metadata mutations are protected too. TypeScript types cannot validate
// IPC input, and schema errors must not echo submitted secrets to diagnostics.
const text = z.string().max(65536)
const key = z.object({ providerId: text, id: text.optional(), name: text, value: text, note: text }).strict()
function parse<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.safeParse(value)
if (!result.success) throw new Error('Invalid vault request.')
return result.data
}
async function unlocked<T>(operation: () => Promise<T>): Promise<T> {
await vaultService.unlock()
if (!vaultService.getStatus().unlocked) throw new Error('Vault was locked.')
return operation()
}
ipcMain.handle('key-vault:status', () => vaultService.getStatus())
ipcMain.handle('key-vault:list', () => unlocked(() => vaultService.list()))
ipcMain.handle('key-vault:unlock', () => vaultService.unlock())
ipcMain.handle('key-vault:lock', () => vaultService.lock())
ipcMain.handle('key-vault:reveal', (_event, providerId: string, keyId: string) =>
vaultService.reveal(providerId, keyId))
ipcMain.handle('key-vault:copy-key', (_event, providerId: string, keyId: string) =>
vaultService.copyKey(providerId, keyId))
ipcMain.handle('key-vault:resolve-ref', (_event, providerName: string, keyName: string) =>
vaultService.resolveReference(providerName, keyName))
ipcMain.handle('key-vault:create-provider', (_event, name: string) => unlocked(() => vaultService.createProvider(parse(text, name))))
ipcMain.handle('key-vault:rename-provider', (_event, id: string, name: string) =>
unlocked(() => vaultService.renameProvider(parse(text, id), parse(text, name))))
ipcMain.handle('key-vault:delete-provider', (_event, id: string) => unlocked(() => vaultService.deleteProvider(parse(text, id))))
ipcMain.handle('key-vault:put-key', (_event, input: KeyVaultKeyInput) => unlocked(() => vaultService.putKey(parse(key, input))))
ipcMain.handle('key-vault:delete-key', (_event, providerId: string, keyId: string) =>
unlocked(() => vaultService.deleteKey(parse(text, providerId), parse(text, keyId))))
}
Loading