From cbaf6df454f3986164b62d58f144252d2572ea7b Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 20:54:16 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(keys):=20first-class=20BYOK=20key=20ma?= =?UTF-8?q?nagement=20=E2=80=94=20store,=20catalog,=20/api/keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auth/keys wave (2026-09-19): the server now owns the flat auth.json map directly (LH_AUTH_FILE from main.cjs; repo-root auth.json in dev, gitignored). The store reflects itself into the child's env at boot with fill-gap precedence — real env (incl. .env.local in dev) always wins — so every consumer keeps its plain process.env reads and saved keys take effect immediately, no restart; main.cjs's spawn-time injection retired (an env copy would shadow every settings-page change). Curated 4-key catalog (OpenRouter, Deepgram, Google search pair) with shape-only validation (no live pings — owner decision), 400s on bad pastes, last-4+length masking (plaintext never leaves the store), unknown file vars preserved + surfaced. Routes: GET /api/keys, PUT/DELETE /api/keys/:id. Tests: catalog/store/composition, with the env-sanitizing prologue (a dev shell may export real keys; tests use throwaway values only). --- .gitignore | 3 + app/api/keys/[id]/route.ts | 49 +++++++++++ app/api/keys/route.ts | 17 ++++ app/api/stt/token/route.ts | 4 +- config.ts | 18 ++++ electron/main.cjs | 36 ++++---- src/core/auth-keys.ts | 131 ++++++++++++++++++++++++++++ src/core/index.ts | 4 + src/core/ports.ts | 20 +++++ src/server/index.ts | 7 ++ src/server/keys.ts | 170 +++++++++++++++++++++++++++++++++++++ src/store/auth.ts | 63 ++++++++++++++ tests/auth-keys.test.ts | 88 +++++++++++++++++++ tests/auth-store.test.ts | 80 +++++++++++++++++ tests/server-keys.test.ts | 162 +++++++++++++++++++++++++++++++++++ 15 files changed, 831 insertions(+), 21 deletions(-) create mode 100644 app/api/keys/[id]/route.ts create mode 100644 app/api/keys/route.ts create mode 100644 src/core/auth-keys.ts create mode 100644 src/server/keys.ts create mode 100644 src/store/auth.ts create mode 100644 tests/auth-keys.test.ts create mode 100644 tests/auth-store.test.ts create mode 100644 tests/server-keys.test.ts diff --git a/.gitignore b/.gitignore index f6f9398..b1ef941 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ SCOPE.md # raw real-data import sources (user directive 2026-09-13: never commit) real-data-extraction/ dist/ + +# dev auth/keys store (auth-keys wave): repo-root auth.json — secrets never committed +auth.json diff --git a/app/api/keys/[id]/route.ts b/app/api/keys/[id]/route.ts new file mode 100644 index 0000000..7eb5cc1 --- /dev/null +++ b/app/api/keys/[id]/route.ts @@ -0,0 +1,49 @@ +/** + * PUT /api/keys/:id — save one key; DELETE — remove it (auth-keys wave, + * 2026-09-19). `id` is the env-var/file key name (OPENROUTER_API_KEY, …). + * + * PUT body {value}: shape-gated (owner decision — bad pastes are 400s + * naming the expected format, never stored; validation is SHAPE-ONLY, no + * live pings). Saves take effect immediately (file + env reflected; no + * restart). A var owned by the REAL env is persisted for later but NOT + * reflected — the response's status row carries shadowedByEnv so the page + * can explain why the saved key is not the live one. + * + * DELETE: gone from the file always; the env copy goes only when it was + * the store's own reflection (a real env var stays — env wins). Idempotent + * (absent key = success). Unknown ids are 404 — the curated catalog is + * the writable surface; unknown file vars are preserved, not editable. + */ +import { isKnownAuthKey } from "@/src/core"; +import { deleteKey, saveKey } from "@/src/server"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +export async function PUT(req: Request, { params }: Params) { + const { id } = await params; + if (!isKnownAuthKey(id)) { + return Response.json({ error: `unknown key "${id}"` }, { status: 404 }); + } + let body: unknown; + try { + body = await req.json(); + } catch { + return Response.json({ error: "body must be JSON" }, { status: 400 }); + } + if (!body || typeof body !== "object" || Array.isArray(body) || typeof (body as { value?: unknown }).value !== "string") { + return Response.json({ error: "body must be {value: string}" }, { status: 400 }); + } + const result = saveKey(id, (body as { value: string }).value); + if (!result.ok) return Response.json({ error: result.error }, { status: 400 }); + return Response.json({ status: result.status }); +} + +export async function DELETE(_req: Request, { params }: Params) { + const { id } = await params; + if (!isKnownAuthKey(id)) { + return Response.json({ error: `unknown key "${id}"` }, { status: 404 }); + } + return Response.json({ status: deleteKey(id) }); +} diff --git a/app/api/keys/route.ts b/app/api/keys/route.ts new file mode 100644 index 0000000..454526b --- /dev/null +++ b/app/api/keys/route.ts @@ -0,0 +1,17 @@ +/** + * GET /api/keys — the auth page's key list (auth-keys wave, 2026-09-19). + * + * Every curated key (present or not) plus unknown file vars surfaced as + * generic rows. Masked only — last 4 + length; PLAINTEXT NEVER LEAVES THE + * STORE THROUGH THIS API (owner decision). `source` tells the page whether + * the live value comes from the environment (dev .env.local / launch env) + * or the file, and `shadowedByEnv` explains why a saved key is not the + * live one (env always wins). + */ +import { keyStatuses } from "@/src/server"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + return Response.json({ keys: keyStatuses() }); +} diff --git a/app/api/stt/token/route.ts b/app/api/stt/token/route.ts index 8e021a7..f797c79 100644 --- a/app/api/stt/token/route.ts +++ b/app/api/stt/token/route.ts @@ -13,8 +13,10 @@ * /auth/grant (their docs, token-based auth FAQ) — plain read/transcribe * keys return FORBIDDEN here. */ +import { keyValue } from "@/src/server"; + export async function GET() { - const key = process.env.DEEPGRAM_API_KEY; + const key = keyValue("DEEPGRAM_API_KEY"); if (!key) { return Response.json( { message: "No DEEPGRAM_API_KEY — add it to auth.json (desktop) or .env.local (dev)." }, diff --git a/config.ts b/config.ts index c027120..ffc3afa 100644 --- a/config.ts +++ b/config.ts @@ -182,3 +182,21 @@ export const SETTINGS_FILE = path.join(DATA_ROOT, "settings.json"); * a vanished segment stops composing but stays on disk. */ export const PROMPTS_OVERRIDE_DIR = path.join(DATA_ROOT, "prompts"); + +/** + * THE AUTH/KEY STORE (auth-keys wave, 2026-09-19): BYOK provider keys in a + * flat {VAR: value} JSON map — the SAME file the Electron app has always + * hand-edited (owner decision: option A, zero migration). Desktop main.cjs + * passes LH_AUTH_FILE pointing at userData/auth.json (mode 0600); in dev + * (no Electron) the default is the repo-root auth.json (gitignored, same + * pattern as .env.local — a worktree may symlink it to the master copy). + * The SERVER reads/writes this file directly at call time (no restart to + * change a key); it reflects the values into process.env at boot with + * FILL-GAP precedence — real environment variables (incl. .env.local in + * dev) always win (src/server/keys.ts). Hand-edits still work: the file is + * re-read from disk on every API access. Secrets NEVER go in settings.json + * — this file keeps that role, alongside .env.local. + */ +export const AUTH_FILE = process.env.LH_AUTH_FILE + ? path.resolve(process.env.LH_AUTH_FILE) + : path.join(ROOT, "auth.json"); diff --git a/electron/main.cjs b/electron/main.cjs index a18cbc8..08f261f 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -4,7 +4,7 @@ * What this process is and is not: * IS: a thin launcher + security guard around the app's real server — * `next build`'s standalone server.js (Next 16), spawned as a child - * on 127.0.0.1 with the BYOK keys and the LH_DATA_DIR seam. + * on 127.0.0.1 with the BYOK auth file and the LH_DATA_DIR seam. * NOT: a host for any app logic. Everything — teacher turns, extraction, * the graph — runs in the Next server, unchanged. * @@ -17,8 +17,11 @@ * - all mutable state under userData (never inside the .app bundle — * translocated apps are read-only); * - keys ride the child's ENV, never argv; auth.json (BYOK) is a flat - * {VAR: value} JSON map, plaintext, mode 0600, hand-edited until the - * settings milestone builds the UI. + * {VAR: value} JSON map, plaintext, mode 0600 — server-managed since + * the auth-keys wave (2026-09-19): the child reads/writes it DIRECTLY + * (LH_AUTH_FILE below) and reflects values into its own env. The old + * spawn-time env injection retired with that wave — a copy made here + * would shadow every settings-page key change until restart. */ const { app, BrowserWindow, Menu, ipcMain, session, shell } = require("electron"); @@ -83,22 +86,12 @@ function freePort() { }); } -/** BYOK keys: auth.json in userData, flat {VAR: value} map, 0600. - * Absent file = no keys injected — the app runs, model calls fail with the - * engine's own clear error until the settings milestone adds the UI. - * Real environment variables win over the file (process.env precedence is - * also Next's own env loading order, so this stays consistent). */ -function authEnv() { - try { - const raw = JSON.parse(fs.readFileSync(AUTH_FILE(), "utf-8")); - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; - return Object.fromEntries( - Object.entries(raw).filter(([, v]) => typeof v === "string").map(([k, v]) => [k, v]), - ); - } catch { - return {}; // absent or corrupt — the documented state is "no keys yet" - } -} +/** BYOK keys: auth.json in userData, flat {VAR: value} map, 0600 — + * SERVER-MANAGED since the auth-keys wave (2026-09-19). This shell no + * longer reads or injects it: the child gets LH_AUTH_FILE below and does + * its own read/reflect/write, so a key saved in the settings page takes + * effect on the next model call — no restart. Real environment variables + * still win over the file inside the child (its documented rule). */ function log(line) { const stamp = new Date().toISOString(); @@ -123,12 +116,15 @@ function startServer(port) { cwd: path.dirname(serverEntry()), env: { ...process.env, - ...authEnv(), ELECTRON_RUN_AS_NODE: "1", NODE_ENV: "production", PORT: String(port), HOSTNAME: "127.0.0.1", // the server defaults to 0.0.0.0 — never LH_DATA_DIR: DATA_DIR(), + // The auth/keys store — the child reads AND writes this file itself + // (no env injection anymore: a spawn-time copy would shadow every + // settings-page key change until restart; env wins over file). + LH_AUTH_FILE: AUTH_FILE(), }, stdio: ["ignore", "pipe", "pipe"], }, diff --git a/src/core/auth-keys.ts b/src/core/auth-keys.ts new file mode 100644 index 0000000..afe3fd9 --- /dev/null +++ b/src/core/auth-keys.ts @@ -0,0 +1,131 @@ +/** + * The curated auth-key catalog + shape rules (auth-keys wave, 2026-09-19). + * PURE, browser-safe — no fs, no env. The catalog drives the auth page's + * key list (label, description, where-to-get docs link) and the write-side + * shape validation (owner decision: SHAPE-ONLY validation, no live pings — + * a revoked key looks fine until a real call fails; that trade was chosen + * over phoning providers). + * + * The store itself is the long-standing flat {VAR: value} auth.json map + * (see config.AUTH_FILE): keys are identified BY their environment-variable + * name — the same name consumers read today (OPENROUTER_API_KEY, …), so the + * catalog id IS the file key and the env var, one name everywhere. + * + * Unknown vars found in the file (outside the catalog) are PRESERVED + * verbatim and surfaced as generic entries — forward-compat for future + * entries (e.g. a plans-login token) without a schema change — but they + * are not hand-writable through the curated API (only the catalog's keys + * have shapes worth checking and docs worth showing). + */ + +/** One curated key entry. `id` is the env-var/file key name. */ +export interface AuthKeyInfo { + id: string; + /** Human label for the settings page. */ + label: string; + /** One-sentence what-is-this-for. */ + description: string; + /** Where the user gets a key (opens in the real browser via the shell). */ + docsUrl: string; +} + +export const AUTH_KEYS: readonly AuthKeyInfo[] = [ + { + id: "OPENROUTER_API_KEY", + label: "OpenRouter", + description: "The main model key — every teacher, prober and sub-agent call goes through it.", + docsUrl: "https://openrouter.ai/settings/keys", + }, + { + id: "DEEPGRAM_API_KEY", + label: "Deepgram", + description: "Speech-to-text key for dictation — needs ≥ Member permission in the Deepgram console to mint tokens.", + docsUrl: "https://console.deepgram.com/", + }, + { + id: "GOOGLE_SEARCH_API_KEY", + label: "Google Search API key", + description: "Web-search half of the Google pair (with the CSE ID) — powers the researcher sub-agent.", + docsUrl: "https://developers.google.com/custom-search/v1/introduction", + }, + { + id: "GOOGLE_CSE_ID", + label: "Google CSE ID", + description: "Programmable Search Engine ID — which engine the search API queries.", + docsUrl: "https://programmablesearchengine.google.com/controlpanel", + }, +] as const; + +const AUTH_KEY_IDS: ReadonlySet = new Set(AUTH_KEYS.map((k) => k.id)); + +export function isKnownAuthKey(id: string): boolean { + return AUTH_KEY_IDS.has(id); +} + +/** Shape-check result: ok, or a 400-ready message naming the expectation. */ +export type KeyShapeResult = { ok: true } | { ok: false; error: string }; + +/** + * The write-side shape gate (owner decision: reject bad pastes with 400 — + * a truncated or wrong-family key can never land in the store). Values are + * TRIMMED first (paste hygiene); empty-after-trim always fails. + * + * Families: OpenRouter keys are `sk-or-…`; Google API keys are `AIza…`; + * Programmable Search Engine IDs are either the legacy `digits:digits` + * form or a plain alphanumeric token; Deepgram publishes no key format, so + * the check is a truncation guard (non-empty, ≥ 20 chars, no whitespace). + */ +export function validateKeyShape(id: string, rawValue: string): KeyShapeResult { + const value = rawValue.trim(); + const nonEmpty = (min: number) => + value.length >= min && !/\s/.test(value) + ? { ok: true as const } + : { ok: false as const, error: `${id} must be at least ${min} characters with no whitespace` }; + switch (id) { + case "OPENROUTER_API_KEY": + return value.startsWith("sk-or-") + ? nonEmpty(12) + : { ok: false, error: `${id} must start with "sk-or-" (OpenRouter's key format)` }; + case "GOOGLE_SEARCH_API_KEY": + return value.startsWith("AIza") + ? nonEmpty(10) + : { ok: false, error: `${id} must start with "AIza" (Google's API-key format)` }; + case "GOOGLE_CSE_ID": + return ( + /^[0-9]{6,}:[0-9A-Za-z_-]+$/.test(value) || /^[0-9A-Za-z]{12,}$/.test(value) + ) + ? { ok: true } + : { ok: false, error: `${id} must be the Programmable Search Engine ID (digits:digits, or the plain alphanumeric form)` }; + case "DEEPGRAM_API_KEY": + // No public format — the length floor only catches truncated pastes. + return nonEmpty(20); + default: + return { ok: false, error: `unknown key "${id}" (supported: ${[...AUTH_KEY_IDS].join(", ")})` }; + } +} + +/** A masked key never leaves the server as plaintext (owner decision: + * last 4 chars + length — enough to identify which key is stored after + * rotating between several, and to spot a mis-pasted family). */ +export function maskKey(value: string): string { + const v = value.trim(); + if (v.length <= 4) return `…${v}`; // degenerate paste — still never the whole value + return `…${v.slice(-4)}`; +} + +/** One key's status row for the GET view (never carries plaintext). */ +export interface AuthKeyStatus { + id: string; + /** Catalog metadata when the key is curated; null for unknown-file-var + * entries (preserved + surfaced, but no curated form to edit through). */ + info: AuthKeyInfo | null; + /** A key is present when EITHER the environment or the file has it. */ + present: boolean; + /** The masked tail + character count, e.g. "…ab12 (64 chars)". */ + masked: string | null; + /** Where the live value comes from, given env-wins precedence. */ + source: "env" | "file" | "none"; + /** True when the file holds a value that the environment shadows — the + * settings page explains why a saved key is not the live one. */ + shadowedByEnv: boolean; +} diff --git a/src/core/index.ts b/src/core/index.ts index 6796a9e..23f32d2 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -40,6 +40,10 @@ export * from "./fuzzy.ts"; // view. Browser-safe — the Wave-4 panel imports these directly. export * from "./settings.ts"; export * from "./settings-resolution.ts"; +// auth-keys.ts (auth-keys wave, 2026-09-19): the curated key catalog + +// shape rules + masking — pure, browser-safe (the auth page binds to the +// /api/keys routes built on it). +export * from "./auth-keys.ts"; // keyboard.ts: the shortcut registry + binding normalization (pure, browser-safe). export * from "./keyboard.ts"; export * from "./coverage.ts"; diff --git a/src/core/ports.ts b/src/core/ports.ts index 079d643..4efb322 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -190,3 +190,23 @@ export interface PromptOverrideStore { * on-switch with no file composes the source default, inert). */ deleteOverride(segmentPath: string): void; } + +// ─── AuthStore — BYOK provider keys (auth-keys wave, 2026-09-19) ──────────── + +/** + * The long-standing flat {VAR: value} auth.json map (config.AUTH_FILE) — + * the same file the Electron app has always hand-edited, now first-class + * server-managed. Values are opaque strings: shape validation is a + * server-band concern (core/auth-keys.ts validateKeyShape — the store + * persists what it is given); env precedence is a server-band concern + * too (src/server/keys.ts). Unknown vars ride along verbatim. + */ +export interface AuthStore { + /** The whole map — absent or corrupt file = {} ("no keys yet", the + * documented first-boot state). Never throws. */ + authVars(): Record; + /** Set one VAR (atomic write, mode 0600 always, other vars preserved). */ + setAuthVar(name: string, value: string): void; + /** Remove one VAR if present (absent = no-op, still a silent success). */ + deleteAuthVar(name: string): void; +} diff --git a/src/server/index.ts b/src/server/index.ts index 0ffac57..58a36ef 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -26,6 +26,13 @@ export { isValidSegmentPath } from "../core/prompt-segments.ts"; // (composition is an agent concern); the barrel re-exports it for the // routes — server→agent is the lawful direction (chat-handler precedent). export { effectivePromptSegments, allSegmentPaths, readPromptOverride } from "../agent/prompt-segments.ts"; + +// The auth/keys composition (auth-keys wave, 2026-09-19): the curated +// key catalog + the env-reflecting store behind GET/PUT/DELETE /api/keys. +// Importing this module runs the idempotent boot fill-gap — every consumer +// keeps its plain process.env reads (that seam is the point). +export * from "./keys.ts"; +export { authStore } from "../store/auth.ts"; export * from "./settings-body.ts"; export * from "./turn-liveness.ts"; export * from "./observability.ts"; diff --git a/src/server/keys.ts b/src/server/keys.ts new file mode 100644 index 0000000..87b9126 --- /dev/null +++ b/src/server/keys.ts @@ -0,0 +1,170 @@ +/** + * The auth/keys composition (auth-keys wave, 2026-09-19): the server-side + * key manager behind GET/PUT/DELETE /api/keys. + * + * THE ENV SEAM (the architecture keystone): every consumer — models.ts's + * openRouterApiKey(), the sub-agents, web.ts, the STT route — reads + * process.env directly, and the agent layer may not import the store + * layer. Instead of rewiring five consumers across lanes, the store + * REFLECTS itself into the child's env: at boot (idempotent, anchored on + * globalThis like the extraction registry) every file var UNSET in the + * real env is fill-gapped into process.env, and each save/delete updates + * file + env together. Keys therefore take effect immediately — no restart + * — which is also why main.cjs's old spawn-time injection HAD to retire: + * an env copy made at spawn would shadow every settings-page change until + * restart (env wins over file). + * + * PRECEDENCE (owner decision): real environment variables (incl. + * .env.local in dev) always win over the file. Boot-time real-env vars + * are snapshotted once per process and never mutated; a PUT to one of + * them persists to the file (for when the env goes away) and the status + * honestly reports shadowedByEnv — the page explains why the saved key + * is not the live one. + * + * Unknown vars in the file are preserved verbatim and surfaced as + * generic status rows (no catalog form to edit through — only curated + * keys have shapes and docs; core/auth-keys.ts). + */ + +import { + AUTH_KEYS, + type AuthKeyInfo, + type AuthKeyStatus, + isKnownAuthKey, + maskKey, + validateKeyShape, +} from "../core/auth-keys.ts"; +import { authStore } from "../store/auth.ts"; + +// The once-per-process boot record — globalThis anchor (HMR re-imports the +// module; the snapshot must not re-run and misread fill-gapped vars as real +// env). Same pattern as globalThis.__lhExtractionRuns. +interface AuthBoot { + /** Vars that were in the REAL env at first init — env precedence's + * untouchable set. Fill-gapped vars are everything else we set. */ + realEnv: Set; + /** Vars WE put into process.env (the file's reflection) — deletable. */ + fillGap: Set; +} +const BOOT_KEY = Symbol.for("lh.authBoot"); + +function boot(): AuthBoot { + const g = globalThis as Record; + if (!g[BOOT_KEY]) g[BOOT_KEY] = { realEnv: new Set(), fillGap: new Set() }; + return g[BOOT_KEY]; +} + +/** + * The boot fill-gap — idempotent. First call snapshots which managed names + * are real env; later calls (HMR re-import, or after a hand-edit) only fill + * vars still unset in the env, so a var deleted through the API never + * resurrects (its file entry is gone too). + */ +export function initAuthEnv(): void { + const record = boot(); + const fileVars = authStore.authVars(); + const managed = new Set([...Object.keys(fileVars), ...AUTH_KEYS.map((k) => k.id)]); + if (record.realEnv.size === 0 && record.fillGap.size === 0) { + // First init in this process: what's already in the env is REAL env. + for (const name of managed) { + if (process.env[name] !== undefined) record.realEnv.add(name); + } + } + for (const [name, value] of Object.entries(fileVars)) { + if (!isManagedName(name)) continue; + if (process.env[name] === undefined) { + process.env[name] = value; + record.fillGap.add(name); + } + } +} + +/** The managed name-space: catalog keys + whatever the file holds (unknown + * vars are managed too — preserved, surfaced, env-reflected — just not + * editable through the curated API). */ +function isManagedName(name: string): boolean { + return isKnownAuthKey(name) || name in authStore.authVars(); +} + +/** + * The pure status projection — exported for tests. `env` is the live + * process env view; `realEnv` the boot snapshot (env precedence). + */ +export function resolveKeyStatuses( + fileVars: Record, + env: Record, + realEnv: ReadonlySet, +): AuthKeyStatus[] { + const ids = new Set([...AUTH_KEYS.map((k) => k.id), ...Object.keys(fileVars)]); + const rows: AuthKeyStatus[] = []; + for (const id of ids) { + const envValue = env[id]; + const fileValue = fileVars[id]; + const isRealEnv = realEnv.has(id); + // Env wins: the live value is the env's when set (real or reflected — + // post fill-gap the file's values LIVE in env, so realEnv is what + // distinguishes a REAL env var from the store's own reflection). + const live = envValue !== undefined ? envValue : fileValue; + rows.push({ + id, + info: AUTH_KEYS.find((k) => k.id === id) ?? null, + present: live !== undefined, + masked: live !== undefined ? `${maskKey(live)} (${live.trim().length} chars)` : null, + source: isRealEnv ? "env" : live !== undefined ? "file" : "none", + shadowedByEnv: isRealEnv && fileValue !== undefined && envValue !== fileValue, + }); + } + return rows; +} + +/** The GET view: every curated key (present or not) + unknown file vars. */ +export function keyStatuses(): AuthKeyStatus[] { + return resolveKeyStatuses(authStore.authVars(), process.env, boot().realEnv); +} + +/** The live value for one var, env-first (the env-wins rule) — the canonical + * read for consumers that don't live behind the @/src/server barrel (the + * STT route was the one cold-boot hole: a first-ever request to it would + * otherwise read env before any fill-gap ran). */ +export function keyValue(id: string): string | undefined { + return process.env[id] ?? authStore.authVars()[id]; +} + +/** + * Save one key (PUT). Shape gate first (owner decision: bad pastes are + * 400s, never stored), then the file write, then the env reflection. A + * var that the REAL env owns is not reflected (env wins — the status row + * reports shadowedByEnv so the page can explain it). + */ +export function saveKey(id: string, value: string): { ok: true; status: AuthKeyStatus } | { ok: false; error: string } { + const shape = validateKeyShape(id, value); + if (!shape.ok) return { ok: false, error: shape.error }; + const trimmed = value.trim(); + authStore.setAuthVar(id, trimmed); + const record = boot(); + if (!record.realEnv.has(id)) { + process.env[id] = trimmed; + record.fillGap.add(id); + } + // validateKeyShape guarantees catalog membership, so the row exists. + return { ok: true, status: keyStatuses().find((s) => s.id === id)! }; +} + +/** + * Remove one key (DELETE): gone from the file always; gone from the env + * only when the env copy was OURS (fill-gap) — a real env var stays (env + * wins) and the status shows the key still present from env. + */ +export function deleteKey(id: string): AuthKeyStatus { + authStore.deleteAuthVar(id); + const record = boot(); + if (record.fillGap.has(id) && !record.realEnv.has(id)) { + delete process.env[id]; + record.fillGap.delete(id); + } + return keyStatuses().find((s) => s.id === id)!; +} + +// Reflect the file into the env at first import (idempotent; the module is +// imported by every route/handler path that touches keys). +initAuthEnv(); diff --git a/src/store/auth.ts b/src/store/auth.ts new file mode 100644 index 0000000..2fe6edd --- /dev/null +++ b/src/store/auth.ts @@ -0,0 +1,63 @@ +/** + * The AuthStore adapter (auth-keys wave, 2026-09-19): the flat {VAR: value} + * auth.json map at config.AUTH_FILE — the SAME file the Electron app has + * always hand-edited (owner decision: option A — same file, env-located, + * zero migration). 0600, atomic writes, hand-edits keep working (the file + * is re-read from disk on every access). + * + * The server (src/server/keys.ts) is the only intended writer: it applies + * shape validation before setAuthVar and env precedence on top of + * authVars(). This adapter persists what it is given, never throws + * (absent/corrupt file = {} = "no keys yet", the documented first-boot + * state, matching settings doctrine), and preserves unknown vars verbatim + * (forward-compat: future entries — e.g. a plans-login token — ride along + * untouched until a catalog entry adopts them). + */ + +import fs from "node:fs"; +import path from "node:path"; +import { AUTH_FILE } from "../../config.ts"; +import type { AuthStore } from "../core/ports.ts"; + +/** The flat string→string map, or {} for absent/corrupt/non-object. */ +function readMap(): Record { + try { + const raw = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8")); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return Object.fromEntries( + Object.entries(raw).filter(([, v]) => typeof v === "string") as [string, string][], + ); + } catch { + return {}; // absent or corrupt — the documented state is "no keys yet" + } +} + +/** Write the map atomically at mode 0600: a partial write must never + * leave a 0644 file (secrets) or a truncated JSON (looks like "no keys"). + * tmp-in-same-dir + rename keeps the swap atomic; chmod BEFORE the + * rename so the mode never exists relaxed on the final path. */ +function writeMap(map: Record): void { + const dir = path.dirname(AUTH_FILE); + fs.mkdirSync(dir, { recursive: true }); + const tmp = path.join(dir, `.auth.json.tmp-${process.pid}`); + fs.writeFileSync(tmp, `${JSON.stringify(map, null, "\t")}\n`, { mode: 0o600 }); + fs.chmodSync(tmp, 0o600); // umask may have relaxed the create mode + fs.renameSync(tmp, AUTH_FILE); +} + +export const authStore: AuthStore = { + authVars() { + return readMap(); + }, + setAuthVar(name, value) { + const map = readMap(); + map[name] = value; + writeMap(map); + }, + deleteAuthVar(name) { + const map = readMap(); + if (!(name in map)) return; // absent = silent success, idempotent + delete map[name]; + writeMap(map); + }, +}; diff --git a/tests/auth-keys.test.ts b/tests/auth-keys.test.ts new file mode 100644 index 0000000..0f4cf84 --- /dev/null +++ b/tests/auth-keys.test.ts @@ -0,0 +1,88 @@ +/** + * The curated auth-key catalog + shape rules (auth-keys wave, 2026-09-19) + * — PURE module, so this file owns no fs. Owner decisions being pinned: + * - the catalog is exactly the four known keys, ids = env-var names; + * - validation is SHAPE-ONLY (no live pings) and bad pastes are 400s; + * - masking is last-4 + length — plaintext never leaves the store. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { AUTH_KEYS, isKnownAuthKey, maskKey, validateKeyShape } from "../src/core/auth-keys.ts"; + +describe("the catalog", () => { + it("is exactly the four curated keys, ids = env-var names", () => { + assert.deepEqual( + AUTH_KEYS.map((k) => k.id), + ["OPENROUTER_API_KEY", "DEEPGRAM_API_KEY", "GOOGLE_SEARCH_API_KEY", "GOOGLE_CSE_ID"], + ); + for (const k of AUTH_KEYS) { + assert.ok(k.label.length > 0, `${k.id} has a label`); + assert.ok(k.description.length > 0, `${k.id} has a description`); + assert.ok(k.docsUrl.startsWith("https://"), `${k.id} has a docs link`); + } + }); + + it("isKnownAuthKey: catalog yes, everything else no", () => { + assert.ok(isKnownAuthKey("OPENROUTER_API_KEY")); + assert.ok(isKnownAuthKey("GOOGLE_CSE_ID")); + assert.ok(!isKnownAuthKey("OPENROUTER")); + assert.ok(!isKnownAuthKey("FUTURE_PLANS_TOKEN")); + assert.ok(!isKnownAuthKey("")); + }); +}); + +describe("validateKeyShape (the 400 gate)", () => { + it("OpenRouter: sk-or- prefix required, length floor past it", () => { + assert.deepEqual(validateKeyShape("OPENROUTER_API_KEY", "sk-or-v1-abc123def456ghi789jkl012"), { ok: true }); + assert.ok(!validateKeyShape("OPENROUTER_API_KEY", "sk-proj-abcdefghij").ok, "wrong family (OpenAI paste)"); + assert.ok(!validateKeyShape("OPENROUTER_API_KEY", "sk-or-").ok, "prefix alone is a truncation"); + assert.ok(!validateKeyShape("OPENROUTER_API_KEY", " ").ok, "empty fails"); + }); + + it("Google API key: AIza prefix required", () => { + assert.ok(validateKeyShape("GOOGLE_SEARCH_API_KEY", "AIzaSyD-abcdef1234").ok); + assert.ok(!validateKeyShape("GOOGLE_SEARCH_API_KEY", "sk-or-v1-abcdefghij").ok, "wrong family"); + assert.ok(!validateKeyShape("GOOGLE_SEARCH_API_KEY", "AIza").ok, "prefix alone fails"); + }); + + it("CSE ID: legacy digits:digits OR plain token form", () => { + assert.ok(validateKeyShape("GOOGLE_CSE_ID", "017576662512468239146:omuauf_lfve").ok); + assert.ok(validateKeyShape("GOOGLE_CSE_ID", "a1b2c3d4e5f6g7h8j9k0lmnop").ok); + assert.ok(!validateKeyShape("GOOGLE_CSE_ID", "17:short").ok, "short legacy form fails"); + assert.ok(!validateKeyShape("GOOGLE_CSE_ID", "has whitespace here").ok); + assert.ok(!validateKeyShape("GOOGLE_CSE_ID", "sk-or-v1-abcdefg").ok, "wrong family"); + }); + + it("Deepgram: no public format — a ≥20-char truncation guard, no whitespace", () => { + assert.ok(validateKeyShape("DEEPGRAM_API_KEY", "a".repeat(40)).ok); + assert.ok(!validateKeyShape("DEEPGRAM_API_KEY", "a".repeat(19)).ok, "truncated paste"); + assert.ok(!validateKeyShape("DEEPGRAM_API_KEY", "a b c d e f g h i j k l m n o p q r s").ok, "whitespace"); + }); + + it("trims surrounding paste whitespace before checking", () => { + assert.ok(validateKeyShape("OPENROUTER_API_KEY", " sk-or-v1-abc123def456 \n").ok); + }); + + it("unknown ids are rejected with the supported list", () => { + const r = validateKeyShape("SOMETHING_ELSE", "whatever"); + assert.ok(!r.ok); + if (!r.ok) assert.match(r.error, /SOMETHING_ELSE.*OPENROUTER_API_KEY/); + }); +}); + +describe("maskKey (plaintext never leaves)", () => { + it("shows the last 4 only", () => { + assert.equal(maskKey("sk-or-v1-abcdefgh1234"), "…1234"); + }); + + it("never reveals a degenerate short value in full", () => { + assert.equal(maskKey("ab"), "…ab"); + assert.equal(maskKey("abcd"), "…abcd"); + assert.equal(maskKey("abcde"), "…bcde"); + }); + + it("trims before masking", () => { + assert.equal(maskKey(" sk-or-v1-xyzw9 "), "…yzw9"); + }); +}); diff --git a/tests/auth-store.test.ts b/tests/auth-store.test.ts new file mode 100644 index 0000000..7c4b822 --- /dev/null +++ b/tests/auth-store.test.ts @@ -0,0 +1,80 @@ +/** + * The AuthStore fs adapter (auth-keys wave, 2026-09-19) — flat {VAR: value} + * map at config.AUTH_FILE. THIS FILE is the sole owner of its auth file: + * LH_AUTH_FILE is redirected to a fresh tmp dir BEFORE the first config + * import (the seam is module-load-bound — data-dir-seam precedent), so no + * hand-edited dev/desktop auth.json is ever touched. + * + * Pinning: absent/corrupt = {} ("no keys yet"); set/delete round-trip with + * unknown vars preserved verbatim; the file is ALWAYS mode 0600 (secrets); + * writes are atomic (tmp+rename — no partial JSON state). + */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, before, describe, it } from "node:test"; + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lh-auth-store-")); +process.env.LH_AUTH_FILE = path.join(TMP, "auth.json"); + +// Import AFTER the env redirect — module-load-bound seam. +const { authStore } = await import("../src/store/auth.ts"); +const { AUTH_FILE } = await import("../config.ts"); + +after(() => { + fs.rmSync(TMP, { recursive: true, force: true }); +}); + +describe("AuthStore adapter", () => { + it("absent file = {} (the documented first-boot state), never throws", () => { + assert.deepEqual(authStore.authVars(), {}); + }); + + it("set → read round-trip; trim is the CALLER's (server's) job, verbatim here", () => { + authStore.setAuthVar("OPENROUTER_API_KEY", "sk-or-v1-abc"); + assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-abc"); + }); + + it("the file is mode 0600 (secrets), also after rewrites", () => { + authStore.setAuthVar("DEEPGRAM_API_KEY", "d".repeat(40)); + const first = fs.statSync(AUTH_FILE).mode & 0o777; + assert.equal(first, 0o600); + authStore.setAuthVar("DEEPGRAM_API_KEY", "e".repeat(40)); + assert.equal(fs.statSync(AUTH_FILE).mode & 0o777, 0o600, "rewrite keeps 0600"); + }); + + it("set preserves unknown vars verbatim (forward-compat)", () => { + fs.writeFileSync(AUTH_FILE, JSON.stringify({ FUTURE_PLANS_TOKEN: "opaque-xyz", KEEP: "me" })); + authStore.setAuthVar("OPENROUTER_API_KEY", "sk-or-v1-new"); + const vars = authStore.authVars(); + assert.equal(vars.FUTURE_PLANS_TOKEN, "opaque-xyz"); + assert.equal(vars.KEEP, "me"); + assert.equal(vars.OPENROUTER_API_KEY, "sk-or-v1-new"); + }); + + it("corrupt/non-object file = {} — never throws, looks like first boot", () => { + fs.writeFileSync(AUTH_FILE, "{not json"); + assert.deepEqual(authStore.authVars(), {}); + fs.writeFileSync(AUTH_FILE, JSON.stringify(["an", "array"])); + assert.deepEqual(authStore.authVars(), {}); + fs.writeFileSync(AUTH_FILE, JSON.stringify({ OK: "fine", bad: 42 })); + assert.deepEqual(authStore.authVars(), { OK: "fine" }, "non-string values drop"); + }); + + it("delete removes just that var; absent delete = silent no-op", () => { + fs.writeFileSync(AUTH_FILE, JSON.stringify({ A: "1", B: "2" })); + authStore.deleteAuthVar("A"); + assert.deepEqual(authStore.authVars(), { B: "2" }); + authStore.deleteAuthVar("NOT_THERE"); + assert.deepEqual(authStore.authVars(), { B: "2" }); + }); + + it("atomic writes: no tmp leftovers, valid JSON on disk every time", () => { + authStore.setAuthVar("OPENROUTER_API_KEY", "sk-or-v1-final"); + const files = fs.readdirSync(TMP).filter((f) => f.startsWith(".auth.json.tmp")); + assert.deepEqual(files, [], "no tmp file survives the rename"); + JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8")); // must parse + }); +}); diff --git a/tests/server-keys.test.ts b/tests/server-keys.test.ts new file mode 100644 index 0000000..ad4f480 --- /dev/null +++ b/tests/server-keys.test.ts @@ -0,0 +1,162 @@ +/** + * The keys composition (auth-keys wave, 2026-09-19): env-reflecting store + + * env-wins precedence + the masked status view. Owner decisions pinned: + * - real env always wins; a PUT to an env-owned var persists to the file + * but never overwrites the env copy (status reports shadowedByEnv); + * - saves/deletes take effect immediately (file + process.env together); + * - unknown file vars are preserved and surfaced, never editable. + * + * LH_AUTH_FILE redirects to a fresh tmp dir BEFORE the imports (the seam is + * module-load bound); the \"real env\" var is set before src/server/keys.ts + * first loads so the boot snapshot treats it as real environment. All + * values here are throwaway test strings — never real secrets. + */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { after, describe, it } from "node:test"; + +// THIS PROCESS'S ENV IS THE TEST SUBJECT — strip any inherited catalog vars +// (a developer shell may export REAL keys; tests use throwaway values only, +// never real secrets). The strip affects only this disposable child process. +for (const varName of [ + "OPENROUTER_API_KEY", + "DEEPGRAM_API_KEY", + "GOOGLE_SEARCH_API_KEY", + "GOOGLE_CSE_ID", + "FUTURE_PLANS_TOKEN", +]) { + delete process.env[varName]; +} + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lh-server-keys-")); +const AUTH = path.join(TMP, "auth.json"); +process.env.LH_AUTH_FILE = AUTH; + +// The file BEFORE first boot: a shadowed OpenRouter (env will differ) + an +// unknown var that must survive every write and surface as a generic row. +fs.writeFileSync( + AUTH, + JSON.stringify({ + OPENROUTER_API_KEY: "sk-or-v1-filefilefile", + FUTURE_PLANS_TOKEN: "opaque-plans-token", + }), +); +// Real env (set BEFORE the module's first import → boot snapshots it): +process.env.OPENROUTER_API_KEY = "sk-or-v1-envvalue99"; + +// Import AFTER env setup — module init runs the boot fill-gap + snapshot. +const { deleteKey, initAuthEnv, keyStatuses, keyValue, resolveKeyStatuses, saveKey } = await import( + "../src/server/keys.ts" +); +const { authStore } = await import("../src/store/auth.ts"); + +const ENV_OWNED = "sk-or-v1-envvalue99"; + +after(() => { + delete process.env.OPENROUTER_API_KEY; + delete process.env.DEEPGRAM_API_KEY; + fs.rmSync(TMP, { recursive: true, force: true }); +}); + +describe("boot fill-gap + precedence", () => { + it("env-owned var: env wins, shadow reported, file copy kept", () => { + const row = keyStatuses().find((s) => s.id === "OPENROUTER_API_KEY")!; + assert.equal(row.source, "env"); + assert.ok(row.shadowedByEnv, "file value differs from the env value"); + assert.equal(row.masked, `…${ENV_OWNED.slice(-4)} (${ENV_OWNED.length} chars)`); + assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-filefilefile", "file copy untouched"); + }); + + it("unknown file var: fill-gapped into env, surfaced as a generic row", () => { + assert.equal(process.env.FUTURE_PLANS_TOKEN, "opaque-plans-token", "reflected at boot"); + const row = keyStatuses().find((s) => s.id === "FUTURE_PLANS_TOKEN")!; + assert.equal(row.info, null, "no curated form"); + assert.equal(row.source, "file"); + assert.ok(row.present); + }); + + it("absent curated keys: not present, source none", () => { + const row = keyStatuses().find((s) => s.id === "DEEPGRAM_API_KEY")!; + assert.ok(!row.present); + assert.equal(row.source, "none"); + assert.equal(row.masked, null); + assert.ok(row.info, "catalog metadata still present for the page form"); + }); + + it("keyValue resolves env-first", () => { + assert.equal(keyValue("OPENROUTER_API_KEY"), ENV_OWNED); + assert.equal(keyValue("FUTURE_PLANS_TOKEN"), "opaque-plans-token"); + assert.equal(keyValue("DEEPGRAM_API_KEY"), undefined); + }); +}); + +describe("saveKey / deleteKey (immediate effect, no restart)", () => { + it("save reflects into env + file (fill-gap set)", () => { + const value = "d".repeat(40); // throwaway, ≥ Deepgram floor + const r = saveKey("DEEPGRAM_API_KEY", value); + assert.ok(r.ok); + assert.equal(process.env.DEEPGRAM_API_KEY, value, "env updated immediately"); + assert.equal(authStore.authVars().DEEPGRAM_API_KEY, value, "file updated"); + assert.equal(r.status.source, "file", "the reflected copy is the store's — the file is its origin of truth"); + assert.ok(!r.status.shadowedByEnv); + }); + + it("save to an env-owned var: file updated, env NOT overwritten, shadow reported", () => { + const r = saveKey("OPENROUTER_API_KEY", "sk-or-v1-newvalue12345"); + assert.ok(r.ok); + assert.equal(process.env.OPENROUTER_API_KEY, ENV_OWNED, "real env untouched"); + assert.equal(authStore.authVars().OPENROUTER_API_KEY, "sk-or-v1-newvalue12345", "file updated for later"); + assert.ok(r.status.shadowedByEnv); + }); + + it("bad shape: rejected, nothing stored or reflected", () => { + const before = JSON.stringify(authStore.authVars()); + const r = saveKey("DEEPGRAM_API_KEY", "short"); + assert.ok(!r.ok); + if (!r.ok) assert.match(r.error, /at least 20/); + assert.equal(JSON.stringify(authStore.authVars()), before); + assert.equal(process.env.DEEPGRAM_API_KEY, "d".repeat(40)); + }); + + it("unknown id: rejected with the supported list", () => { + const r = saveKey("FUTURE_PLANS_TOKEN", "anything-at-all-here"); + assert.ok(!r.ok); + if (!r.ok) assert.match(r.error, /FUTURE_PLANS_TOKEN/); + }); + + it("delete removes env + file for fill-gap vars", () => { + const row = deleteKey("DEEPGRAM_API_KEY"); + assert.ok(!row.present); + assert.equal(process.env.DEEPGRAM_API_KEY, undefined, "env copy was ours — removed"); + assert.ok(!("DEEPGRAM_API_KEY" in authStore.authVars())); + }); + + it("delete of an env-owned var: file copy gone, env remains (env wins)", () => { + const row = deleteKey("OPENROUTER_API_KEY"); + assert.equal(row.source, "env"); + assert.ok(row.present, "still live from the real env"); + assert.ok(!row.shadowedByEnv, "nothing left in the file to shadow"); + assert.equal(process.env.OPENROUTER_API_KEY, ENV_OWNED); + }); + + it("re-running initAuthEnv is idempotent — deleted vars never resurrect", () => { + initAuthEnv(); + assert.equal(process.env.DEEPGRAM_API_KEY, undefined, "file entry gone → no re-fill"); + }); +}); + +describe("resolveKeyStatuses (pure projection)", () => { + it("same value in env and file is NOT shadowed (redundant, not misleading)", () => { + const row = resolveKeyStatuses( + { GOOGLE_SEARCH_API_KEY: "AIzaSame1234" }, + { GOOGLE_SEARCH_API_KEY: "AIzaSame1234" }, + new Set(["GOOGLE_SEARCH_API_KEY"]), + ).find((s) => s.id === "GOOGLE_SEARCH_API_KEY")!; + assert.ok(row, "the curated id is always in the projection"); + assert.equal(row.source, "env"); + assert.ok(!row.shadowedByEnv, "same value both places: redundant, not shadowed"); + }); +}); From a828d9b6d9b6538523207a43f35d28cfcd36da78 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 19 Sep 2026 22:28:11 +0300 Subject: [PATCH 2/2] =?UTF-8?q?feat(models):=20rolePins=20on=20GET=20/api/?= =?UTF-8?q?models=20=E2=80=94=20the=20background=20roles'=20resolved=20pin?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings interlude (owner request, 2026-09-19): the route now also returns rolePins: Record — the six background roles' RESOLVED models (the user's agent-models override falling back to the config.ts MODELS pin), so the settings UI shows each role row's 'Default: ' instead of a generic config caption. Same resolution the agent factories use (effectiveSettings), so the caption can never drift from what runs. Pure projection (rolePinsFor) + the fs wrapper (catalogRolePins); pins tested against config defaults and model/thinkingLevel-only overrides. BACKGROUND_MODEL_PINS exported from stores.ts for the test (was local). --- src/core/contracts/models.ts | 13 +++++++++++++ src/server/model-catalog.ts | 34 +++++++++++++++++++++++++++++++-- src/server/stores.ts | 1 + tests/model-catalog.test.ts | 37 +++++++++++++++++++++++++++++++++++- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/core/contracts/models.ts b/src/core/contracts/models.ts index 60906a6..22c2287 100644 --- a/src/core/contracts/models.ts +++ b/src/core/contracts/models.ts @@ -6,6 +6,7 @@ * components never import from the server band). */ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { BackgroundModelRole } from "../settings.ts"; /** One picker row: everything the UI shows about a model, projected from * pi-ai's catalog. Costs are USD per 1M tokens (the same catalog, the same @@ -32,8 +33,20 @@ export interface CatalogDefaults { thinkingLevel: ThinkingLevel; } +/** One background role's RESOLVED pin (settings interlude, 2026-09-19): + * the user's agent-models override falling back to the config.ts MODELS + * pin — what that role actually runs today. The settings UI shows each + * agent-role row's "Default: " caption from it. */ +export type RolePins = Record< + BackgroundModelRole, + { model: { provider: string; id: string }; thinkingLevel?: ThinkingLevel } +>; + /** GET /api/models response. */ export interface ModelCatalogResponse { models: ModelCatalogEntry[]; defaults: CatalogDefaults; + /** The six background roles' resolved pins (same resolution the agent + * factories use — src/server/stores.ts effectiveSettings). */ + rolePins: RolePins; } \ No newline at end of file diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index a09084b..76cb27f 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -19,12 +19,16 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { MODELS } from "../../config.ts"; import { piModels } from "../agent/models.ts"; import { settingsStore } from "../store/settings.ts"; +import { effectiveSettings } from "./stores.ts"; import type { ChatSettings } from "../core/contracts/chat.ts"; import type { CatalogDefaults, ModelCatalogEntry, ModelCatalogResponse, + RolePins, } from "../core/contracts/models.ts"; +import type { EffectiveModelSpec } from "../core/settings-resolution.ts"; +import { BACKGROUND_MODEL_ROLES, type BackgroundModelRole } from "../core/settings.ts"; /** The picker's client-safe model entry (contract: core/contracts/models.ts). */ export type ModelDto = ModelCatalogEntry; @@ -48,6 +52,31 @@ export function catalogDefaults(): CatalogDefaults { return catalogDefaultsFor(settingsStore.readSettings()?.chat); } +/** Project the resolved per-role specs to the client DTO (pure — the + * settings UI's "Default: " caption reads this). + * thinkingLevel is always concrete in the resolved view; the contract + * keeps it optional so the UI tolerates a future role without one. */ +export function rolePinsFor(models: Record): RolePins { + const pins = {} as RolePins; + for (const role of BACKGROUND_MODEL_ROLES) { + const spec = models[role]; + pins[role] = { + model: { provider: spec.provider, id: spec.id }, + thinkingLevel: spec.thinkingLevel, + }; + } + return pins; +} + +/** The six background roles' RESOLVED pins (settings interlude, + * 2026-09-19): the user's agent-models override falling back to the + * config.ts MODELS pin — the same resolution the agent factories use + * (src/server/stores.ts effectiveSettings), so the caption can never + * drift from what actually runs. */ +export function catalogRolePins(): RolePins { + return rolePinsFor(effectiveSettings().agent.models); +} + /** Project one catalog model to the client DTO. */ export function toModelDto(model: Model): ModelDto { return { @@ -70,14 +99,15 @@ export function toModelDto(model: Model): ModelDto { /** The full picker payload: every model of every configured provider * (sorted by provider then id — stable, diff-friendly) + the config - * defaults a settings-less chat runs. */ + * defaults a settings-less chat runs + the background roles' resolved + * pins (the settings UI's per-role default captions). */ export function modelCatalog(): ModelCatalogResponse { const models = piModels() .getProviders() .flatMap((p) => piModels().getModels(p.id)) .map(toModelDto) .sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider))); - return { models, defaults: catalogDefaults() }; + return { models, defaults: catalogDefaults(), rolePins: catalogRolePins() }; } /** One validated outcome for a settings write. */ diff --git a/src/server/stores.ts b/src/server/stores.ts index f584b11..5335e8a 100644 --- a/src/server/stores.ts +++ b/src/server/stores.ts @@ -35,6 +35,7 @@ const BACKGROUND_MODEL_PINS: ModelRoleSpecs = { auditor: MODELS.auditor, labeler: MODELS.labeler, }; +export { BACKGROUND_MODEL_PINS }; /** * The resolved view of the settings record (core/settings-resolution.ts): diff --git a/tests/model-catalog.test.ts b/tests/model-catalog.test.ts index f30788b..8114bb9 100644 --- a/tests/model-catalog.test.ts +++ b/tests/model-catalog.test.ts @@ -1,9 +1,12 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { modelCatalog, validateChatSettings, catalogDefaultsFor, toModelDto } from "../src/server/model-catalog.ts"; +import { modelCatalog, validateChatSettings, catalogDefaultsFor, toModelDto, rolePinsFor, catalogRolePins } from "../src/server/model-catalog.ts"; import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai"; import { MODELS } from "../config.ts"; import { piEngineFor } from "../src/agent/models.ts"; +import { BACKGROUND_MODEL_ROLES, type BackgroundModelRole } from "../src/core/settings.ts"; +import { resolveSettings } from "../src/core/settings-resolution.ts"; +import { BACKGROUND_MODEL_PINS } from "../src/server/stores.ts"; // The model-control wave (2026-09-12): pi-ai owns the registry — these pins // hold our projection + validation honest against the shipped catalog, and @@ -62,6 +65,38 @@ describe("the model catalog projection", () => { }); }); +describe("the background roles' resolved pins (rolePins)", () => { + it("no overrides → every role pins its config MODELS spec, nested DTO shape, all six roles", () => { + const pins = rolePinsFor(resolveSettings(null, BACKGROUND_MODEL_PINS).agent.models); + assert.deepEqual(Object.keys(pins), [...BACKGROUND_MODEL_ROLES]); + for (const role of BACKGROUND_MODEL_ROLES) { + assert.deepEqual(pins[role].model, { provider: "openrouter", id: MODELS[role].model }); + assert.equal(pins[role].thinkingLevel, MODELS[role].effort); + } + // the vision pin rides along like any other + assert.equal(pins.visualMaker.model.id, "qwen/qwen3.8-flash"); + }); + + it("a model override wins for that role only; others stay pinned", () => { + const record = { + version: 1, + agent: { models: { researcher: { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" } } } }, + }; + const pins = rolePinsFor(resolveSettings(record, BACKGROUND_MODEL_PINS).agent.models); + assert.equal(pins.researcher.model.id, "qwen/qwen3.8-flash"); + assert.equal(pins.researcher.thinkingLevel, MODELS.researcher.effort, "level stays the pin unless overridden too"); + assert.equal(pins.visualMaker.model.id, MODELS.visualMaker.model); + assert.equal(pins.labeler.model.id, MODELS.labeler.model); + }); + + it("a thinkingLevel-only override keeps the model, changes the level", () => { + const record = { version: 1, agent: { models: { extractor: { thinkingLevel: "high" as const } } } }; + const pins = rolePinsFor(resolveSettings(record, BACKGROUND_MODEL_PINS).agent.models); + assert.equal(pins.extractor.model.id, MODELS.extractor.model); + assert.equal(pins.extractor.thinkingLevel, "high"); + }); +}); + describe("the settings validation gate", () => { it("accepts a supported level and rejects an unsupported one with the list", () => { const ok = validateChatSettings({ model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" }, thinkingLevel: "max" });