@gotcos/glasses-server 6.79.2 → 6.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +31 -0
  3. package/README.md +10 -0
  4. package/managed-runtime-contract.json +1 -0
  5. package/package.json +10 -1
  6. package/server/index.ts +14 -1
  7. package/server/lib/agent-session-search.ts +8 -5
  8. package/server/lib/agent-session-store.ts +45 -4
  9. package/server/lib/archive.ts +2 -1
  10. package/server/lib/attached-provider-adapter.ts +11 -0
  11. package/server/lib/claude-bridge.ts +112 -8
  12. package/server/lib/claude-settings-guard.ts +94 -0
  13. package/server/lib/claude-steer.ts +27 -0
  14. package/server/lib/codex-bridge.ts +205 -32
  15. package/server/lib/codex-engine-sessions.ts +27 -6
  16. package/server/lib/codex-homes.ts +134 -0
  17. package/server/lib/codex-live-queue.ts +14 -0
  18. package/server/lib/codex-profile-home.ts +452 -0
  19. package/server/lib/codex-queue-control.ts +17 -1
  20. package/server/lib/codex-subagent-activity.ts +22 -0
  21. package/server/lib/conversation.ts +92 -15
  22. package/server/lib/direct-bridge.ts +853 -0
  23. package/server/lib/direct-sse.ts +262 -0
  24. package/server/lib/fork-thread.ts +11 -0
  25. package/server/lib/key-answer-fields.ts +49 -0
  26. package/server/lib/key-catalog.ts +301 -0
  27. package/server/lib/key-lanes-pricing.ts +12 -0
  28. package/server/lib/key-lanes-wiring.ts +346 -0
  29. package/server/lib/key-lanes.ts +145 -0
  30. package/server/lib/key-ledger.ts +123 -0
  31. package/server/lib/key-notices.ts +486 -0
  32. package/server/lib/key-profile-rejects.ts +25 -0
  33. package/server/lib/key-profile-routing.ts +173 -0
  34. package/server/lib/key-profiles.ts +182 -0
  35. package/server/lib/key-relay-contract.ts +262 -0
  36. package/server/lib/key-relay-errors.ts +211 -0
  37. package/server/lib/key-relay-meter.ts +303 -0
  38. package/server/lib/key-relay.ts +1018 -0
  39. package/server/lib/key-secrets-guard.ts +52 -0
  40. package/server/lib/key-store.ts +228 -0
  41. package/server/lib/keyed-sessions.ts +154 -0
  42. package/server/lib/lane-b-env.ts +169 -0
  43. package/server/lib/meeting-autonomous-finalize.ts +267 -0
  44. package/server/lib/meeting-stop-intents.ts +277 -0
  45. package/server/lib/model-router.ts +96 -7
  46. package/server/lib/morning-brief-config.ts +3 -0
  47. package/server/lib/native-head.ts +13 -1
  48. package/server/lib/occupancy-probes.ts +4 -0
  49. package/server/lib/ollama-run-ledger.ts +96 -13
  50. package/server/lib/prompt-edit.ts +2 -0
  51. package/server/lib/query-job-coordinator.ts +2 -1
  52. package/server/lib/query-job-runtime.ts +67 -15
  53. package/server/lib/query-job-store.ts +7 -1
  54. package/server/lib/query-job-types.ts +15 -1
  55. package/server/lib/session-hook-events.ts +28 -0
  56. package/server/lib/session-hook-ledger.ts +10 -0
  57. package/server/lib/thread-occupancy.ts +22 -1
  58. package/server/lib/token-audit.ts +18 -4
  59. package/server/lib/work-handoff-requests.ts +3 -0
  60. package/server/routes/agent-session-bindings.ts +15 -0
  61. package/server/routes/agent-sessions.ts +17 -5
  62. package/server/routes/handoffs.ts +22 -1
  63. package/server/routes/health.ts +5 -0
  64. package/server/routes/key-lanes.ts +442 -0
  65. package/server/routes/meeting.ts +326 -47
  66. package/server/routes/openai-compat.ts +28 -3
  67. package/server/routes/query.ts +20 -6
  68. package/server/routes/transcribe-stream.ts +48 -1
  69. package/shared/key-preference.ts +57 -0
  70. package/shared/model-preference.ts +20 -2
