diff --git a/.claude/settings.json b/.claude/settings.json index c29d26ca..99d6b834 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -31,8 +31,7 @@ "Bash(head:*)", "Bash(tail:*)", "Bash(wc:*)", - "Bash(tree:*)", - "Read(//Users/dev/Documents/Projects/Agent-Organizer/**)" + "Bash(tree:*)" ], "deny": [ "Bash(sudo:*)", @@ -40,8 +39,7 @@ "Bash(git push:*)", "Read(.env)", "Read(.env.*)", - "Read(**/*.pem)", - "Read(**/secrets/**)" + "Read(**/*.pem)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index acf892e1..bb3463d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,8 +48,9 @@ entry point (multi-turn sessions, persistence, export-to-workflow) are shipped. **Phase 2 (CLI) is underway and milestone M3 is reached** — the CLI skeleton (2.A) and config resolution (2.B) landed (PR #40), `relavium run` is wired to the engine (2.D, the M3 keystone — PR #41), the `--json` CI machine-output contract -landed (2.F — PR #42, ADR-0049), and the engine regression harness (2.K — PR #43) -completes M3. The next pickup is 2.H (durable run history). For live status, per-PR history, +landed (2.F — PR #42, ADR-0049), the engine regression harness (2.K — PR #43) +completes M3, and durable run history landed (2.H — PR #44, ADR-0050). The next pickup is 2.C +(provider/keys). For live status, per-PR history, milestone dates, and open obligations, see the canonical home [docs/roadmap/current.md](docs/roadmap/current.md); [README.md](README.md) is the public overview. diff --git a/README.md b/README.md index b65b0433..bfb626dd 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,8 @@ One engine, three modes behind the one `LLMProvider` seam: local-first BYOK — workflow parsing, DAG execution, live streaming, checkpoint/resume, multi-provider failover, cost governance, and multimodal media I/O. **Phase 2 (the CLI) is underway** — the CLI skeleton, config resolution, `relavium run` (wired to the engine), its -`--json` CI machine-output contract, and the engine regression harness have landed (milestone -**M3** reached). For live status and the full roadmap, see +`--json` CI machine-output contract, the engine regression harness, and durable local run history +have landed (milestone **M3** reached). For live status and the full roadmap, see [docs/roadmap/current.md](docs/roadmap/current.md) and the [roadmap](docs/roadmap/README.md). diff --git a/apps/cli/package.json b/apps/cli/package.json index 17514e64..31bfa95f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -17,6 +17,7 @@ "test": "vitest run" }, "dependencies": { + "@napi-rs/keyring": "catalog:", "@relavium/core": "workspace:*", "@relavium/db": "workspace:*", "@relavium/llm": "workspace:*", diff --git a/apps/cli/src/commands/provider.test.ts b/apps/cli/src/commands/provider.test.ts new file mode 100644 index 00000000..35c8c7b2 --- /dev/null +++ b/apps/cli/src/commands/provider.test.ts @@ -0,0 +1,257 @@ +import { createClient, createProviderStore, runMigrations, type DbClient } from '@relavium/db'; +import type { LlmProvider } from '@relavium/llm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + createProviderResolver, + keyHint, + providerKeyEnvVar, + type ProviderResolver, +} from '../engine/providers.js'; +import { KeychainUnavailableError, type KeychainStore } from '../secrets/keychain.js'; +import { readSecretFromStdin } from '../secrets/read-secret.js'; +import { captureIo } from '../test-support.js'; +import { runProviderCommand, type ProviderCommandDeps } from './provider.js'; + +const TS_MS = new Date('2026-06-23T12:00:00.000Z').getTime(); +// A fake key built from fragments so no contiguous secret literal exists (Leakwatch convention). +const RAW_KEY = ['sk', 'ant', 'FAKEKEY', '9999'].join('-'); + +/** A mutable in-memory keychain — the production `@napi-rs/keyring` accessor is never loaded by these tests. */ +function memKeychain( + seed: Record = {}, +): KeychainStore & { map: Map } { + const map = new Map(Object.entries(seed)); + return { + map, + get: (account) => map.get(account) ?? null, + set: (account, secret) => { + map.set(account, secret); + }, + delete: (account) => map.delete(account), + }; +} + +/** A stub resolver for `provider test` — `generate` resolves or rejects; `keyFor` returns a fixed key. */ +function stubResolver(generate: LlmProvider['generate']): ProviderResolver { + const provider: LlmProvider = { + id: 'anthropic', + generate, + stream: () => { + throw new Error('stream not used in provider test'); + }, + // `provider test` only calls `generate`; `supports` is never read here. A real CapabilityFlags fixture + // would have to satisfy the schema's `vision === media.input.image` refine (+ the nested media shape) and + // would couple this test to that evolving seam schema for zero assertion value — hence a bare stub cast. + supports: {} as LlmProvider['supports'], + }; + return { resolveProvider: () => provider, keyFor: () => RAW_KEY }; +} + +describe('relavium provider commands (2.C)', () => { + let client: DbClient; + let deps: (over: Partial) => ProviderCommandDeps; + let io: ReturnType; + + beforeEach(() => { + client = createClient(':memory:'); + runMigrations(client.db); + io = captureIo(); + let next = 0; + const store = createProviderStore(client.db, { + uuid: () => `00000000-0000-4000-8000-${String(++next).padStart(12, '0')}`, + now: () => TS_MS, + }); + deps = (over) => ({ + io: io.io, + store, + keychain: memKeychain(), + resolver: stubResolver(() => + Promise.resolve({} as Awaited>), + ), + readSecret: () => Promise.resolve(RAW_KEY), + ...over, + }); + }); + + afterEach(() => { + client.sqlite.close(); + }); + + it('set-key stores the key in the keychain + only a ref in the db — never the key value', async () => { + const keychain = memKeychain(); + const d = deps({ keychain }); + const code = await runProviderCommand({ action: 'set-key', name: 'anthropic' }, d); + expect(code).toBe(0); + expect(keychain.get('anthropic:default')).toBe(RAW_KEY); // raw key in the keychain only + const rec = d.store.get('anthropic'); + expect(rec?.apiKeyKeychainRef).toBe('anthropic:default'); // db row holds the ref + expect(JSON.stringify(rec)).not.toContain(RAW_KEY); // ...never the key + expect(io.out()).toContain('••••9999'); // a hint, never the full key + expect(io.out()).not.toContain(RAW_KEY); + }); + + it('list shows registered providers and whether a key is set (no key echoed)', async () => { + const keychain = memKeychain(); + const d = deps({ keychain }); + await runProviderCommand({ action: 'set-key', name: 'anthropic' }, d); + await runProviderCommand({ action: 'add', name: 'openai' }, d); + io.out(); // (accumulates; assert on the final list call) + await runProviderCommand({ action: 'list' }, d); + const text = io.out(); + expect(text).toContain('anthropic'); + expect(text).toContain('[key set]'); + expect(text).toContain('openai'); + expect(text).toContain('[no key]'); + expect(text).not.toContain(RAW_KEY); + }); + + it('remove-key deletes the keychain entry and clears the db ref', async () => { + const keychain = memKeychain(); + const d = deps({ keychain }); + await runProviderCommand({ action: 'set-key', name: 'anthropic' }, d); + const code = await runProviderCommand({ action: 'remove-key', name: 'anthropic' }, d); + expect(code).toBe(0); + expect(keychain.get('anthropic:default')).toBeNull(); + expect(d.store.get('anthropic')?.apiKeyKeychainRef).toBeUndefined(); + }); + + it('test reports success when the provider generate resolves', async () => { + const d = deps({ + resolver: stubResolver(() => + Promise.resolve({} as Awaited>), + ), + }); + const code = await runProviderCommand({ action: 'test', name: 'anthropic' }, d); + expect(code).toBe(0); + expect(io.out()).toContain('key works'); + }); + + it('test fails cleanly (exit 2) and redacts the key even if the provider error contains it', async () => { + // The provider error embeds the raw key — the CLI must redact it (the key is in scope at the catch). + const d = deps({ + resolver: stubResolver(() => + Promise.reject(new Error(`401 unauthorized: ${RAW_KEY} rejected`)), + ), + }); + let caught: unknown; + try { + await runProviderCommand({ action: 'test', name: 'anthropic' }, d); + } catch (err) { + caught = err; + } + expect(caught).toMatchObject({ exitCode: 2 }); + expect(caught).toBeInstanceOf(Error); + const message = caught instanceof Error ? caught.message : String(caught); + expect(message).not.toContain(RAW_KEY); // redacted... + expect(message).toContain('••••9999'); // ...to the hint + }); + + it('rejects a non-HTTPS or malformed --base-url (exit 2)', async () => { + // HTTPS-only at store time — a plaintext `http:` endpoint must never be persisted, and a + // non-`http(s)` scheme is rejected outright (matches the routing-time `assertHttpsBaseUrl` gate). + for (const baseUrl of ['http://proxy.example/v1', 'file:///etc/passwd', 'not-a-url']) { + await expect( + runProviderCommand({ action: 'add', name: 'openai', baseUrl }, deps({})), + ).rejects.toMatchObject({ exitCode: 2 }); + } + }); + + it('set-key preserves a base URL set by a prior `add` (never clobbers it)', async () => { + const d = deps({}); + await runProviderCommand( + { action: 'add', name: 'openai', baseUrl: 'https://proxy.example/v1' }, + d, + ); + await runProviderCommand({ action: 'set-key', name: 'openai' }, d); + expect(d.store.get('openai')?.baseUrl).toBe('https://proxy.example/v1'); // not the SDK default + }); + + it('rejects an unknown provider name (exit 2)', async () => { + await expect( + runProviderCommand({ action: 'add', name: 'bogus' }, deps({})), + ).rejects.toMatchObject({ exitCode: 2 }); + }); + + it('maps a keychain-unavailable backend to a clean exit-2 message', async () => { + const keychain: KeychainStore = { + get: () => null, + set: () => { + throw new KeychainUnavailableError('no Secret Service'); + }, + delete: () => false, + }; + await expect( + runProviderCommand({ action: 'set-key', name: 'anthropic' }, deps({ keychain })), + ).rejects.toMatchObject({ exitCode: 2 }); + }); +}); + +describe('createProviderResolver keyFor — keychain → env var → error (2.C)', () => { + it('prefers the OS keychain', () => { + const keychain = memKeychain({ 'anthropic:default': 'from-keychain' }); + const resolver = createProviderResolver( + { [providerKeyEnvVar('anthropic')]: 'from-env' }, + keychain, + ); + expect(resolver.keyFor('anthropic')).toBe('from-keychain'); + }); + + it('falls back to the env var when the keychain has no entry', () => { + const resolver = createProviderResolver( + { [providerKeyEnvVar('openai')]: 'from-env' }, + memKeychain(), + ); + expect(resolver.keyFor('openai')).toBe('from-env'); + }); + + it('falls through to the env var when the keychain backend is unavailable', () => { + const keychain: KeychainStore = { + get: () => { + throw new KeychainUnavailableError('locked'); + }, + set: () => undefined, + delete: () => false, + }; + const resolver = createProviderResolver( + { [providerKeyEnvVar('gemini')]: 'from-env' }, + keychain, + ); + expect(resolver.keyFor('gemini')).toBe('from-env'); + }); + + it('throws a clean invocation error (exit 2) when no source has a key', () => { + const resolver = createProviderResolver({}, memKeychain()); + expect(() => resolver.keyFor('deepseek')).toThrowError(/no API key for provider 'deepseek'/); + }); +}); + +describe('keyHint', () => { + it('shows only the last 4 chars, never the full key', () => { + expect(keyHint(RAW_KEY)).toBe('••••9999'); + expect(keyHint(RAW_KEY)).not.toContain('sk-ant'); + }); + it('fully masks a too-short value', () => { + expect(keyHint('abc')).toBe('••••'); + }); +}); + +describe('readSecretFromStdin', () => { + it('refuses to read a typed key from an interactive TTY (errors with a pipe hint, exit 2)', async () => { + // Capture the FULL original descriptor so the restore reinstates it exactly (a value-only restore would + // leave a synthetic data property where there may have been an accessor / a different configurability). + const original = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + // A typed secret on an echoing terminal is the failure mode the stdin guard prevents — even if stdout + // is redirected (the guard keys on stdin, not stdout). + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + try { + await expect(readSecretFromStdin()).rejects.toMatchObject({ exitCode: 2 }); + } finally { + if (original === undefined) { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } else { + Object.defineProperty(process.stdin, 'isTTY', original); + } + } + }); +}); diff --git a/apps/cli/src/commands/provider.ts b/apps/cli/src/commands/provider.ts new file mode 100644 index 00000000..1af52de1 --- /dev/null +++ b/apps/cli/src/commands/provider.ts @@ -0,0 +1,222 @@ +import { ProviderIdSchema, type ProviderId } from '@relavium/llm'; +import type { ProviderStore } from '@relavium/db'; + +import { keyHint, type ProviderResolver } from '../engine/providers.js'; +import { CliError } from '../process/errors.js'; +import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; +import type { CliIo } from '../process/io.js'; +import { + KeychainUnavailableError, + keychainAccount, + type KeychainStore, +} from '../secrets/keychain.js'; + +/** + * The `relavium provider` command cores (workstream **2.C**) — register providers and manage their API keys + * in the OS keychain. Framework-free (no `commander` import): parsed args + injected ports in, output via + * the {@link CliIo}; a fault throws a typed {@link CliError} (exit 2). The store / keychain / resolver are + * injected, so the cores unit-test with an in-memory keychain + `:memory:` db and never touch the real + * keychain. A **full key is never written** to stdout/logs/`--json` — only a {@link keyHint} (last 4). + * + * The headless fallback for resolution is the env var (`RELAVIUM__API_KEY`); the `secrets.enc` + * encrypted-file fallback is deferred past v1.0 (keychain-and-secrets.md). + */ + +/** The known providers (each has an `@relavium/llm` adapter) — display name, base URL, and a cheap test model. */ +const KNOWN_PROVIDERS: Record< + ProviderId, + { displayName: string; baseUrl: string; testModel: string } +> = { + anthropic: { + displayName: 'Anthropic', + baseUrl: 'https://api.anthropic.com', + testModel: 'claude-haiku-4-5', + }, + openai: { + displayName: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + testModel: 'gpt-5.4-mini', + }, + gemini: { + displayName: 'Google Gemini', + baseUrl: 'https://generativelanguage.googleapis.com', + testModel: 'gemini-2.5-flash', + }, + deepseek: { + displayName: 'DeepSeek', + baseUrl: 'https://api.deepseek.com', + testModel: 'deepseek-chat', + }, +}; + +export type ProviderAction = 'list' | 'add' | 'set-key' | 'remove-key' | 'test'; + +export interface ProviderCommandArgs { + readonly action: ProviderAction; + readonly name?: string; + readonly baseUrl?: string; + readonly model?: string; +} + +export interface ProviderCommandDeps { + readonly io: CliIo; + readonly store: ProviderStore; + readonly keychain: KeychainStore; + readonly resolver: ProviderResolver; + /** Read the API key (from stdin in production) — injected so tests supply a fixed key, never a real one. */ + readonly readSecret: () => Promise; +} + +/** Validate a provider name against the known `@relavium/llm` providers (`ProviderId`), or fail (exit 2). */ +function parseProviderId(name: string): ProviderId { + const result = ProviderIdSchema.safeParse(name); + if (!result.success) { + throw new CliError( + 'invalid_invocation', + `unknown provider '${name}' — known providers: ${ProviderIdSchema.options.join(', ')}.`, + ); + } + return result.data; +} + +function requireName(args: ProviderCommandArgs): string { + if (args.name === undefined || args.name === '') { + throw new CliError( + 'invalid_invocation', + `\`relavium provider ${args.action}\` requires a provider name.`, + ); + } + return args.name; +} + +function providerList(deps: ProviderCommandDeps): void { + const providers = deps.store.list(); + if (providers.length === 0) { + deps.io.writeOut( + 'No providers registered. Add a key with `relavium provider set-key `.\n', + ); + return; + } + for (const p of providers) { + // "key set" is derived from the stored keychain ref (no key read) — never echo the key here. + deps.io.writeOut(`${p.name}\t${p.baseUrl}\t[${p.apiKeyKeychainRef ? 'key set' : 'no key'}]\n`); + } +} + +function providerAdd(args: ProviderCommandArgs, deps: ProviderCommandDeps): void { + const id = parseProviderId(requireName(args)); + const meta = KNOWN_PROVIDERS[id]; + const record = deps.store.upsert({ + name: id, + displayName: meta.displayName, + baseUrl: args.baseUrl === undefined ? meta.baseUrl : requireHttpsUrl(args.baseUrl), + }); + deps.io.writeOut( + `Registered provider '${id}' (${record.baseUrl}). Store a key with \`relavium provider set-key ${id}\`.\n`, + ); +} + +async function providerSetKey(args: ProviderCommandArgs, deps: ProviderCommandDeps): Promise { + const id = parseProviderId(requireName(args)); + const meta = KNOWN_PROVIDERS[id]; + const key = await deps.readSecret(); // from stdin — never an argv flag + const account = keychainAccount(id); + deps.keychain.set(account, key); // KeychainUnavailableError surfaces (no silent plaintext fallback) + // Register the row only if it's new — never overwrite a base URL the user set via `provider add --base-url`. + if (deps.store.get(id) === undefined) { + deps.store.upsert({ name: id, displayName: meta.displayName, baseUrl: meta.baseUrl }); + } + deps.store.setKeychainRef(id, account); // the ref, NEVER the key value + deps.io.writeOut(`Stored ${id} key ${keyHint(key)} in the OS keychain.\n`); +} + +function providerRemoveKey(args: ProviderCommandArgs, deps: ProviderCommandDeps): void { + const id = parseProviderId(requireName(args)); + const removed = deps.keychain.delete(keychainAccount(id)); + deps.store.clearKeychainRef(id); + deps.io.writeOut( + removed ? `Removed ${id} key from the OS keychain.\n` : `No ${id} key was stored.\n`, + ); +} + +async function providerTest(args: ProviderCommandArgs, deps: ProviderCommandDeps): Promise { + const id = parseProviderId(requireName(args)); + const provider = deps.resolver.resolveProvider(id); + if (provider === undefined) { + throw new CliError('invalid_invocation', `no adapter for provider '${id}'.`); + } + const key = deps.resolver.keyFor(id); // keychain → env var → error; never logged + const model = args.model ?? KNOWN_PROVIDERS[id].testModel; + try { + await provider.generate( + { + model, + messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }], + maxTokens: 1, + }, + key, + ); + } catch (err) { + // A clean failure. @relavium/llm already scrubs secrets from its error messages, but the key is in + // scope HERE, so defensively redact any occurrence before surfacing — and do NOT attach `err` as the + // cause (it could carry the key in a nested field that `--verbose` might render). + const raw = err instanceof Error ? err.message : String(err); + const scrubbed = raw.split(key).join(keyHint(key)); + throw new CliError('invalid_invocation', `${id}: key test failed — ${scrubbed}`); + } + deps.io.writeOut(`${id}: key works (${model}).\n`); +} + +/** + * Validate a user-supplied provider base URL: a parseable **HTTPS** URL (fail-fast at `add`). HTTPS-only + * matches the at-routing-time gate (`@relavium/llm`'s `assertHttpsBaseUrl`, security-review.md §Network) and + * keeps a plaintext `http:` endpoint from ever being persisted; the private/loopback/metadata range-block + * stays at the routing-time gate (it needs host resolution this fail-fast deliberately does not do). The + * value is stored **verbatim** (not `url.href`-normalized) to preserve the user's exact endpoint/trailing + * slash; the routing-time gate re-parses it via `new URL()` anyway. + */ +function requireHttpsUrl(raw: string): string { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new CliError('invalid_invocation', `invalid base URL: '${raw}'.`); + } + if (url.protocol !== 'https:') { + throw new CliError('invalid_invocation', `base URL must be HTTPS, got '${raw}'.`); + } + return raw; +} + +/** Dispatch a `relavium provider ` to its core, mapping a keychain-unavailable backend to exit 2. */ +export async function runProviderCommand( + args: ProviderCommandArgs, + deps: ProviderCommandDeps, +): Promise { + try { + switch (args.action) { + case 'list': + providerList(deps); + break; + case 'add': + providerAdd(args, deps); + break; + case 'set-key': + await providerSetKey(args, deps); + break; + case 'remove-key': + providerRemoveKey(args, deps); + break; + case 'test': + await providerTest(args, deps); + break; + } + return EXIT_CODES.success; + } catch (err) { + // An unavailable keychain (locked / no Secret Service) is an environment fault → a clean exit-2 message. + if (err instanceof KeychainUnavailableError) { + throw new CliError('invalid_invocation', err.message, { cause: err }); + } + throw err; + } +} diff --git a/apps/cli/src/commands/specs.ts b/apps/cli/src/commands/specs.ts index 2e1312c0..5aa083fb 100644 --- a/apps/cli/src/commands/specs.ts +++ b/apps/cli/src/commands/specs.ts @@ -1,10 +1,23 @@ +import { randomUUID } from 'node:crypto'; + +import { createProviderStore } from '@relavium/db'; import type { Command } from 'commander'; +import { loadResolvedConfig } from '../config/load.js'; +import { openLocalDb } from '../db/open.js'; +import { createProviderResolver } from '../engine/providers.js'; import { openHistoryStore } from '../history/open.js'; import { CliError } from '../process/errors.js'; -import type { ExitCode } from '../process/exit-codes.js'; +import { type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; +import { createOsKeychainStore } from '../secrets/os-keychain.js'; +import { readSecretFromStdin } from '../secrets/read-secret.js'; +import { + runProviderCommand, + type ProviderCommandArgs, + type ProviderCommandDeps, +} from './provider.js'; import { runCommand } from './run.js'; /** @@ -65,11 +78,6 @@ const STUB_COMMANDS: readonly StubSpec[] = [ summary: 'Export a workflow/agent to a portable YAML (secrets stripped).', landsIn: 'workstream 2.J', }, - { - name: 'provider', - summary: 'Manage providers and API keys in the OS keychain.', - landsIn: 'workstream 2.C', - }, { name: 'agent', summary: 'Manage and run agents.', landsIn: 'workstreams 2.M–2.Q' }, { name: 'init', @@ -80,6 +88,7 @@ const STUB_COMMANDS: readonly StubSpec[] = [ export function registerCommands(program: Command, ctx?: CommandContext): void { registerRun(program, ctx); + registerProvider(program, ctx); for (const spec of STUB_COMMANDS) { program .command(spec.name) @@ -110,12 +119,107 @@ function registerRun(program: Command, ctx?: CommandContext): void { run.action(async (workflow: string, opts: { input?: readonly string[] }) => { ctx.result.exitCode = await runCommand( { workflow, input: opts.input ?? [] }, - // Production wires durable run history (2.H) — the real CLI persists to ~/.relavium/history.db. - { io: ctx.io, global: ctx.global, openRunStore: openHistoryStore }, + { + io: ctx.io, + global: ctx.global, + // Production wires durable run history (2.H) + the keychain-backed key resolver (2.C): the real CLI + // persists to ~/.relavium/history.db and resolves keys via the OS keychain → env var. + openRunStore: openHistoryStore, + providers: createProviderResolver(ctx.io.env, createOsKeychainStore()), + }, ); }); } +/** Register `relavium provider` and its subcommands (2.C). Each opens the local db + keychain per invocation. */ +function registerProvider(program: Command, ctx?: CommandContext): void { + const provider = program + .command('provider') + .description('Manage providers and API keys in the OS keychain.'); + const list = provider + .command('list') + .description('List registered providers and whether a key is set.'); + const add = provider + .command('add ') + .description('Register a provider.') + .option('--base-url ', 'override the provider base URL'); + const setKey = provider + .command('set-key ') + .description('Store a provider API key in the OS keychain (the key is read from stdin).'); + const removeKey = provider + .command('remove-key ') + .description('Remove a provider API key from the OS keychain.'); + const test = provider + .command('test ') + .description('Verify a provider key with a minimal live request.') + .option('--model ', 'model to test with (defaults to a cheap known model)'); + + if (ctx === undefined) { + // No runtime context (bare buildProgram for help) — keep each subcommand a clean stub. + for (const sub of [list, add, setKey, removeKey, test]) { + sub.action(() => { + throw new CliError( + 'not_implemented', + '`relavium provider` requires the CLI runtime context.', + ); + }); + } + return; + } + + const dispatch = async (args: ProviderCommandArgs): Promise => { + ctx.result.exitCode = await withProviderDeps(ctx, (deps) => runProviderCommand(args, deps)); + }; + list.action(async () => { + await dispatch({ action: 'list' }); + }); + add.action(async (name: string, opts: { baseUrl?: string }) => { + await dispatch({ + action: 'add', + name, + ...(opts.baseUrl === undefined ? {} : { baseUrl: opts.baseUrl }), + }); + }); + setKey.action(async (name: string) => { + await dispatch({ action: 'set-key', name }); + }); + removeKey.action(async (name: string) => { + await dispatch({ action: 'remove-key', name }); + }); + test.action(async (name: string, opts: { model?: string }) => { + await dispatch({ + action: 'test', + name, + ...(opts.model === undefined ? {} : { model: opts.model }), + }); + }); +} + +/** Open the local db + OS keychain for one `provider` invocation, run the core, and always close the db. */ +async function withProviderDeps( + ctx: CommandContext, + fn: (deps: ProviderCommandDeps) => Promise, +): Promise { + const { homeDir } = loadResolvedConfig({ + cwd: ctx.global.cwd, + configPath: ctx.global.configPath, + }); + const { db, close } = openLocalDb(homeDir); + try { + const keychain = createOsKeychainStore(); // one native accessor, shared by the store-ref writes + the resolver + const deps: ProviderCommandDeps = { + io: ctx.io, + store: createProviderStore(db, { uuid: () => randomUUID(), now: () => Date.now() }), + keychain, + resolver: createProviderResolver(ctx.io.env, keychain), + readSecret: readSecretFromStdin, + }; + return await fn(deps); + } finally { + close(); + } +} + /** First whitespace-delimited token of a command name — `logs ` → `logs`. */ function commandWord(name: string): string { return name.split(' ', 1)[0] ?? name; diff --git a/apps/cli/src/db/open.ts b/apps/cli/src/db/open.ts new file mode 100644 index 00000000..90d5a572 --- /dev/null +++ b/apps/cli/src/db/open.ts @@ -0,0 +1,63 @@ +import { chmodSync } from 'node:fs'; +import { join } from 'node:path'; + +import { createClient, runMigrations, type Db } from '@relavium/db'; + +import { ensureGlobalConfigDir, globalConfigDir } from '../config/paths.js'; + +/** An opened local database plus the handle to close its SQLite connection. */ +export interface OpenedDb { + readonly db: Db; + readonly close: () => void; +} + +/** + * Open `~/.relavium/history.db` for the CLI (workstreams **2.H** run history, **2.C** the `llm_providers` + * registry — both tables live in this one file): lazy-create + `0700` the home dir, open via + * `better-sqlite3`, apply migrations, then `0600` the db + its `-wal`/`-shm` sidecars — the unencrypted + * at-rest CLI posture guarded by OS permissions ([ADR-0050](../../../../docs/decisions/0050-cli-history-db-at-rest-posture.md)). + */ +export function openLocalDb(homeDir: string): OpenedDb { + ensureGlobalConfigDir(homeDir); // creates ~/.relavium/ at 0700 (ADR-0050) + const path = join(globalConfigDir(homeDir), 'history.db'); + const client = createClient(path); + // If setup (migrations / the at-rest chmod) throws, close the just-opened handle before propagating — + // otherwise the SQLite connection leaks for the lifetime of the failing process. + try { + runMigrations(client.db); + // The db file is guaranteed to exist here — a chmod failure on IT must be LOUD (ADR-0050's at-rest + // guarantee rests on this 0600). Its WAL/SHM sidecars may not exist yet (no checkpoint) — best-effort. + chmodSync(path, 0o600); + for (const suffix of ['-wal', '-shm']) { + try { + chmodSync(`${path}${suffix}`, 0o600); + } catch (err) { + if (errnoCode(err) !== 'ENOENT') { + throw err; + } + } + } + } catch (err) { + client.sqlite.close(); + throw err; + } + return { + db: client.db, + // Idempotent: better-sqlite3's close() throws on an already-closed handle, so guard on `.open` + // — a double close (e.g. an error-recovery path that also closes in a finally) is then a no-op. + close: () => { + if (client.sqlite.open) { + client.sqlite.close(); + } + }, + }; +} + +/** The `errno` code of a Node fs error (`ENOENT`, `EPERM`, …), or `undefined` if it is not one. */ +function errnoCode(err: unknown): string | undefined { + if (typeof err === 'object' && err !== null && 'code' in err) { + const code: unknown = err.code; + return typeof code === 'string' ? code : undefined; + } + return undefined; +} diff --git a/apps/cli/src/engine/providers.ts b/apps/cli/src/engine/providers.ts index 5ce9f894..84eb3e6a 100644 --- a/apps/cli/src/engine/providers.ts +++ b/apps/cli/src/engine/providers.ts @@ -3,26 +3,38 @@ import { defaultProviders, type LlmProvider, type ProviderId } from '@relavium/l import type { Agent } from '@relavium/shared'; import { CliError } from '../process/errors.js'; +import { + KeychainUnavailableError, + keychainAccount, + type KeychainStore, +} from '../secrets/keychain.js'; /** * The CLI's provider seam (ADR-0038 host-injected resolution). `resolveProvider` returns the keyless - * `@relavium/llm` adapter for an authored provider id; `keyFor` resolves that provider's API key from - * the environment (`RELAVIUM__API_KEY`, the headless per-invocation key source). The OS keychain - * source lands in **2.C** behind this same seam. A key is read only when `keyFor` is invoked (per - * attempt) and is never logged, stored, or returned to the caller. + * `@relavium/llm` adapter for an authored provider id; `keyFor` resolves that provider's API key through the + * documented chain: **OS keychain → `RELAVIUM__API_KEY` env var → error** (2.C; the `secrets.enc` + * encrypted-file fallback is deferred past v1.0 per keychain-and-secrets.md). A key is read only when + * `keyFor` is invoked (per attempt) and is never logged, stored, or returned to a renderer. * - * Injectable so a test (and the 2.K regression harness) drives a stub provider with a dummy key. + * Injectable so a test (and the 2.K regression harness) drives a stub provider + dummy key; the keychain is + * an **optional** source so tests / the harness stay keychain-free (env-only) while production injects the + * real `@napi-rs/keyring` store. */ export interface ProviderResolver { readonly resolveProvider: (id: ProviderId) => LlmProvider | undefined; readonly keyFor: (id: ProviderId) => string; } -/** The env var holding a provider's API key — the headless per-invocation key source. */ +/** The env var holding a provider's API key — the headless per-invocation key source (CI / no-keychain). */ export function providerKeyEnvVar(id: ProviderId): string { return `RELAVIUM_${id.toUpperCase()}_API_KEY`; } +/** A non-secret display hint for a key — masked, with the last 4 chars (or fully masked when too short). NEVER the full key. */ +export function keyHint(key: string): string { + return key.length <= 4 ? '••••' : `••••${key.slice(-4)}`; +} + /** * The provider ids whose key is **guaranteed** needed by a parsed workflow — the **primary** * `provider` (the authored `agent.provider`, never derived from the model) of every inline agent @@ -60,6 +72,7 @@ export function neededProviderIds(def: WorkflowDefinition): ProviderId[] { export function createProviderResolver( env: Readonly> = process.env, + keychain?: KeychainStore, ): ProviderResolver { // Keyless adapters built once and reused; the key is injected per call via `keyFor`. The // provider→adapter mapping lives in the seam package (`@relavium/llm`), not here. @@ -67,14 +80,32 @@ export function createProviderResolver( return { resolveProvider: (id) => adapters[id], keyFor: (id) => { - const key = env[providerKeyEnvVar(id)]; - if (key === undefined || key === '') { - throw new CliError( - 'invalid_invocation', - `no API key for provider '${id}' — set ${providerKeyEnvVar(id)} (OS keychain support lands in 2.C).`, - ); + // 1. OS keychain (the primary store, 2.C). Absent (`null`) → fall through to env; an *unavailable* + // backend (locked / no Secret Service) also falls through — the env var is the CLI's documented + // no-keychain path. We never read/write a plaintext fallback. + if (keychain !== undefined) { + let fromKeychain: string | null = null; + try { + fromKeychain = keychain.get(keychainAccount(id)); + } catch (err) { + if (!(err instanceof KeychainUnavailableError)) { + throw err; + } + } + if (fromKeychain !== null && fromKeychain !== '') { + return fromKeychain; + } + } + // 2. Env var — the headless / CI per-invocation source. + const fromEnv = env[providerKeyEnvVar(id)]; + if (fromEnv !== undefined && fromEnv !== '') { + return fromEnv; } - return key; + // 3. No source — a clean invocation error naming both ways to provide the key (never the key itself). + throw new CliError( + 'invalid_invocation', + `no API key for provider '${id}' — store one with \`relavium provider set-key ${id}\` or set ${providerKeyEnvVar(id)}.`, + ); }, }; } diff --git a/apps/cli/src/history/open.ts b/apps/cli/src/history/open.ts index 2680c41b..4ce5b1bc 100644 --- a/apps/cli/src/history/open.ts +++ b/apps/cli/src/history/open.ts @@ -1,16 +1,9 @@ import { randomUUID } from 'node:crypto'; -import { chmodSync } from 'node:fs'; -import { join } from 'node:path'; import type { WorkflowDefinition } from '@relavium/core'; -import { - createClient, - createRunHistoryStore, - runMigrations, - type RunHistoryStore, -} from '@relavium/db'; +import { createRunHistoryStore, type RunHistoryStore } from '@relavium/db'; -import { ensureGlobalConfigDir, globalConfigDir } from '../config/paths.js'; +import { openLocalDb } from '../db/open.js'; /** An opened history store plus the handle to close its SQLite connection at run end. */ export interface OpenedHistory { @@ -19,37 +12,15 @@ export interface OpenedHistory { } /** - * Open `~/.relavium/history.db` for one CLI run (workstream **2.H**): lazy-create + `0700` the home dir, - * open it via `better-sqlite3`, apply migrations, then `0600` the db + its `-wal`/`-shm` sidecars — the - * unencrypted-at-rest CLI posture guarded by OS permissions - * ([ADR-0050](../../../../docs/decisions/0050-cli-history-db-at-rest-posture.md)). - * - * The store records THIS workflow: its frozen snapshot feeds `runs.workflow_definition_snapshot` (the - * events the engine emits don't carry the graph). Production (`commands/specs.ts`) wires this; the unit - * tests and the 2.K harness omit it and keep the in-memory store, so they never touch the user's home. + * Open the durable run-history store for one CLI run (workstream **2.H**) over `~/.relavium/history.db` + * (see {@link openLocalDb} for the open/migrate/`0600` posture, ADR-0050). The store records THIS workflow: + * its frozen snapshot feeds `runs.workflow_definition_snapshot` (the engine's events don't carry the graph). + * Production (`commands/specs.ts`) wires this; the unit tests and the 2.K harness omit it and keep the + * in-memory store, so they never touch the user's home. */ export function openHistoryStore(workflow: WorkflowDefinition, homeDir: string): OpenedHistory { - ensureGlobalConfigDir(homeDir); // creates ~/.relavium/ at 0700 (ADR-0050) - const path = join(globalConfigDir(homeDir), 'history.db'); - const client = createClient(path); - runMigrations(client.db); - // The db file is guaranteed to exist here (better-sqlite3 created it; runMigrations wrote it), so a chmod - // failure on IT must be LOUD — ADR-0050's whole at-rest guarantee is this 0600. Its WAL/SHM sidecars may - // not exist yet (no checkpoint), so those alone are best-effort. - chmodSync(path, 0o600); - for (const suffix of ['-wal', '-shm']) { - try { - chmodSync(`${path}${suffix}`, 0o600); - } catch (err) { - // Tolerate ONLY the sidecar's absence (no WAL checkpoint has happened yet). A real chmod failure - // (EPERM, EIO) on an existing sidecar must surface — it holds the same run data as the db, so the - // same 0600 guarantee applies; swallowing it would leave it world-readable (ADR-0050). - if (errnoCode(err) !== 'ENOENT') { - throw err; - } - } - } - const store = createRunHistoryStore(client.db, { + const { db, close } = openLocalDb(homeDir); + const store = createRunHistoryStore(db, { uuid: () => randomUUID(), now: () => Date.now(), workflow: { @@ -58,19 +29,5 @@ export function openHistoryStore(workflow: WorkflowDefinition, homeDir: string): definitionJson: JSON.stringify(workflow), }, }); - return { - store, - close: () => { - client.sqlite.close(); - }, - }; -} - -/** The `errno` code of a Node fs error (`ENOENT`, `EPERM`, …), or `undefined` if it is not one. */ -function errnoCode(err: unknown): string | undefined { - if (typeof err === 'object' && err !== null && 'code' in err) { - const code: unknown = err.code; - return typeof code === 'string' ? code : undefined; - } - return undefined; + return { store, close }; } diff --git a/apps/cli/src/secrets/keychain.ts b/apps/cli/src/secrets/keychain.ts new file mode 100644 index 00000000..674f6916 --- /dev/null +++ b/apps/cli/src/secrets/keychain.ts @@ -0,0 +1,43 @@ +/** + * The CLI's OS-keychain **seam** (workstream **2.C**) — the platform-free contract every consumer (the + * `provider` commands, the key resolver) depends on, so they are unit-tested with an in-memory fake and + * never touch the real keychain. The concrete `@napi-rs/keyring` accessor (the only file that loads the + * native module) lives in `./os-keychain.ts` and implements this interface ([ADR-0019](../../../../docs/decisions/0019-cli-node-keychain-library.md)). + * + * Naming is the cross-surface scheme from [keychain-and-secrets.md](../../../../docs/reference/desktop/keychain-and-secrets.md): + * `service = relavium`, `account = {providerId}:{keyId}` — one entry per key so keys rotate/revoke + * independently. The key VALUE never leaves the keychain except as the outbound request's `Authorization` + * header at call time; the `llm_providers` row stores only the `account` ref ([ADR-0006](../../../../docs/decisions/0006-os-keychain-for-api-keys.md)). + */ + +const DEFAULT_KEY_ID = 'default'; + +/** The keychain `account` for a provider key — `{providerId}:{keyId}` (keychain-and-secrets.md §Entry naming). */ +export function keychainAccount(providerId: string, keyId: string = DEFAULT_KEY_ID): string { + return `${providerId}:${keyId}`; +} + +/** + * A platform-keychain backend failure — the store is **present but unusable** (locked keychain, no Linux + * Secret Service, a denied prompt). Deliberately **distinct** from a key simply being absent (`get` → `null`). + * The two paths treat it differently: a **write** (`provider set-key`) **surfaces** it (you cannot silently + * fail to store a key), while the run-time **key resolver** (`keyFor`) treats it like absence and falls + * through to the env-var source — the CLI's documented no-keychain path. (An env var is not an on-disk + * plaintext store, so this does not violate the no-silent-plaintext-fallback rule — + * keychain-and-secrets.md §Operational notes.) + */ +export class KeychainUnavailableError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'KeychainUnavailableError'; + } +} + +export interface KeychainStore { + /** The secret for `account`, or `null` when no entry exists. Throws {@link KeychainUnavailableError} on a backend failure. */ + get: (account: string) => string | null; + /** Store (overwrite) the secret for `account`. Throws {@link KeychainUnavailableError} on a backend failure. */ + set: (account: string, secret: string) => void; + /** Delete the entry; returns `false` when none existed. Throws {@link KeychainUnavailableError} on a backend failure. */ + delete: (account: string) => boolean; +} diff --git a/apps/cli/src/secrets/os-keychain.ts b/apps/cli/src/secrets/os-keychain.ts new file mode 100644 index 00000000..680abd6a --- /dev/null +++ b/apps/cli/src/secrets/os-keychain.ts @@ -0,0 +1,54 @@ +import { Entry } from '@napi-rs/keyring'; + +import { KeychainUnavailableError, type KeychainStore } from './keychain.js'; + +/** + * The concrete OS-keychain accessor over `@napi-rs/keyring` (a maintained N-API credential lib — **not** the + * archived `keytar`, [ADR-0019](../../../../docs/decisions/0019-cli-node-keychain-library.md)). This is the + * **only** module that loads the native binding; the `provider` commands and the key resolver depend on the + * platform-free {@link KeychainStore} interface (`./keychain.ts`), so they unit-test against an in-memory + * fake. Production (`commands/specs.ts`) injects this one. + */ + +const SERVICE = 'relavium'; + +/** Wire a {@link KeychainStore} over the OS keychain via `@napi-rs/keyring` (service `relavium`). */ +export function createOsKeychainStore(service: string = SERVICE): KeychainStore { + const entry = (account: string): Entry => new Entry(service, account); + return { + get: (account) => { + try { + return entry(account).getPassword(); // `null` for an absent entry — not an error in @napi-rs/keyring + } catch (err) { + throw new KeychainUnavailableError(`OS keychain is unavailable: ${errMessage(err)}`, { + cause: err, + }); + } + }, + set: (account, secret) => { + try { + entry(account).setPassword(secret); + } catch (err) { + throw new KeychainUnavailableError( + `could not write to the OS keychain: ${errMessage(err)}`, + { + cause: err, + }, + ); + } + }, + delete: (account) => { + try { + return entry(account).deletePassword(); + } catch (err) { + throw new KeychainUnavailableError(`could not access the OS keychain: ${errMessage(err)}`, { + cause: err, + }); + } + }, + }; +} + +function errMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/apps/cli/src/secrets/read-secret.ts b/apps/cli/src/secrets/read-secret.ts new file mode 100644 index 00000000..fac7c51e --- /dev/null +++ b/apps/cli/src/secrets/read-secret.ts @@ -0,0 +1,35 @@ +import { CliError } from '../process/errors.js'; + +/** + * Read a secret (an API key) from **stdin** for `relavium provider set-key` (2.C). The key is deliberately + * NOT a CLI argument — argv leaks into the process list (`ps`), shell history, and CI logs. Piping keeps it + * off all of those: `echo "$KEY" | relavium provider set-key anthropic` (or a heredoc). + * + * The guard is on **`process.stdin.isTTY`** (the only thing that decides whether the user would type the + * secret into an echoing terminal) — NOT stdout, so `set-key ... > out.txt` (stdin still a TTY) still errors + * rather than reading + echoing a typed key. A hidden interactive prompt is a later enhancement (it rides + * the `@clack/prompts` wizard infra arriving with 2.E). + */ +export async function readSecretFromStdin(): Promise { + if (process.stdin.isTTY === true) { + throw new CliError( + 'invalid_invocation', + 'pipe the API key on stdin — e.g. `echo "$KEY" | relavium provider set-key ` (a key is never passed as an argument).', + ); + } + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) { + // Narrow without an unsafe cast: an un-encoded stdin stream yields Buffer chunks; a string only if an + // encoding was set (it never is here). Any other shape is ignored rather than coerced. + if (Buffer.isBuffer(chunk)) { + chunks.push(chunk); + } else if (typeof chunk === 'string') { + chunks.push(Buffer.from(chunk, 'utf8')); + } + } + const key = Buffer.concat(chunks).toString('utf8').trim(); + if (key === '') { + throw new CliError('invalid_invocation', 'no API key was read from stdin (empty input).'); + } + return key; +} diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 1d2db946..3561d064 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -1,6 +1,6 @@ # CLI Command Reference (`relavium`) -> Last updated: 2026-06-03 +> Last updated: 2026-06-23 - **Status**: Reference (partial — surface defined, exact flags to be finalized as the CLI is built) - **Surface**: CLI (`relavium`) @@ -110,7 +110,7 @@ The command set below is the confirmed surface. Subcommands marked _(planned)_ a | `relavium budget resume [--approve\|--abort]` | Resume a run suspended at a budget cap (`budget:paused`, `on_exceed: pause_for_approval`) — approve to continue or abort. The non-interactive operator path for [ADR-0028](../../decisions/0028-workflow-resource-governance.md). | | `relavium init` _(planned)_ | Initialize a `.relavium/` directory in the current project. | | `relavium agent ` _(planned)_ | Manage agents (list / create / test). | -| `relavium provider ` _(planned)_ | Manage providers and API keys in the OS keychain. | +| `relavium provider ` | Manage providers and API keys in the OS keychain (`list` / `add` / `set-key` / `remove-key` / `test`). | ### `relavium run` @@ -130,7 +130,7 @@ relavium run ./workflows/code-review.relavium.yaml --input file=./src/index.ts - - A missing API key for an inline agent's **primary** provider is caught **pre-flight** as an invalid invocation (exit `2`) naming the `RELAVIUM__API_KEY` to set, before the run starts. The pre-flight is a strict subset of the keys a run may touch, so it never blocks a valid run: a `fallback_chain` provider's key (read only if the chain fails over to it) and a `$ref`-resolved external agent's key (until `$ref` resolution lands, 2.M–2.Q) are conditional and instead surface mid-run as a run failure (exit `1`). - On a `human_gate` node the run **pauses**: in interactive mode it prompts inline; in CI mode it exits with the gate-paused code (`3`, see [Exit codes](#exit-codes)) and can be resumed with `relavium gate`. The emitted `human_gate:paused` event carries the `gateId` needed for the resume (`relavium gate --gate `); with `--json` it is on the NDJSON event line, otherwise read it from `relavium status`/`relavium logs`. -> **Implementation status (as of workstream 2.F).** `run` is wired to the `@relavium/core` engine: path/id resolution, `--input` coercion, the full lifecycle event stream, exit codes `0`/`1`/`2`/`3`, SIGINT→cancel, and the stable `--json` NDJSON machine contract (stdout = pure RunEvent stream, diagnostics → stderr; see [above](#the---json-machine-output-contract)) are live. Still landing in later workstreams: the rich `ink` TUI (2.E — until then a minimal one-line-per-event human renderer), the interactive inline gate prompt and `relavium gate` resume (2.G/2.H — until then a `human_gate` node exits `3`), provider keys from the OS keychain (2.C — until then the `RELAVIUM__API_KEY` environment fallback, the per-invocation key source in the [config-spec.md](../contracts/config-spec.md) precedence), and durable run history (2.H — until then runs are in-memory). Built-in tools that need a host capability (filesystem, process, egress) are **fail-closed** (unavailable) pending a security-reviewed capability workstream. +> **Implementation status (as of workstream 2.C).** `run` is wired to the `@relavium/core` engine: path/id resolution, `--input` coercion, the full lifecycle event stream, exit codes `0`/`1`/`2`/`3`, SIGINT→cancel, and the stable `--json` NDJSON machine contract (stdout = pure RunEvent stream, diagnostics → stderr; see [above](#the---json-machine-output-contract)) are live. Provider keys resolve from the **OS keychain → `RELAVIUM__API_KEY` env var → error** (2.C; manage them with `relavium provider`), and runs persist to durable history (2.H). Two pieces still land in later workstreams: the rich `ink` TUI (2.E — until then a minimal one-line-per-event human renderer) and the interactive inline gate prompt + `relavium gate` resume (2.G — until then a `human_gate` node exits `3`). Built-in tools that need a host capability (filesystem, process, egress) are **fail-closed** (unavailable) pending a security-reviewed capability workstream. ### `relavium list` @@ -166,6 +166,32 @@ relavium gate --gate --approve # disambiguate when > - `--gate ` selects **which** pending gate to resolve. The resume contract is `engine.resume(runId, gateId, decision)` — `gateId` is mandatory on the resume path (it is carried on the `human_gate:paused` event; see [sse-event-schema.md](../contracts/sse-event-schema.md) and `resume_run` in [ipc-contract.md](../contracts/ipc-contract.md)). `--gate` is **optional on the CLI**: when exactly one gate is pending the CLI fills it in automatically; when **more than one** gate is pending it is **required**, and omitting it is an invalid invocation (exit `2`) listing the pending `gateId`s. - Get the pending `gateId`(s) from `relavium status` or `relavium logs ` (both print them — see below). +### `relavium provider` + +Registers LLM providers and manages their API keys in the **OS keychain** (workstream 2.C; `@napi-rs/keyring`, +[ADR-0019](../../decisions/0019-cli-node-keychain-library.md)). The **key value never leaves the keychain**: +the `llm_providers` row stores only the keychain `account` ref, display shows only a hint (last 4 chars), and a +key is read solely at LLM-call time. Known providers: `anthropic`, `openai`, `gemini`, `deepseek`. + +```bash +relavium provider list # registered providers + whether a key is set +relavium provider add anthropic # register a provider (its default base URL) +echo "$ANTHROPIC_API_KEY" | relavium provider set-key anthropic # store a key (read from STDIN, never argv) +relavium provider test anthropic # verify the key with a minimal live request +relavium provider remove-key anthropic # delete the key from the keychain +``` + +- **`set-key` reads the key from stdin**, never a CLI argument (argv leaks into `ps`, shell history, and CI + logs); pipe it or use a heredoc. The key is stored in the OS keychain under the canonical entry-naming + scheme ([keychain-and-secrets.md](../desktop/keychain-and-secrets.md#entry-naming)). +- **`add` / `set-key`** auto-register the provider row. `--base-url ` on `add` records a custom endpoint (validated as an **HTTPS** URL); it is **not yet honored by request routing** — adapters use their built-in endpoints today. Wiring a custom base URL to outbound requests lands later **with** the full SSRF base-URL gate (HTTPS-only; private/loopback/metadata ranges blocked) per [security-review.md](../../standards/security-review.md), before any key is attached to it. +- **`test`** does a 1-token `generate` through `@relavium/llm`; `--model ` overrides the cheap default. A bad + key fails cleanly (exit `2`) without echoing the key. +- **Key resolution** (used by `run` + `test`): **OS keychain → `RELAVIUM__API_KEY` env var → error**. + The env var is the headless/CI source; the `secrets.enc` encrypted-file fallback is deferred past v1.0 + ([keychain-and-secrets.md](../desktop/keychain-and-secrets.md)). An unavailable keychain (locked / no Linux + Secret Service) surfaces a clean error — never a silent plaintext fallback. + ## Exit codes CI relies on deterministic exit codes: diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index 891a5246..043a3c33 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -19,11 +19,11 @@ Created on first launch. Holds user-wide preferences and the registry of MCP ser config.toml # global preferences, MCP server registrations, update channel ipc.json # desktop loopback server discovery (port, authToken, pid) — see ipc-contract.md history.db # cross-project run history (SQLite; desktop: SQLCipher, CLI: unencrypted + 0600/0700 OS perms — ADR-0050) — see desktop/database-schema.md - secrets.enc # OPTIONAL encrypted-file key fallback (headless/CI) — see keychain-and-secrets.md + secrets.enc # RESERVED encrypted-file key fallback (deferred past v1.0; Argon2id KDF) — see keychain-and-secrets.md tmp/ # scratch space agents may write to under the sandbox tier ``` -> API keys are **not** stored in `config.toml`. By default they live in the OS keychain; `secrets.enc` is an opt-in fallback only. See [../desktop/keychain-and-secrets.md](../desktop/keychain-and-secrets.md). +> API keys are **not** stored in `config.toml`. They live in the OS keychain; the CLI's headless/CI fallback is the env var `RELAVIUM__API_KEY`. The `secrets.enc` encrypted-file fallback is **deferred past v1.0** (it needs a proper Argon2id KDF). See [../desktop/keychain-and-secrets.md](../desktop/keychain-and-secrets.md). ### Per-project — `/.relavium/` diff --git a/docs/reference/desktop/keychain-and-secrets.md b/docs/reference/desktop/keychain-and-secrets.md index 696883ce..7593fa32 100644 --- a/docs/reference/desktop/keychain-and-secrets.md +++ b/docs/reference/desktop/keychain-and-secrets.md @@ -73,7 +73,7 @@ The **Phase-2 CLI does not use SQLCipher**: it opens the same `history.db` with - **SQLCipher passphrase must be set before plugin init.** Derive it from a stable machine secret (not hardcoded) so restarts do not require a user prompt. - **Capability gating.** Every keychain plugin call the frontend can trigger must be declared in the Tauri v2 capabilities manifest (`src-tauri/capabilities/`); a missing capability surfaces as a silent "not allowed" runtime error. - **Linux dependency.** libsecret requires a Secret Service provider to be running; if none is present in v1.0, key storage is unavailable and the app surfaces an error (the KDF-based file fallback is deferred — see [Encrypted-file fallback — deferred past v1.0](#encrypted-file-fallback--deferred-past-v10)). -- **No silent plaintext fallback.** If the OS keychain cannot be used, the app surfaces an error rather than writing a key in the clear; it never hand-rolls an alternative key store. +- **No silent plaintext fallback.** If the OS keychain cannot be used, the app surfaces an error rather than writing a key in the clear; it never hand-rolls an alternative key store. _(The CLI's run-time key resolver additionally falls through to the `RELAVIUM__API_KEY` env var when the keychain is absent or unavailable — an env var is not an on-disk plaintext store, so this is not a plaintext fallback; the `provider set-key` **write** path still surfaces an unavailable keychain as a clean error.)_ ## Phase 2 divergence diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index b7b2d055..2c95cf61 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -43,10 +43,12 @@ consumer), ✅ Done (PR #41, 2026-06-22), which also adds the `defaultProviders( and **2.F** (the `--json` CI machine-output contract — pure-NDJSON stdout, diagnostics → stderr), ✅ Done (PR #42, 2026-06-22) behind [ADR-0049](../decisions/0049-cli-machine-output-contract.md); and **2.K** (the engine regression harness, now the engine's CI regression gate), ✅ Done -(PR #43, 2026-06-23) — **reaching global milestone M3**. -**Next pickup:** **2.H** (durable run history — the highest-leverage feeder, unblocking -2.I / 2.G / 2.M / 2.S), with the **2.E** (ink TUI) feeder also open; the full status-aware order -is the [Remaining build order](phases/phase-2-cli.md#remaining-build-order) queue. The CLI also lands the inbound MCP client (2.R, +(PR #43, 2026-06-23) — **reaching global milestone M3**; and **2.H** (durable local run history via +`@relavium/db` — the `RunStore` writer + read API the gate-resume/list/logs/status surfaces consume), +✅ Done (PR #44, 2026-06-23) behind [ADR-0050](../decisions/0050-cli-history-db-at-rest-posture.md). +**Next pickup:** **2.C** (provider/keys — independent, unblocks 2.R + 2.M + live runs), with the +**2.E** (ink TUI) feeder also open; the full status-aware order is the +[Remaining build order](phases/phase-2-cli.md#remaining-build-order) queue. The CLI also lands the inbound MCP client (2.R, [ADR-0034](../decisions/0034-mcp-client-sdk-dependency.md)) off the M3 critical path. See the [Phase 2 workstreams](phases/phase-2-cli.md) and the [sequencing plan](phases/phase-2-cli.md#sequencing--parallelization). diff --git a/docs/roadmap/phases/phase-2-cli.md b/docs/roadmap/phases/phase-2-cli.md index 6272c5df..30482594 100644 --- a/docs/roadmap/phases/phase-2-cli.md +++ b/docs/roadmap/phases/phase-2-cli.md @@ -1,6 +1,6 @@ # Phase 2 — CLI -> Status: In progress (Product Phase 1, build phase 2). **2.A** (CLI skeleton + process contract) and **2.B** (config resolution) are ✅ **Done (PR #40, 2026-06-22)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md) + [ADR-0048](../../decisions/0048-toml-config-parser.md); **2.D** (`run` → engine, the M3 keystone) is ✅ **Done (PR #41, 2026-06-22)**, and **2.F** (the `--json` CI machine-output contract) is ✅ **Done (PR #42, 2026-06-22)**, behind [ADR-0049](../../decisions/0049-cli-machine-output-contract.md), and **2.K** (the engine regression harness) is ✅ **Done (PR #43, 2026-06-23)** — so **global milestone M3 is reached**. The status-aware order for everything still open (next pickup: **2.H**) is the [Remaining build order](#remaining-build-order) queue. +> Status: In progress (Product Phase 1, build phase 2). **2.A** (CLI skeleton + process contract) and **2.B** (config resolution) are ✅ **Done (PR #40, 2026-06-22)**, behind [ADR-0047](../../decisions/0047-cli-framework-commander-ink-clack.md) + [ADR-0048](../../decisions/0048-toml-config-parser.md); **2.D** (`run` → engine, the M3 keystone) is ✅ **Done (PR #41, 2026-06-22)**, and **2.F** (the `--json` CI machine-output contract) is ✅ **Done (PR #42, 2026-06-22)**, behind [ADR-0049](../../decisions/0049-cli-machine-output-contract.md), and **2.K** (the engine regression harness) is ✅ **Done (PR #43, 2026-06-23)** — so **global milestone M3 is reached**; **2.H** (durable run history) is ✅ **Done (PR #44, 2026-06-23)**, behind [ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md). The status-aware order for everything still open (next pickup: **2.C**) is the [Remaining build order](#remaining-build-order) queue. - **Related**: [../README.md](../README.md), [phase-1-engine-and-llm.md](phase-1-engine-and-llm.md), [phase-3-desktop.md](phase-3-desktop.md), [../../reference/cli/commands.md](../../reference/cli/commands.md), [../../reference/contracts/config-spec.md](../../reference/contracts/config-spec.md), [../../reference/desktop/keychain-and-secrets.md](../../reference/desktop/keychain-and-secrets.md), [../../reference/contracts/sse-event-schema.md](../../reference/contracts/sse-event-schema.md), [../../reference/desktop/database-schema.md](../../reference/desktop/database-schema.md), [../../architecture/execution-model.md](../../architecture/execution-model.md), [../../architecture/shared-core-engine.md](../../architecture/shared-core-engine.md) @@ -189,8 +189,11 @@ keychain and VS Code `SecretStorage`. `api_key_keychain_ref`, …) to `@relavium/db`'s `llm_providers` table — never the key value. - Implement the headless fallback chain for CI / no-keychain hosts: env var - (e.g. `RELAVIUM__API_KEY`) → opt-in `~/.relavium/secrets.enc` - (AES-256-GCM) → hard error. No silent plaintext fallback. + (e.g. `RELAVIUM__API_KEY`) → hard error. No silent plaintext fallback. + _(The opt-in `~/.relavium/secrets.enc` encrypted-file fallback is **deferred past v1.0** — it + requires a proper KDF, e.g. Argon2id over a user passphrase, not a hand-rolled scheme; the + canonical [keychain-and-secrets.md](../../reference/desktop/keychain-and-secrets.md) §Encrypted-file + fallback governs. v1.0 is keychain + env var only.)_ - On display, show only a **key hint** (last 4 chars); never echo a full key to stdout, logs, or `--json` output. - Resolve keys **at LLM-call time** through the engine's provider seam so a key is @@ -320,7 +323,7 @@ approval to completion; a non-interactive run exits with the gate-paused code `3 and is later resumed by `relavium gate --approve`; a doubled decision does not double-advance; reject and `--input` paths both work end-to-end. -### 2.H — Local run history via `@relavium/db` +### 2.H — Local run history via `@relavium/db` — ✅ **Done (PR #44)** Wire durable SQLite history so runs persist their events, steps, and costs for later inspection — the same tables the desktop replays. @@ -642,24 +645,24 @@ This is the status × plan view; the dependency rationale for every row lives in [Ordered waves](#ordered-waves-each-wave-is-internally-parallel-waves-gate-left-to-right) — this table does not restate them, it only sequences what remains. -> **Status (2026-06-23):** ✅ **2.A · 2.B · 2.D · 2.F · 2.K** done — **M3 reached** · next pickup: **2.H**. -> (2.K's gate-resume + agent-replay halves remain deferred — they land with 2.G / later.) +> **Status (2026-06-23):** ✅ **2.A · 2.B · 2.D · 2.F · 2.K · 2.H** done — **M3 reached** · next pickup: **2.C**. +> (2.K's gate-resume + agent-replay halves remain deferred — they land with 2.G / later. 2.H shipped the +> durable history substrate + read API; the consuming commands `list`/`logs`/`status` are 2.I.) | Next | Lane | Why now | Blockers (all met on arrival) | |---|---|---|---| -| **1. 2.H** durable history | feeder | highest leverage — unblocks 2.I, 2.G, 2.M **and** 2.S; go/no-go #2 | 2.D ✓ | -| **2. 2.C** provider / keys | feeder | independent; unblocks 2.R + 2.M; go/no-go #5 | 2.B ✓ | -| **3. 2.E** ink TUI | feeder | go/no-go #1; the shared ink infra 2.G + 2.M reuse | 2.D ✓ | -| **4. 2.G** human gate + resume | feeder | go/no-go #3 **and closes 2.K's deferred gate-resume half** | 2.E · 2.H · 2.F ✓ | -| **5. 2.I** list / logs / status | feeder | go/no-go #2 (read side); blocks nothing | 2.H | -| **6. 2.L** package & publish | ◆ spine | go/no-go #7 — **all 7 [exit criteria](#exit-criteria-go--no-go) hold here → Phase 3 may start** | 2.K whole (via 2.G) | -| **7. 2.S** media host-wiring | additive | biggest lane + the lone SSRF security review; **first** among the additive lanes — never tailed | 2.D · 2.H | -| **8. 2.R** MCP client | additive | inbound MCP tools | 2.B · 2.C | -| **9. 2.M → 2.N–2.Q** chat | additive | agent-first chat surface | 2.C · 2.H · 2.E | -| **10. 2.J** create / import / export | additive | cheap filler — drop into any low-energy slot | 2.A ✓ | - -- **Gate-closing backbone — `2.H → 2.C → 2.E → 2.G → 2.I → 2.L`:** these six PRs flip all - seven exit criteria (2.K is done). The remaining four (**2.S, 2.R, chat, 2.J**) +| **1. 2.C** provider / keys | feeder | independent; unblocks 2.R + 2.M; go/no-go #5 | 2.B ✓ | +| **2. 2.E** ink TUI | feeder | go/no-go #1; the shared ink infra 2.G + 2.M reuse | 2.D ✓ | +| **3. 2.G** human gate + resume | feeder | go/no-go #3 **and closes 2.K's deferred gate-resume half** | 2.E · 2.H ✓ · 2.F ✓ | +| **4. 2.I** list / logs / status | feeder | go/no-go #2 (read side); blocks nothing | 2.H ✓ | +| **5. 2.L** package & publish | ◆ spine | go/no-go #7 — **all 7 [exit criteria](#exit-criteria-go--no-go) hold here → Phase 3 may start** | 2.K whole (via 2.G) | +| **6. 2.S** media host-wiring | additive | biggest lane + the lone SSRF security review; **first** among the additive lanes — never tailed | 2.D · 2.H ✓ | +| **7. 2.R** MCP client | additive | inbound MCP tools | 2.B · 2.C | +| **8. 2.M → 2.N–2.Q** chat | additive | agent-first chat surface | 2.C · 2.H ✓ · 2.E | +| **9. 2.J** create / import / export | additive | cheap filler — drop into any low-energy slot | 2.A ✓ | + +- **Gate-closing backbone — `2.C → 2.E → 2.G → 2.I → 2.L`:** these five PRs flip the remaining + exit criteria (2.K + 2.H are done). The remaining four (**2.S, 2.R, chat, 2.J**) complete in-phase but do **not** block starting Phase 3. - **2.K fully closes at step 4 (2.G).** Its deferred gate-resume scenario can only be exercised once the gate pause/resume surface exists, so 2.L (step 6) must follow 2.G even diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 85137f9c..b4cf504b 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -80,6 +80,16 @@ export { type RunRecord, } from './run-history-store.js'; +// Provider registry (2.C) — CRUD over the non-secret `llm_providers` catalog the CLI's `relavium provider` +// commands manage. The key VALUE never lives here — only the OS-keychain `account` ref (ADR-0006/0019). +export { + createProviderStore, + type ProviderStore, + type ProviderStoreDeps, + type ProviderRecord, + type ProviderUpsert, +} from './provider-store.js'; + // Media store (1.AF, ADR-0042) — the host-side content-addressed blob store the engine references by // handle. Node-side (node:crypto + node:fs); a host wires one into ExecutionHost.mediaStore. The pure // engine never imports this — it depends only on the @relavium/shared `MediaStore` interface. diff --git a/packages/db/src/provider-store.test.ts b/packages/db/src/provider-store.test.ts new file mode 100644 index 00000000..90b1d199 --- /dev/null +++ b/packages/db/src/provider-store.test.ts @@ -0,0 +1,136 @@ +import { eq } from 'drizzle-orm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createClient, runMigrations, type DbClient } from './client.js'; +import { llmProviders } from './schema.js'; +import { createProviderStore, type ProviderStore } from './provider-store.js'; + +const TS_MS = new Date('2026-06-23T12:00:00.000Z').getTime(); + +describe('createProviderStore', () => { + let client: DbClient; + let store: ProviderStore; + let next: number; + + beforeEach(() => { + client = createClient(':memory:'); + runMigrations(client.db); + next = 0; + store = createProviderStore(client.db, { + uuid: () => `00000000-0000-4000-8000-${String(++next).padStart(12, '0')}`, + now: () => TS_MS, + }); + }); + + afterEach(() => { + client.sqlite.close(); + }); + + it('upserts a provider row and reads it back', () => { + const rec = store.upsert({ + name: 'anthropic', + displayName: 'Anthropic', + baseUrl: 'https://api.anthropic.com', + }); + expect(rec.name).toBe('anthropic'); + expect(rec.baseUrl).toBe('https://api.anthropic.com'); + expect(rec.apiKeyKeychainRef).toBeUndefined(); // no key set yet + expect(store.get('anthropic')?.id).toBe(rec.id); + }); + + it('upsert is idempotent by name (updates, never duplicates)', () => { + const a = store.upsert({ + name: 'openai', + displayName: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + }); + const b = store.upsert({ + name: 'openai', + displayName: 'OpenAI', + baseUrl: 'https://proxy.example/v1', + }); + expect(b.id).toBe(a.id); // same row + expect(b.baseUrl).toBe('https://proxy.example/v1'); // updated + expect(b.defaultHeaders).toEqual({}); // not double-encoded into a "{}" string on update + expect(store.list()).toHaveLength(1); + }); + + it('preserves non-empty defaultHeaders verbatim across an update that omits them', () => { + store.upsert({ + name: 'openai', + displayName: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + defaultHeaders: { 'x-org': 'acme', 'x-beta': 'on' }, + }); + // An update that omits defaultHeaders must keep the stored object — never drop it, never re-encode + // the already-JSON string into a `"{...}"` string (the regression fixed in 8ae794c). + const b = store.upsert({ + name: 'openai', + displayName: 'OpenAI (proxied)', + baseUrl: 'https://proxy.example/v1', + }); + expect(b.defaultHeaders).toEqual({ 'x-org': 'acme', 'x-beta': 'on' }); + expect(typeof b.defaultHeaders).toBe('object'); // a parsed object, not a double-encoded string + }); + + it('preserves createdAt and advances updatedAt on update', () => { + let clock = 1_000; + const timed = createProviderStore(client.db, { + uuid: () => '00000000-0000-4000-8000-000000000abc', + now: () => clock, + }); + const a = timed.upsert({ name: 'gemini', displayName: 'Gemini', baseUrl: 'https://g.example' }); + clock = 2_000; + const b = timed.upsert({ + name: 'gemini', + displayName: 'Gemini 2', + baseUrl: 'https://g.example', + }); + expect(b.createdAt).toBe(a.createdAt); // createdAt is never clobbered on update + expect(new Date(b.updatedAt).getTime()).toBeGreaterThan(new Date(a.updatedAt).getTime()); + }); + + it('rejects a corrupt default_headers value at the read boundary (loud, not silent)', () => { + store.upsert({ name: 'openai', displayName: 'OpenAI', baseUrl: 'https://api.openai.com/v1' }); + // Simulate a corrupt/foreign-shaped column (a direct edit / bad migration) — the read must abort. + client.db + .update(llmProviders) + .set({ defaultHeaders: '["not","an","object"]' }) + .where(eq(llmProviders.name, 'openai')) + .run(); + expect(() => store.get('openai')).toThrowError(/not a JSON object/); + }); + + it('records and clears the keychain ref — and NEVER stores a key value', () => { + store.upsert({ + name: 'anthropic', + displayName: 'Anthropic', + baseUrl: 'https://api.anthropic.com', + }); + store.setKeychainRef('anthropic', 'anthropic:default'); + expect(store.get('anthropic')?.apiKeyKeychainRef).toBe('anthropic:default'); + + // The raw row holds only the ref — the schema has no column for a key value. + const row = client.db + .select() + .from(llmProviders) + .where(eq(llmProviders.name, 'anthropic')) + .get(); + expect(row?.apiKeyKeychainRef).toBe('anthropic:default'); + expect(JSON.stringify(row)).not.toContain('sk-'); // no key-shaped material anywhere on the row + + store.clearKeychainRef('anthropic'); + expect(store.get('anthropic')?.apiKeyKeychainRef).toBeUndefined(); // ref cleared, row stays registered + expect(store.get('anthropic')).toBeDefined(); + }); + + it('lists active providers by name', () => { + store.upsert({ name: 'openai', displayName: 'OpenAI', baseUrl: 'https://api.openai.com/v1' }); + store.upsert({ + name: 'anthropic', + displayName: 'Anthropic', + baseUrl: 'https://api.anthropic.com', + }); + expect(store.list().map((p) => p.name)).toEqual(['anthropic', 'openai']); // sorted by name + }); +}); diff --git a/packages/db/src/provider-store.ts b/packages/db/src/provider-store.ts new file mode 100644 index 00000000..c36a2e82 --- /dev/null +++ b/packages/db/src/provider-store.ts @@ -0,0 +1,167 @@ +import { and, asc, eq, isNull } from 'drizzle-orm'; + +import type { Db } from './client.js'; +import { llmProviders, type LlmProviderRow, type NewLlmProviderRow } from './schema.js'; +import { epochMsToIso } from './time.js'; + +/** + * Provider registry store (workstream **2.C**) — CRUD over the `llm_providers` catalog the CLI's + * `relavium provider` commands manage, mirroring `session-store.ts` / `run-history-store.ts` (the mappers + * are the single domain↔row + validation boundary; ids/timestamps are store-minted via injected deps). + * + * **The API key value is NEVER stored here** — only `apiKeyKeychainRef`, the OS-keychain `account` + * identifier (`{providerId}:{keyId}`), per ADR-0006 / keychain-and-secrets.md. The key itself lives in the + * OS keychain (the CLI accessor is `apps/cli/src/secrets/keychain.ts`, `@napi-rs/keyring` per ADR-0019). + */ + +/** A registered provider — the non-secret row, with ISO timestamps. `apiKeyKeychainRef` is the keychain ref, never the key. */ +export interface ProviderRecord { + readonly id: string; + readonly name: string; + readonly displayName: string; + readonly baseUrl: string; + /** The OS-keychain `account` holding this provider's key, or `undefined` when no key is set. NEVER the key. */ + readonly apiKeyKeychainRef?: string; + readonly defaultHeaders: Record; + readonly isActive: boolean; + readonly createdAt: string; + readonly updatedAt: string; +} + +/** Fields a caller supplies to register/update a provider (the store mints id + timestamps). */ +export interface ProviderUpsert { + readonly name: string; + readonly displayName: string; + readonly baseUrl: string; + readonly defaultHeaders?: Record; +} + +export interface ProviderStoreDeps { + readonly uuid: () => string; + readonly now: () => number; +} + +export interface ProviderStore { + /** All registered (not soft-deleted) providers, by name. */ + list: () => ProviderRecord[]; + /** One provider by its unique `name`, or `undefined`. */ + get: (name: string) => ProviderRecord | undefined; + /** Create or update (by `name`) the non-secret provider row; returns the resulting record. */ + upsert: (input: ProviderUpsert) => ProviderRecord; + /** Record that a key is stored (the keychain `account` ref) — never the key value. */ + setKeychainRef: (name: string, ref: string) => void; + /** Clear the keychain ref (the key was removed); the provider row stays registered. */ + clearKeychainRef: (name: string) => void; +} + +/** + * Parse a stored `default_headers` JSON-text column into a validated `Record` — `unknown` + * + a runtime shape check at the DB read boundary (code-style-typescript.md §Boundaries; no unsafe `as`). + * A corrupt/foreign-shaped value (malformed JSON, a non-object, or a non-string member) aborts the read + * loudly rather than propagating a wrongly-typed value, mirroring the sibling stores' `JSON.parse` posture. + */ +function parseStringRecord(json: string): Record { + const parsed: unknown = JSON.parse(json); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new TypeError('llm_providers.default_headers is not a JSON object'); + } + const out: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value !== 'string') { + throw new TypeError(`llm_providers.default_headers['${key}'] is not a string`); + } + out[key] = value; + } + return out; +} + +function fromRow(row: LlmProviderRow): ProviderRecord { + return { + id: row.id, + name: row.name, + displayName: row.displayName, + baseUrl: row.baseUrl, + ...(row.apiKeyKeychainRef === null ? {} : { apiKeyKeychainRef: row.apiKeyKeychainRef }), + defaultHeaders: parseStringRecord(row.defaultHeaders), + isActive: row.isActive, + createdAt: epochMsToIso(row.createdAt), + updatedAt: epochMsToIso(row.updatedAt), + }; +} + +/** Wire a {@link ProviderStore} over a `@relavium/db` connection. */ +export function createProviderStore(db: Db, deps: ProviderStoreDeps): ProviderStore { + const activeRow = (name: string): LlmProviderRow | undefined => + db + .select() + .from(llmProviders) + .where(and(eq(llmProviders.name, name), isNull(llmProviders.deletedAt))) + .get(); + + return { + list: () => + db + .select() + .from(llmProviders) + .where(isNull(llmProviders.deletedAt)) + .orderBy(asc(llmProviders.name)) + .all() + .map(fromRow), + + get: (name) => { + const row = activeRow(name); + return row === undefined ? undefined : fromRow(row); + }, + + upsert: (input) => { + const t = deps.now(); + const existing = activeRow(input.name); + if (existing === undefined) { + const row: NewLlmProviderRow = { + id: deps.uuid(), + name: input.name, + displayName: input.displayName, + baseUrl: input.baseUrl, + defaultHeaders: JSON.stringify(input.defaultHeaders ?? {}), + createdAt: t, + updatedAt: t, + }; + db.insert(llmProviders).values(row).run(); + } else { + db.update(llmProviders) + .set({ + displayName: input.displayName, + baseUrl: input.baseUrl, + // `existing.defaultHeaders` is already the stored JSON STRING — keep it verbatim when the caller + // supplies none; stringify only a fresh value. (Re-stringifying the string would double-encode it.) + defaultHeaders: + input.defaultHeaders === undefined + ? existing.defaultHeaders + : JSON.stringify(input.defaultHeaders), + updatedAt: t, + }) + .where(eq(llmProviders.id, existing.id)) + .run(); + } + const row = activeRow(input.name); + if (row === undefined) { + throw new Error(`provider '${input.name}' not found after upsert`); // unreachable — just inserted/updated + } + return fromRow(row); + }, + + setKeychainRef: (name, ref) => { + db.update(llmProviders) + .set({ apiKeyKeychainRef: ref, updatedAt: deps.now() }) + .where(and(eq(llmProviders.name, name), isNull(llmProviders.deletedAt))) + .run(); + }, + + clearKeychainRef: (name) => { + db.update(llmProviders) + .set({ apiKeyKeychainRef: null, updatedAt: deps.now() }) + .where(and(eq(llmProviders.name, name), isNull(llmProviders.deletedAt))) + .run(); + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9686a827..3a7dab5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,9 @@ catalogs: '@jitl/quickjs-singlefile-mjs-release-sync': specifier: ^0.32.0 version: 0.32.0 + '@napi-rs/keyring': + specifier: ^1.3.0 + version: 1.3.0 '@types/better-sqlite3': specifier: ^7.6.13 version: 7.6.13 @@ -116,6 +119,9 @@ importers: apps/cli: dependencies: + '@napi-rs/keyring': + specifier: 'catalog:' + version: 1.3.0 '@relavium/core': specifier: workspace:* version: link:../../packages/core @@ -1014,6 +1020,82 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/keyring-darwin-arm64@1.3.0': + resolution: {integrity: sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.3.0': + resolution: {integrity: sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.3.0': + resolution: {integrity: sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + resolution: {integrity: sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + resolution: {integrity: sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + resolution: {integrity: sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + resolution: {integrity: sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + resolution: {integrity: sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + resolution: {integrity: sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + resolution: {integrity: sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + resolution: {integrity: sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + resolution: {integrity: sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.3.0': + resolution: {integrity: sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==} + engines: {node: '>= 10'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2959,6 +3041,57 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/keyring-darwin-arm64@1.3.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.3.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.3.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.3.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.3.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.3.0': + optional: true + + '@napi-rs/keyring@1.3.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.3.0 + '@napi-rs/keyring-darwin-x64': 1.3.0 + '@napi-rs/keyring-freebsd-x64': 1.3.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.3.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.3.0 + '@napi-rs/keyring-linux-arm64-musl': 1.3.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-gnu': 1.3.0 + '@napi-rs/keyring-linux-x64-musl': 1.3.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.3.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.3.0 + '@napi-rs/keyring-win32-x64-msvc': 1.3.0 + '@pkgjs/parseargs@0.11.0': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1811b749..120460e0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -53,7 +53,12 @@ catalog: # parser (ADR-0048). Confined to apps/cli; the engine-deps guard excludes apps/, so these # never enter an engine package. `tsup` is the build tool (toolchain), catalogued like turbo. # Each declared in apps/cli's manifest at the workstream that first needs it (commander/tsup - # at 2.A; smol-toml at 2.B). Versions taken under the §9a cooling window. + # at 2.A; smol-toml at 2.B; @napi-rs/keyring at 2.C). Versions taken under the §9a cooling window. commander: ^12.1.0 smol-toml: ^1.3.4 tsup: ^8.5.0 + # CLI OS-keychain accessor (2.C) — a maintained N-API credential lib, NOT the archived + # keytar (ADR-0019); confined to apps/cli behind a `KeychainStore` interface. Floor pinned to the + # resolved/audited 1.3.x so the audited version is the shipped version (the most security-sensitive + # native dep — a re-pin, not a silent caret drift). + '@napi-rs/keyring': ^1.3.0