@@ -0,0 +1,442 @@
1
+ // Key lanes (K1, plan v4 section 3.1): `/api/key-lanes/*`.
2
+ //
3
+ // While keyLanesEnabled() is false, every path here falls through to the server's existing 404,
4
+ // byte for byte, with ONE deliberate exception agreed for K1: `POST /api/key-lanes/enable`, so COS
5
+ // Control can turn lanes on from its own Settings row without an env edit. That door answers only
6
+ // an authenticated (X-Cos-Token, checked here as well as by the global gate), loopback,
7
+ // non-browser request, and never while COS_KEY_LANES=0. It is the only call that creates
8
+ // key-lanes.json. Everything else (wrong method, wrong path, phone, browser, missing token) gets
9
+ // today's 404.
10
+ //
11
+ // While enabled: the exposure list in plan 3.1, nothing else. Writes are Mac only (loopback, no
12
+ // browser Origin), so a G2 or phone request cannot add, change or remove a key. No route ever
13
+ // returns a secret: keys go out as PublicKeyRecord (ref, kind, last 4, cap). No route logs a body.
14
+ import express, { Router, type ErrorRequestHandler, type Request, type RequestHandler } from 'express'
15
+ import { copyFileSync, existsSync, mkdirSync, rmSync, unlinkSync } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import { join } from 'node:path'
18
+ import { dataPath } from '../lib/data-dir.js'
19
+ import {
20
+ keyLanesActive,
21
+ keyLanesEnabled,
22
+ keyLanesEnvSwitch,
23
+ keyLanesStatusProvider,
24
+ runKeyLanesInactiveHooks,
25
+ setKeyLanesEnabled,
26
+ } from '../lib/key-lanes.js'
27
+ import { deleteProfile, deleteProfilesFile, getProfile, listProfiles, upsertProfile, validateProfileInput } from '../lib/key-profiles.js'
28
+ import type { BreakerState, KeyKind, KeyLanesStatusBody } from '../lib/key-relay-contract.js'
29
+ import { KEY_REF_RE } from '../lib/key-relay-contract.js'
30
+ import {
31
+ deleteKey,
32
+ deleteKeyStore,
33
+ getPublicKey,
34
+ isKeyKind,
35
+ keyShapeProblem,
36
+ listPublicKeys,
37
+ normalizeCapUsd,
38
+ normalizeCompatibleBaseUrl,
39
+ saveKey,
40
+ updateKeyCap,
41
+ } from '../lib/key-store.js'
42
+ import { isLoopbackSocket } from '../lib/network-policy.js'
43
+ import { timingSafeTokenEqual } from '../lib/token-auth.js'
44
+ import { isKeyProfileId } from '../../shared/key-preference.js'
45
+ import { curatedKeyModels, downselectKeyCatalog, getKeyCatalog, KEY_CATALOG_CURATED, type KeyCatalogModel } from '../lib/key-catalog.js'
46
+ import { keyNoticeError } from '../lib/key-notices.js'
47
+ import { getSharedKeyMeter } from '../lib/key-relay-meter.js'
48
+ import { existingKeyRelay, installKeyRelaySeams } from '../lib/key-relay.js'
49
+
50
+ export const KEY_LANES_ENABLE_PATH = '/api/key-lanes/enable'
51
+ export const KEY_LEDGER_FILE = 'key-ledger.jsonl'
52
+ export const CODEX_HOMES_DIR = 'codex-homes'
53
+ const VALIDATE_TIMEOUT_MS = 10_000
54
+
55
+ export type KeyValidation =
56
+ | { ok: true; limitRemainingUsd?: number; isFreeTier?: boolean }
57
+ | { ok: false; error: string; message: string }
58
+
59
+ export interface CatalogModel {
60
+ id: string
61
+ name: string
62
+ inUsdPerM: number
63
+ outUsdPerM: number
64
+ }
65
+
66
+ export interface KeyLanesRouteDeps {
67
+ /** The pairing token. The door checks it itself so it never relies on mount order alone. */
68
+ apiToken?: () => string
69
+ /** Plan 10.3 provider check. Tests inject a fake; the default calls the provider once. */
70
+ validateKey?: (kind: KeyKind, secret: string, baseUrl?: string) => Promise<KeyValidation>
71
+ /** Public OpenRouter catalog (no key). Called only by GET /key-lanes/catalog. */
72
+ fetchCatalog?: () => Promise<CatalogModel[]>
73
+ downloadsDir?: () => string
74
+ now?: () => Date
75
+ }
76
+
77
+ // ── Who may write ────────────────────────────────────────────────────────────
78
+
79
+ export type MacOnlyRefusal = 'not_loopback' | 'browser'
80
+
81
+ /**
82
+ * Null for a request from a native client on this Mac (COS Control). A phone or G2 companion
83
+ * arrives on a LAN or Tailscale socket; a WebView or browser sends Origin (the companion's is
84
+ * `null`). An agent child on loopback with the token cannot be told apart in
85
+ * K1; it can still never read a secret back.
86
+ */
87
+ export function macOnlyRefusal(req: Pick<Request, 'socket' | 'headers'>): MacOnlyRefusal | null {
88
+ if (!isLoopbackSocket(req)) return 'not_loopback'
89
+ // Browsers send Origin on every POST and DELETE (Fetch Metadata alone is not a tell: Node's
90
+ // fetch sends sec-fetch-mode too).
91
+ if (req.headers.origin !== undefined) return 'browser'
92
+ return null
93
+ }
94
+
95
+ function requestPath(req: Request): string {
96
+ return (req.originalUrl || req.url).split('?')[0]
97
+ }
98
+
99
+ /** The one door that answers while lanes are off. Everything it does not admit is today's 404. */
100
+ export function enableDoorOpen(req: Request, apiToken: () => string): boolean {
101
+ return req.method === 'POST'
102
+ && requestPath(req) === KEY_LANES_ENABLE_PATH
103
+ && keyLanesEnvSwitch() !== 'off'
104
+ && timingSafeTokenEqual(req.headers['x-cos-token'], apiToken())
105
+ && macOnlyRefusal(req) === null
106
+ }
107
+
108
+ function admitted(req: Request, apiToken: () => string): boolean {
109
+ return keyLanesEnabled() || enableDoorOpen(req, apiToken)
110
+ }
111
+
112
+ const defaultApiToken = () => process.env.COS_API_TOKEN ?? ''
113
+
114
+ /**
115
+ * Mounted at `/api/key-lanes` BEFORE the global 10 MB JSON parser. A request it does not admit
116
+ * passes through untouched, so the global parser and the 404 behave exactly as before. An admitted
117
+ * one is parsed here at 32 KB, and a malformed body is refused without echoing a byte of it (Node's
118
+ * JSON errors quote the input, which could be a key).
119
+ */
120
+ export function createKeyLanesBodyParser(deps: Pick<KeyLanesRouteDeps, 'apiToken'> = {}): RequestHandler[] {
121
+ const apiToken = deps.apiToken ?? defaultApiToken
122
+ const parse = express.json({ limit: '32kb' })
123
+ const gate: RequestHandler = (req, res, next) => {
124
+ if (!admitted(req, apiToken)) return next()
125
+ parse(req, res, next)
126
+ }
127
+ const refuse: ErrorRequestHandler = (_err, req, res, next) => {
128
+ if (!admitted(req, apiToken)) return next(_err)
129
+ res.status(400).json({ error: 'bad_json', message: 'The request body is not valid JSON.' })
130
+ }
131
+ return [gate, refuse as unknown as RequestHandler]
132
+ }
133
+
134
+ // ── Provider checks (plan 10.3) ──────────────────────────────────────────────
135
+
136
+ const PROVIDER_NAME: Record<KeyKind, string> = {
137
+ openrouter: 'OpenRouter',
138
+ anthropic: 'Anthropic',
139
+ openai: 'OpenAI',
140
+ 'openai-compatible': 'The endpoint',
141
+ }
142
+
143
+ /** One GET per save. Never logs, never puts the key in a URL, never returns it. */
144
+ export async function defaultValidateKey(kind: KeyKind, secret: string, baseUrl?: string): Promise<KeyValidation> {
145
+ const name = PROVIDER_NAME[kind]
146
+ const target = kind === 'openrouter' ? 'https://openrouter.ai/api/v1/key'
147
+ : kind === 'anthropic' ? 'https://api.anthropic.com/v1/models'
148
+ : kind === 'openai' ? 'https://api.openai.com/v1/models'
149
+ : `${baseUrl ?? ''}/models`
150
+ const headers: Record<string, string> = kind === 'anthropic'
151
+ ? { 'x-api-key': secret, 'anthropic-version': '2023-06-01' }
152
+ : { authorization: `Bearer ${secret}` }
153
+ let response: Response
154
+ try {
155
+ response = await fetch(target, { headers, signal: AbortSignal.timeout(VALIDATE_TIMEOUT_MS), redirect: 'error' })
156
+ } catch {
157
+ return { ok: false, error: 'validation_unreachable', message: `Could not reach ${name} to check the key. Try again.` }
158
+ }
159
+ if (response.status === 401 || response.status === 403) {
160
+ return { ok: false, error: 'key_rejected', message: `${name} rejected this key. Check it and paste it again.` }
161
+ }
162
+ if (!response.ok) return { ok: false, error: 'validation_failed', message: `${name} could not check the key (HTTP ${response.status}). Try again.` }
163
+ if (kind !== 'openrouter') return { ok: true }
164
+ let data: { limit?: unknown; limit_remaining?: unknown; is_free_tier?: unknown } | undefined
165
+ try { data = ((await response.json()) as { data?: typeof data }).data } catch { data = undefined }
166
+ if (!data) return { ok: false, error: 'validation_failed', message: 'OpenRouter sent an answer COS could not read. Try again.' }
167
+ if (data.limit === null || data.limit === undefined) {
168
+ return { ok: false, error: 'no_credit_limit', message: keyNoticeError('no_credit_limit') }
169
+ }
170
+ if (typeof data.limit_remaining === 'number' && data.limit_remaining <= 0) {
171
+ return { ok: false, error: 'out_of_credit', message: 'This OpenRouter key has no credit left.' }
172
+ }
173
+ return {
174
+ ok: true,
175
+ ...(typeof data.limit_remaining === 'number' ? { limitRemainingUsd: data.limit_remaining } : {}),
176
+ ...(typeof data.is_free_tier === 'boolean' ? { isFreeTier: data.is_free_tier } : {}),
177
+ }
178
+ }
179
+
180
+ // ── Catalog (plan 10.4) ──────────────────────────────────────────────────────
181
+
182
+ /** The curated two (D10), from P5's key-catalog.ts, in the shape this route has always served. */
183
+ export const CURATED_KEY_MODELS = KEY_CATALOG_CURATED.map(({ id, name, inUsdPerM, outUsdPerM, role }) => ({ id, name, inUsdPerM, outUsdPerM, role }))
184
+
185
+ /** P11: the shared 24 h public catalog (key-catalog.ts). Throws when only the built-in list is known. */
186
+ export async function defaultFetchCatalog(): Promise<CatalogModel[]> {
187
+ const snapshot = await getKeyCatalog().get()
188
+ if (snapshot.from !== 'openrouter') throw new Error('catalog unavailable')
189
+ return snapshot.models
190
+ }
191
+
192
+ /** The next UTC midnight: when every key's daily cap resets (plan 4.5, OpenRouter usage_daily). */
193
+ export function keyCapResetsAt(now: Date): string {
194
+ return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1)).toISOString()
195
+ }
196
+
197
+ // ── Router ───────────────────────────────────────────────────────────────────
198
+
199
+ const CLOSED_BREAKER: BreakerState = { open: false, failures: 0 }
200
+
201
+ function statusBody(now: Date): KeyLanesStatusBody & { resetsAt: string } {
202
+ const provider = keyLanesStatusProvider()
203
+ return {
204
+ enabled: keyLanesEnabled(),
205
+ active: keyLanesActive(),
206
+ keys: listPublicKeys().map(k => ({
207
+ ref: k.ref,
208
+ kind: k.kind,
209
+ last4: k.last4,
210
+ capUsd: k.capUsd,
211
+ spentTodayUsd: provider.spentTodayUsd?.(k.ref) ?? 0,
212
+ breaker: provider.breaker?.(k.ref) ?? CLOSED_BREAKER,
213
+ })),
214
+ profiles: listProfiles(),
215
+ keyedSessionIds: provider.keyedSessionIds?.() ?? [],
216
+ // P11: the phone shows when today's caps reset. Never a secret: keys go out as ref and last 4.
217
+ resetsAt: keyCapResetsAt(now),
218
+ }
219
+ }
220
+
221
+ function ledgerExportName(dir: string, now: Date): string {
222
+ const stem = `cos-key-ledger-${now.toISOString().slice(0, 10)}`
223
+ let candidate = join(dir, `${stem}.jsonl`)
224
+ for (let n = 2; existsSync(candidate) && n < 100; n++) candidate = join(dir, `${stem}-${n}.jsonl`)
225
+ return candidate
226
+ }
227
+
228
+ export function createKeyLanesRouter(deps: KeyLanesRouteDeps = {}): Router {
229
+ const apiToken = deps.apiToken ?? defaultApiToken
230
+ const validateKey = deps.validateKey ?? defaultValidateKey
231
+ const fetchCatalog = deps.fetchCatalog ?? defaultFetchCatalog
232
+ const downloadsDir = deps.downloadsDir ?? (() => join(homedir(), 'Downloads'))
233
+ const now = deps.now ?? (() => new Date())
234
+
235
+ const router = Router()
236
+
237
+ // Off: the door or today's 404. `next('router')` leaves this router untouched.
238
+ router.use('/key-lanes', (req, _res, next) => {
239
+ if (admitted(req, apiToken)) return next()
240
+ return next('router')
241
+ })
242
+
243
+ const macOnly: RequestHandler = (req, res, next) => {
244
+ const refusal = macOnlyRefusal(req)
245
+ if (!refusal) return next()
246
+ return res.status(403).json({ error: 'mac_only', reason: refusal, message: 'Keys are managed in COS Control on this Mac.' })
247
+ }
248
+
249
+ /** After a write that may have ended the last enabled profile. */
250
+ const settle = async (wasActive: boolean, reason: 'disabled' | 'no_enabled_profile') => {
251
+ if (wasActive && !keyLanesActive()) await runKeyLanesInactiveHooks(reason)
252
+ }
253
+
254
+ router.post('/key-lanes/enable', macOnly, (_req, res) => {
255
+ setKeyLanesEnabled(true, now)
256
+ // P11: lanes turned on without a restart get the relay's status answers at once (map writes only).
257
+ installKeyRelaySeams()
258
+ res.json({ enabled: keyLanesEnabled(), active: keyLanesActive() })
259
+ })
260
+
261
+ router.post('/key-lanes/disable', macOnly, async (_req, res) => {
262
+ const wasActive = keyLanesActive()
263
+ setKeyLanesEnabled(false, now)
264
+ await settle(wasActive, 'disabled')
265
+ const forcedOn = keyLanesEnvSwitch() === 'on'
266
+ res.json({ enabled: keyLanesEnabled(), active: keyLanesActive(), ...(forcedOn ? { forcedOnBy: 'COS_KEY_LANES' } : {}) })
267
+ })
268
+
269
+ router.get('/key-lanes/status', (_req, res) => {
270
+ res.set('Cache-Control', 'private, no-store')
271
+ res.json(statusBody(now()))
272
+ })
273
+
274
+ router.post('/key-lanes/keys', macOnly, async (req, res) => {
275
+ res.set('Cache-Control', 'private, no-store')
276
+ const body = (req.body ?? {}) as { kind?: unknown; key?: unknown; ref?: unknown; capUsd?: unknown; baseUrl?: unknown }
277
+ // Cap change on a saved key: no secret in the body.
278
+ if (body.key === undefined && typeof body.ref === 'string') {
279
+ const cap = normalizeCapUsd(body.capUsd)
280
+ if (body.capUsd === undefined || cap === null) return res.status(400).json({ error: 'invalid_cap', message: 'A daily cap is between $0.01 and $1000.' })
281
+ const updated = updateKeyCap(body.ref, cap)
282
+ if (!updated) return res.status(404).json({ error: 'unknown_key', message: 'That key is not saved on this Mac.' })
283
+ // P11 (plan 4.5): the relay meters against the cap Control saved, not a later file edit.
284
+ getSharedKeyMeter().noteSavedCap(updated.ref, updated.capUsd)
285
+ return res.json({ key: updated })
286
+ }
287
+ if (!isKeyKind(body.kind)) return res.status(400).json({ error: 'invalid_kind', message: 'Pick OpenRouter, Anthropic, OpenAI or a custom endpoint.' })
288
+ const kind = body.kind
289
+ const shape = keyShapeProblem(kind, body.key)
290
+ if (shape) return res.status(400).json({ error: 'invalid_key', message: shape })
291
+ const cap = normalizeCapUsd(body.capUsd)
292
+ if (cap === null) return res.status(400).json({ error: 'invalid_cap', message: 'A daily cap is between $0.01 and $1000.' })
293
+ let baseUrl: string | undefined
294
+ if (kind === 'openai-compatible') {
295
+ baseUrl = normalizeCompatibleBaseUrl(body.baseUrl) ?? undefined
296
+ if (!baseUrl) return res.status(400).json({ error: 'invalid_base_url', message: 'Give the endpoint URL, starting with https://.' })
297
+ }
298
+ const secret = (body.key as string).trim()
299
+ let check: KeyValidation
300
+ try {
301
+ check = await validateKey(kind, secret, baseUrl)
302
+ } catch {
303
+ check = { ok: false, error: 'validation_failed', message: 'COS could not check the key. Try again.' }
304
+ }
305
+ if (!check.ok) return res.status(422).json({ error: check.error, message: check.message })
306
+ const saved = saveKey({ kind, secret, capUsd: cap, validatedAt: now().toISOString(), ...(baseUrl ? { baseUrl } : {}) }, now)
307
+ getSharedKeyMeter().noteSavedCap(saved.ref, saved.capUsd)
308
+ return res.json({
309
+ key: saved,
310
+ ...(check.limitRemainingUsd !== undefined || check.isFreeTier !== undefined
311
+ ? { credit: { ...(check.limitRemainingUsd !== undefined ? { remainingUsd: check.limitRemainingUsd } : {}), ...(check.isFreeTier !== undefined ? { freeTier: check.isFreeTier } : {}) } }
312
+ : {}),
313
+ })
314
+ })
315
+
316
+ // Remove one key (plan 17 item 3). Refused while an enabled profile uses it, unless ?force=1,
317
+ // which turns those profiles off first so no turn can pick a key that is gone.
318
+ router.delete('/key-lanes/keys/:ref', macOnly, async (req, res) => {
319
+ const ref = typeof req.params.ref === 'string' ? req.params.ref : ''
320
+ if (!KEY_REF_RE.test(ref)) return res.status(400).json({ error: 'invalid_key_ref', message: 'Unknown key.' })
321
+ if (!getPublicKey(ref)) return res.status(404).json({ error: 'unknown_key', message: 'That key is not saved on this Mac.' })
322
+ const force = req.query.force === '1'
323
+ const users = listProfiles().filter(p => p.keyRef === ref && p.enabled)
324
+ if (users.length && !force) {
325
+ return res.status(409).json({
326
+ error: 'key_in_use',
327
+ message: `Turn off the profiles that use this key first: ${users.map(p => p.label).join(', ')}.`,
328
+ profiles: users.map(p => p.id),
329
+ })
330
+ }
331
+ const wasActive = keyLanesActive()
332
+ for (const p of users) upsertProfile({ ...p, enabled: false, updatedAt: now().toISOString() })
333
+ for (const p of users) existingKeyRelay()?.revokeProfile(p.id)
334
+ deleteKey(ref)
335
+ await settle(wasActive, 'no_enabled_profile')
336
+ return res.json({ removed: true, disabledProfiles: users.map(p => p.id), active: keyLanesActive() })
337
+ })
338
+
339
+ // QA U6 / C8: Control's "Reset" for a key paused after three failures (plan 4.6).
340
+ router.post('/key-lanes/breaker/reset', macOnly, (req, res) => {
341
+ const ref = (req.body as { ref?: unknown } | undefined)?.ref
342
+ if (typeof ref !== 'string' || !KEY_REF_RE.test(ref)) return res.status(400).json({ error: 'invalid_key_ref', message: 'Unknown key.' })
343
+ if (!getPublicKey(ref)) return res.status(404).json({ error: 'unknown_key', message: 'That key is not saved on this Mac.' })
344
+ const relay = existingKeyRelay()
345
+ relay?.resetBreaker('key', ref)
346
+ return res.json({ reset: true, breaker: relay?.breakerState('key', ref) ?? CLOSED_BREAKER })
347
+ })
348
+
349
+ router.post('/key-lanes/profiles', macOnly, async (req, res) => {
350
+ const wasActive = keyLanesActive()
351
+ const result = validateProfileInput((req.body ?? {}) as Record<string, unknown>, ref => getPublicKey(ref)?.kind, now)
352
+ if (!result.ok) return res.status(400).json({ error: result.error, message: result.message })
353
+ const before = getProfile(result.profile.id)
354
+ const write = upsertProfile(result.profile)
355
+ if (!write.ok) return res.status(409).json({ error: write.error, message: 'That is the most key profiles COS keeps. Remove one first.' })
356
+ const p = result.profile
357
+ if (before) {
358
+ const relay = existingKeyRelay()
359
+ // QA W5: a profile turned off or pointed elsewhere stops its live turns now.
360
+ if (!p.enabled || before.keyRef !== p.keyRef || before.model !== p.model || before.engine !== p.engine) relay?.revokeProfile(p.id)
361
+ // QA U6: a new model or key is a fresh start for the profile's breaker.
362
+ if (before.keyRef !== p.keyRef || before.model !== p.model) relay?.resetBreaker('profile', p.id)
363
+ }
364
+ await settle(wasActive, 'no_enabled_profile')
365
+ return res.json({ profile: result.profile, active: keyLanesActive() })
366
+ })
367
+
368
+ router.delete('/key-lanes/profiles/:id', macOnly, async (req, res) => {
369
+ if (!isKeyProfileId(req.params.id)) return res.status(400).json({ error: 'invalid_profile', message: 'Unknown profile id.' })
370
+ const wasActive = keyLanesActive()
371
+ const removed = deleteProfile(req.params.id)
372
+ if (!removed) return res.status(404).json({ error: 'unknown_profile', message: 'No profile with that id.' })
373
+ existingKeyRelay()?.revokeProfile(req.params.id)
374
+ await settle(wasActive, 'no_enabled_profile')
375
+ return res.json({ removed: true, active: keyLanesActive() })
376
+ })
377
+
378
+ router.post('/key-lanes/remove-all', macOnly, async (req, res) => {
379
+ const body = (req.body ?? {}) as { exportLedger?: unknown }
380
+ if (body.exportLedger !== undefined && typeof body.exportLedger !== 'boolean') {
381
+ return res.status(400).json({ error: 'invalid_request', message: 'exportLedger is true or false.' })
382
+ }
383
+ const ledger = dataPath(KEY_LEDGER_FILE)
384
+ let ledgerExportedTo: string | undefined
385
+ if (body.exportLedger === true && existsSync(ledger)) {
386
+ // Export first: if it fails, nothing has been removed yet.
387
+ try {
388
+ const dir = downloadsDir()
389
+ mkdirSync(dir, { recursive: true })
390
+ ledgerExportedTo = ledgerExportName(dir, now())
391
+ copyFileSync(ledger, ledgerExportedTo)
392
+ } catch {
393
+ return res.status(500).json({ error: 'export_failed', message: 'Could not save the spend ledger to Downloads. Nothing was removed.' })
394
+ }
395
+ }
396
+ // Revoke nonces and close the relay (P2), clear session bindings (P6) before the files go.
397
+ const hookFailures = await runKeyLanesInactiveHooks('remove_all')
398
+ const removed: string[] = []
399
+ if (deleteKeyStore()) removed.push('keys')
400
+ if (deleteProfilesFile()) removed.push('profiles')
401
+ const homes = dataPath(CODEX_HOMES_DIR)
402
+ if (existsSync(homes)) { rmSync(homes, { recursive: true, force: true }); removed.push('codexHomes') }
403
+ if (existsSync(ledger)) { unlinkSync(ledger); removed.push('ledger') }
404
+ setKeyLanesEnabled(false, now)
405
+ return res.json({
406
+ removed,
407
+ enabled: keyLanesEnabled(),
408
+ active: keyLanesActive(),
409
+ ...(ledgerExportedTo ? { ledgerExportedTo } : {}),
410
+ ...(hookFailures.length ? { hookFailures } : {}),
411
+ })
412
+ })
413
+
414
+ router.get('/key-lanes/catalog', async (req, res) => {
415
+ const q = typeof req.query.q === 'string' ? req.query.q.trim().toLowerCase().slice(0, 40) : ''
416
+ // QA G5: one 24 h cache (key-catalog.ts), not a second one here.
417
+ let models: CatalogModel[] | null
418
+ try {
419
+ models = await fetchCatalog()
420
+ } catch {
421
+ models = null
422
+ }
423
+ // P11: curated prices and lanes from P5's key-catalog.ts, the one catalog module.
424
+ const curated = curatedKeyModels(models as KeyCatalogModel[] | null)
425
+ .map(({ id, name, inUsdPerM, outUsdPerM, role }) => ({ id, name, inUsdPerM, outUsdPerM, role }))
426
+ // Lane B takes only anthropic/ and openai/ slugs; every other model is direct only (plan 4.4).
427
+ // QA G5: P5's downselect (20 rows, one 64-character lens row each, unsafe glyphs cleaned),
428
+ // reporting only the rows that match the search.
429
+ let search: Array<CatalogModel & { lanes: string[]; label: string }> | undefined
430
+ if (q && models) {
431
+ const byId = new Map(models.map(m => [m.id, m]))
432
+ search = downselectKeyCatalog(models as KeyCatalogModel[], { query: q })
433
+ .flatMap(r => {
434
+ const m = byId.get(r.id)
435
+ return m && (m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q)) ? [{ ...m, lanes: r.lanes, label: r.label }] : []
436
+ })
437
+ }
438
+ res.json({ curated, pricesFrom: models ? 'openrouter' : 'built-in', ...(search ? { search } : {}) })
439
+ })
440
+
441
+ return router
442
+ }