@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,94 @@
1
+ // Key lanes K1 P3 (plan v4 7.2, W17): the Claude settings-layer check before a keyed spawn.
2
+ //
3
+ // Claude Code applies the `env` block of its settings files over the process environment, and an
4
+ // `apiKeyHelper` hands it a credential of its own. Either would point a keyed turn somewhere other
5
+ // than the loopback relay, so the meter and the cap would not see it (or the user's other
6
+ // credential would pay for it). Before a profile spawn the three files the CLI reads for this
7
+ // working directory are checked; any `ANTHROPIC_*` or `CLAUDE_CODE_USE_*` name in `env`, or an
8
+ // `apiKeyHelper`, refuses the profile with a line that names it.
9
+ //
10
+ // Read only on a keyed turn. Never writes, never creates, never reads a value aloud: the refusal
11
+ // names the setting, not what it is set to. A file that does not parse is skipped, because the CLI
12
+ // cannot apply a file it cannot parse either.
13
+ import { existsSync, readFileSync, statSync } from 'node:fs'
14
+ import { homedir } from 'node:os'
15
+ import { join } from 'node:path'
16
+
17
+ export const CLAUDE_SETTINGS_REFUSED_ENV = /^(ANTHROPIC_|CLAUDE_CODE_USE_|CLAUDE_CODE_OAUTH_TOKEN$|AWS_BEARER_TOKEN_BEDROCK$)/
18
+
19
+ /** QA W4 / G7: the managed (enterprise) settings files Claude Code reads above every user file. */
20
+ export const CLAUDE_MANAGED_SETTINGS_FILES: readonly string[] = [
21
+ '/Library/Application Support/ClaudeCode/managed-settings.json',
22
+ '/etc/claude-code/managed-settings.json',
23
+ ]
24
+
25
+ export interface ClaudeSettingsRefusal {
26
+ /** The file that sets it. */
27
+ file: string
28
+ /** The env name, or `apiKeyHelper`. */
29
+ name: string
30
+ message: string
31
+ }
32
+
33
+ export function claudeSettingsFiles(input: { cwd: string; home?: string; env?: NodeJS.ProcessEnv; managed?: readonly string[] }): string[] {
34
+ const home = input.home ?? homedir()
35
+ const env = input.env ?? process.env
36
+ // QA W4 / G7: CLAUDE_CONFIG_DIR moves the user settings; both places are checked.
37
+ const configDir = typeof env.CLAUDE_CONFIG_DIR === 'string' && env.CLAUDE_CONFIG_DIR.trim() ? env.CLAUDE_CONFIG_DIR.trim() : null
38
+ return [
39
+ ...(input.managed ?? CLAUDE_MANAGED_SETTINGS_FILES),
40
+ ...(configDir ? [join(configDir, 'settings.json')] : []),
41
+ join(home, '.claude', 'settings.json'),
42
+ join(input.cwd, '.claude', 'settings.json'),
43
+ join(input.cwd, '.claude', 'settings.local.json'),
44
+ ]
45
+ }
46
+
47
+ function readSettings(file: string): Record<string, unknown> | null {
48
+ try {
49
+ if (!existsSync(file) || !statSync(file).isFile()) return null
50
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
51
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ export function claudeSettingsRefusalMessage(name: string): string {
58
+ return `A Claude settings file sets ${name}. Remove it to use a key.`
59
+ }
60
+
61
+ /**
62
+ * Canary R12: names a settings file may set that do not bypass the relay but that a keyed run
63
+ * must not inherit. They are not refused (MU's own project file sets one); the keyed run's flag
64
+ * layer forces them off, and this reports which file set them so the log says so.
65
+ */
66
+ export const CLAUDE_SETTINGS_WATCHED_ENV: readonly string[] = ['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS']
67
+
68
+ export function claudeSettingsWatched(input: { cwd: string; home?: string; env?: NodeJS.ProcessEnv; managed?: readonly string[] }): Array<{ file: string; name: string }> {
69
+ const out: Array<{ file: string; name: string }> = []
70
+ for (const file of claudeSettingsFiles(input)) {
71
+ const env = readSettings(file)?.env
72
+ if (!env || typeof env !== 'object' || Array.isArray(env)) continue
73
+ for (const name of CLAUDE_SETTINGS_WATCHED_ENV) if (Object.prototype.hasOwnProperty.call(env, name)) out.push({ file, name })
74
+ }
75
+ return out
76
+ }
77
+
78
+ /** The first setting that would bypass the relay, or null when a keyed spawn may go ahead. */
79
+ export function claudeSettingsRefusal(input: { cwd: string; home?: string; env?: NodeJS.ProcessEnv; managed?: readonly string[] }): ClaudeSettingsRefusal | null {
80
+ for (const file of claudeSettingsFiles(input)) {
81
+ const settings = readSettings(file)
82
+ if (!settings) continue
83
+ if (settings.apiKeyHelper !== undefined && settings.apiKeyHelper !== null && settings.apiKeyHelper !== '') {
84
+ return { file, name: 'apiKeyHelper', message: claudeSettingsRefusalMessage('apiKeyHelper') }
85
+ }
86
+ const env = settings.env
87
+ if (env && typeof env === 'object' && !Array.isArray(env)) {
88
+ for (const name of Object.keys(env).sort()) {
89
+ if (CLAUDE_SETTINGS_REFUSED_ENV.test(name)) return { file, name, message: claudeSettingsRefusalMessage(name) }
90
+ }
91
+ }
92
+ }
93
+ return null
94
+ }
@@ -21,7 +21,9 @@
21
21
  // SteerTracker sent and covered uuids; what a result means (finish, wait, steering off)
22
22
  // steerCutoffMs how close to a deadline a steer is refused as too late
23
23
  // resolveRunModel the model a run will use, exactly as model-router resolves it
24
+ // steerProviderFor the steer gate's provider; a key-profile job is `profile_session`
24
25
  import { DEFAULT_MODEL, isCodexModel, isCursorModel, isOllamaModel, normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
26
+ import { parsePreference } from '../../shared/key-preference.js'
25
27
 
26
28
  /** Quiet after a result that left a steer uncovered, before stdin is closed (plan P4). */
27
29
  export const STEER_SETTLE_MS = 20_000
@@ -257,3 +259,28 @@ export function providerOfModel(model: ModelPreference): QueryProvider {
257
259
  if (isCursorModel(model)) return 'cursor'
258
260
  return isCodexModel(model) ? 'codex' : 'claude'
259
261
  }
262
+
263
+ export type SteerProviderVerdict =
264
+ | { kind: 'provider'; provider: QueryProvider }
265
+ | { kind: 'profile_session'; profileId: string }
266
+
267
+ /**
268
+ * Key lanes K1 (plan 5.2): the explicit guard in front of resolveRunModel / providerOfModel,
269
+ * which stay CLOSED. A job whose request names `profile:<id>`, or that names no model in a
270
+ * session bound to one, is a `profile_session`; the caller asks the profile's engine whether it
271
+ * steers. Without this guard both fall through to the Claude default and a Codex profile's job
272
+ * would be offered Claude steering. With no profile anywhere the verdict is exactly
273
+ * `providerOfModel(resolveRunModel(model, sessionModel, envDefault))`. The session binding is read
274
+ * only when nothing else names the model, as model-router does.
275
+ */
276
+ export function steerProviderFor(
277
+ model: unknown,
278
+ sessionModel: ModelPreference | null | undefined,
279
+ envDefault: unknown,
280
+ sessionBinding: () => unknown,
281
+ ): SteerProviderVerdict {
282
+ const requested = parsePreference(model)
283
+ const bound = requested ?? (sessionModel == null ? parsePreference(sessionBinding()) : undefined)
284
+ if (bound?.kind === 'profile') return { kind: 'profile_session', profileId: bound.id }
285
+ return { kind: 'provider', provider: providerOfModel(resolveRunModel(model, sessionModel, envDefault)) }
286
+ }
@@ -86,6 +86,24 @@ import { terminalProviderAuthFailure } from './provider-terminal-error.js'
86
86
  import { terminateProviderProcess } from './provider-process-lifecycle.js'
87
87
  import { teeJobTrail } from './job-trail.js'
88
88
  import { applyTurnRequestEnv, createTurnRequestBinding, spawnWithTurnRequest } from './turn-request.js'
89
+ // Key lanes K1 P4 (plan v4 8): Lane B Codex. Every use below sits behind `options.keyProfile`;
90
+ // without one, argv, env, the catalog read and the resume binding are the 963fe25 ones.
91
+ import { randomUUID } from 'node:crypto'
92
+ import { profilePreference } from '../../shared/key-preference.js'
93
+ import type { AnswerKeyFields, KeyKind, KeyProfile, RelayTurnHandle } from './key-relay-contract.js'
94
+ import { getSharedKeyMeter } from './key-relay-meter.js'
95
+ import {
96
+ CODEX_LANE_B_COPY,
97
+ CODEX_RELAY_PROVIDER_ID,
98
+ applyCodexLaneBEnv,
99
+ codexKeyRelay,
100
+ codexProfilePreflight,
101
+ recordKeyedCodexThread,
102
+ renderCodexProfileHome,
103
+ writeCodexProfileHome,
104
+ type CodexKeyLaneCallOptions,
105
+ type RenderedCodexProfileHome,
106
+ } from './codex-profile-home.js'
89
107
 
90
108
  const INACTIVITY_MS = 180_000
91
109
  const WALL_MAX_MS = 900_000
@@ -240,6 +258,50 @@ export function buildCodexExecArgs(input: {
240
258
  return args
241
259
  }
242
260
 
261
+ /**
262
+ * Key lanes (plan v4 8.1): argv for a keyed Codex turn. The user's COS_CODEX_EXTRA_ARGS, the
263
+ * ChatGPT model catalog and its effort and service-tier settings never reach it: the profile's
264
+ * slug goes on `--model` and the generated home's relay is selected as the provider. Sandbox,
265
+ * publisher directory, resume and image handling are the subscription ones.
266
+ */
267
+ export function buildCodexProfileExecArgs(input: {
268
+ codexCwd: string
269
+ imagePaths?: string[]
270
+ persistentCodexSession: boolean
271
+ codexThreadId?: string
272
+ profileModel: string
273
+ publisherWritableDirectory?: string
274
+ }): string[] {
275
+ const imagePaths = input.imagePaths ?? []
276
+ const args = ['exec', ...codexSandboxArgs()]
277
+ if (input.publisherWritableDirectory) {
278
+ args.push('--add-dir', input.publisherWritableDirectory)
279
+ }
280
+ // A trusted project's own config could name another provider; argv wins over every layer.
281
+ args.push('-c', `model_provider="${CODEX_RELAY_PROVIDER_ID}"`)
282
+ if (input.codexThreadId) {
283
+ args.push('resume', '--json', '--all', '--model', input.profileModel)
284
+ for (const p of imagePaths) args.push('--image', p)
285
+ args.push(input.codexThreadId, '-')
286
+ return args
287
+ }
288
+ args.push('--json', '--cd', input.codexCwd, '--model', input.profileModel)
289
+ if (!input.persistentCodexSession) args.push('--ephemeral')
290
+ for (const p of imagePaths) args.push('--image', p)
291
+ args.push('-')
292
+ return args
293
+ }
294
+
295
+ /** Key lanes: a spawn that throws synchronously revokes the turn's nonce. Without one it is the call itself. */
296
+ function withKeyTurnRevokedOnThrow<T>(keyTurn: RelayTurnHandle | null, run: () => T): T {
297
+ try {
298
+ return run()
299
+ } catch (err) {
300
+ keyTurn?.revoke('cancel')
301
+ throw err
302
+ }
303
+ }
304
+
243
305
  function buildCodexPrompt(systemPrompt: string, fullQuery: string): string {
244
306
  return [
245
307
  'SYSTEM INSTRUCTIONS',
@@ -300,6 +362,23 @@ function toolStatus(event: any): string | undefined {
300
362
  return undefined
301
363
  }
302
364
 
365
+ /**
366
+ * Key lanes: the Codex runner in P6's engine-seam shape (`registerKeyProfileEngine('codex', ...)`
367
+ * in key-profile-routing.ts; P11 wires it). The turn's profile rides `options.keyProfile`.
368
+ */
369
+ export function runCodexKeyProfileTurn(
370
+ query: string,
371
+ sessionId: string,
372
+ callbacks: StreamCallbacks,
373
+ turn: { profile: KeyProfile },
374
+ images?: ModelImageInput[],
375
+ reference?: PromptReference,
376
+ globalMsgNum?: number,
377
+ options?: CallOptions,
378
+ ): Promise<string> {
379
+ return callCodexStreaming(query, sessionId, callbacks, undefined, images, reference, globalMsgNum, { ...options, keyProfile: turn.profile })
380
+ }
381
+
303
382
  function safeCodexUserError(message: string): string {
304
383
  const code = classifyCodexError(message)
305
384
  if (code === 'codex.cli_unavailable') return 'Codex CLI unavailable. Check server Settings.'
@@ -317,27 +396,55 @@ export async function callCodexStreaming(
317
396
  images?: ModelImageInput[],
318
397
  reference?: PromptReference,
319
398
  globalMsgNum?: number,
320
- options?: CallOptions,
399
+ options?: CallOptions & CodexKeyLaneCallOptions,
321
400
  ): Promise<string> {
322
401
  const sid = getOrCreateSession(sessionId)
323
- // Refresh is TTL-cached and coalesced, so every run sees the newest known
324
- // catalog without creating duplicate app-server discovery processes.
325
- await getCodexModelCatalog()
402
+ // Key lanes: a keyed turn passes every check before history, the run ledger or a process, and
403
+ // never reads the ChatGPT catalog or COS_CODEX_EXTRA_ARGS. A refusal ends the turn with its lens
404
+ // line; the subscription is never the fallback.
405
+ const keyProfile = options?.keyProfile
406
+ let keyKind: KeyKind | null = null
407
+ let profileHome: RenderedCodexProfileHome | null = null
408
+ if (keyProfile) {
409
+ const preflight = codexProfilePreflight({ profile: keyProfile, dispatch: !!options?.dispatch, cwd: getCodexExecutionCwd() })
410
+ if (!preflight.ok) {
411
+ await callbacks.onError(preflight.message)
412
+ return sid
413
+ }
414
+ keyKind = preflight.keyKind
415
+ try {
416
+ profileHome = renderCodexProfileHome(keyProfile)
417
+ } catch (err) {
418
+ console.error(`[codex-bridge] key profile home render failed: ${err instanceof Error ? err.message.slice(0, 200) : 'unknown'}`)
419
+ await callbacks.onError(CODEX_LANE_B_COPY.relayUnavailable)
420
+ return sid
421
+ }
422
+ } else {
423
+ // Refresh is TTL-cached and coalesced, so every run sees the newest known
424
+ // catalog without creating duplicate app-server discovery processes.
425
+ await getCodexModelCatalog()
426
+ }
326
427
  if (options?.abortSignal?.aborted) {
327
428
  throw new Error('codex-bridge: client disconnected before Codex started.')
328
429
  }
329
430
  let extra: string[]
330
- try {
331
- extra = parseCodexExtraArgs(process.env.COS_CODEX_EXTRA_ARGS)
332
- assertSafeCodexExtraArgs(extra)
333
- } catch (error) {
334
- if (error instanceof CodexExtraArgsError) {
335
- await callbacks.onError(error.message)
336
- return sid
431
+ if (keyProfile) {
432
+ extra = []
433
+ } else {
434
+ try {
435
+ extra = parseCodexExtraArgs(process.env.COS_CODEX_EXTRA_ARGS)
436
+ assertSafeCodexExtraArgs(extra)
437
+ } catch (error) {
438
+ if (error instanceof CodexExtraArgsError) {
439
+ await callbacks.onError(error.message)
440
+ return sid
441
+ }
442
+ throw error
337
443
  }
338
- throw error
339
444
  }
340
- const extraFingerprint = codexEngineFingerprint(extra)
445
+ // Plan 8.1 resume binding: a keyed thread resumes only under its own profile and generated
446
+ // config, and a subscription turn never resumes it (nor a keyed turn a subscription thread).
447
+ const extraFingerprint = profileHome ? profileHome.engineFingerprint : codexEngineFingerprint(extra)
341
448
  const history = getHistory(sid)
342
449
  const session = getSessionRaw(sid)
343
450
  const contextBreaks = session?.contextBreaks ?? []
@@ -346,8 +453,11 @@ export async function callCodexStreaming(
346
453
  const persistentCodexSession = isCodexPersistenceEnabled()
347
454
  const codexCwd = getCodexExecutionCwd()
348
455
  const codexTrustMode = getCodexTrustMode()
349
- const resolvedCodexModel = resolveCodexModelOption(model)
350
- const resolvedCodexEffort = resolveCodexEffortForModel(resolvedCodexModel, options?.effort)
456
+ // A keyed turn's model is the profile slug; the catalog is never consulted for it.
457
+ const resolvedCodexModel = keyProfile ? null : resolveCodexModelOption(model)
458
+ const resolvedCodexEffort = resolvedCodexModel ? resolveCodexEffortForModel(resolvedCodexModel, options?.effort) : 'provider-default'
459
+ // A keyed exchange is stamped with its profile (P6 persists it by syntax), never with the slot.
460
+ const exchangeModel = keyProfile ? profilePreference(keyProfile.id) as unknown as CodexModelPreference : model
351
461
  const engineSession = persistentCodexSession
352
462
  ? getCodexEngineSession({
353
463
  cosSessionId: sid,
@@ -355,6 +465,7 @@ export async function callCodexStreaming(
355
465
  cwd: codexCwd,
356
466
  trustMode: codexTrustMode,
357
467
  engineFingerprint: extraFingerprint,
468
+ ...(keyProfile ? { profileId: keyProfile.id } : {}),
358
469
  })
359
470
  : null
360
471
  const imageInputs: ModelImageInput[] = images ?? []
@@ -370,7 +481,7 @@ export async function callCodexStreaming(
370
481
  trustMode: codexTrustMode,
371
482
  codexThreadId: engineSession?.codexThreadId,
372
483
  expiresAt: engineSession?.expiresAt,
373
- cliModel: resolvedCodexModel.id || 'codex-cli-default',
484
+ cliModel: keyProfile ? keyProfile.model : (resolvedCodexModel?.id || 'codex-cli-default'),
374
485
  reasoningEffort: resolvedCodexEffort,
375
486
  query,
376
487
  })
@@ -438,7 +549,7 @@ export async function callCodexStreaming(
438
549
  globalMsgNum,
439
550
  inboundAttachments,
440
551
  exchangeProvenance,
441
- model,
552
+ exchangeModel,
442
553
  )
443
554
 
444
555
  const hasNonImageAttachment = requestRefs.some(ref => mediaCategoryOf(ref) !== 'image')
@@ -455,17 +566,26 @@ export async function callCodexStreaming(
455
566
  if (options?.attachmentPromptBlock) fullQuery = `${fullQuery}\n\n${options.attachmentPromptBlock}`
456
567
 
457
568
  const prompt = buildCodexPrompt(systemPrompt, fullQuery)
458
- const args = buildCodexExecArgs({
459
- codexCwd,
460
- imagePaths,
461
- persistentCodexSession,
462
- codexThreadId: engineSession?.codexThreadId,
463
- model,
464
- resolvedModel: resolvedCodexModel,
465
- effort: options?.effort,
466
- publisherWritableDirectory: outputImagePublisher?.writableDirectory,
467
- extra,
468
- })
569
+ const args = keyProfile
570
+ ? buildCodexProfileExecArgs({
571
+ codexCwd,
572
+ imagePaths,
573
+ persistentCodexSession,
574
+ codexThreadId: engineSession?.codexThreadId,
575
+ profileModel: keyProfile.model,
576
+ publisherWritableDirectory: outputImagePublisher?.writableDirectory,
577
+ })
578
+ : buildCodexExecArgs({
579
+ codexCwd,
580
+ imagePaths,
581
+ persistentCodexSession,
582
+ codexThreadId: engineSession?.codexThreadId,
583
+ model,
584
+ resolvedModel: resolvedCodexModel!,
585
+ effort: options?.effort,
586
+ publisherWritableDirectory: outputImagePublisher?.writableDirectory,
587
+ extra,
588
+ })
469
589
 
470
590
  const env = { ...process.env }
471
591
  delete env.CLAUDECODE
@@ -482,6 +602,43 @@ export async function callCodexStreaming(
482
602
  )
483
603
  }
484
604
 
605
+ // Key lanes: one nonce per turn from the relay (P2 contract), the generated home written with
606
+ // its origin, then the child env points at both. Only the child's copy is written. A relay
607
+ // that cannot issue, or a home that cannot be written, ends the turn before spawn.
608
+ let keyTurn: RelayTurnHandle | null = null
609
+ const keyTurnId = keyProfile && profileHome ? options?.turnId ?? randomUUID() : null
610
+ if (keyProfile && profileHome) {
611
+ try {
612
+ const relay = await codexKeyRelay()
613
+ keyTurn = await relay.issueTurn({
614
+ keyRef: keyProfile.keyRef,
615
+ profileId: keyProfile.id,
616
+ turnId: keyTurnId!,
617
+ sessionId: sid,
618
+ engine: 'codex',
619
+ allowedModel: keyProfile.model,
620
+ }, { wallMaxMs: WALL_MAX_MS })
621
+ writeCodexProfileHome(profileHome, keyTurn.origin)
622
+ applyCodexLaneBEnv(env, { home: profileHome.home, nonce: keyTurn.nonce, profileId: keyProfile.id })
623
+ } catch (err) {
624
+ keyTurn?.revoke('cancel')
625
+ outputImagePublisher?.cleanup()
626
+ cleanupModelImageInputs(imageInputs)
627
+ removeExchange(sid, pendingUserExchange)
628
+ try {
629
+ finishCodexRun(run.runId, { status: 'failed', startedAtMs: startTime, error: 'codex-bridge: key relay refused the turn.', exitCode: null })
630
+ } catch { /* the ledger is best effort here */ }
631
+ console.error(`[codex-bridge] key relay refused the turn: ${err instanceof Error ? err.message.slice(0, 200) : 'unknown'}`)
632
+ await callbacks.onError(CODEX_LANE_B_COPY.relayUnavailable)
633
+ return sid
634
+ }
635
+ }
636
+ const keyAuditFields = keyProfile && keyKind ? { profile: keyProfile.id, lane: 'key' as const, credentialKind: keyKind } : {}
637
+ // Key lanes (P11, plan 3.1): `lane` and `credential` on a keyed answer, omitted otherwise.
638
+ const keyLane: AnswerKeyFields | null = keyProfile && keyKind
639
+ ? { lane: 'key', credential: { kind: keyKind, ref: keyProfile.keyRef, profileId: keyProfile.id } }
640
+ : null
641
+
485
642
  // The COS UserPromptSubmit hook sees `prompt`, which is SYSTEM INSTRUCTIONS plus USER
486
643
  // REQUEST in one text, on a fresh thread and on resume alike. This private file hands it
487
644
  // the user's own words, bound by the hash of exactly what goes on stdin; the file dies
@@ -489,12 +646,12 @@ export async function callCodexStreaming(
489
646
  const turnRequest = createTurnRequestBinding({ userText: query, prompt, turnId: options?.turnId })
490
647
  applyTurnRequestEnv(env, turnRequest)
491
648
 
492
- const proc = spawnWithTurnRequest(turnRequest, () => spawn(resolvedCodex.path, args, {
649
+ const proc = withKeyTurnRevokedOnThrow(keyTurn, () => spawnWithTurnRequest(turnRequest, () => spawn(resolvedCodex.path, args, {
493
650
  stdio: ['pipe', 'pipe', 'pipe'],
494
651
  env,
495
652
  cwd: codexCwd,
496
653
  detached: true,
497
- }))
654
+ })))
498
655
 
499
656
  let fullText = ''
500
657
  let stderr = ''
@@ -519,7 +676,7 @@ export async function callCodexStreaming(
519
676
  function clearEngineSessionBestEffort(reason: string) {
520
677
  if (!engineSession) return
521
678
  try {
522
- clearCodexEngineSession(sid, model)
679
+ clearCodexEngineSession(sid, model, keyProfile?.id)
523
680
  } catch (error) {
524
681
  console.error(`[codex-bridge] engine session clear failed (${reason}):`, error)
525
682
  }
@@ -535,6 +692,7 @@ export async function callCodexStreaming(
535
692
  cwd: codexCwd,
536
693
  trustMode: codexTrustMode,
537
694
  engineFingerprint: extraFingerprint,
695
+ ...(keyProfile ? { profileId: keyProfile.id } : {}),
538
696
  })
539
697
  updateCodexRun(run.runId, { codexThreadId, expiresAt: saved.expiresAt })
540
698
  } catch (error) {
@@ -570,6 +728,8 @@ export async function callCodexStreaming(
570
728
  return
571
729
  }
572
730
  finalized = true
731
+ // Canary R14: the turn is over the moment the bridge says so; the child gets no more calls on the key.
732
+ keyTurn?.revoke('child_close')
573
733
  cleanup()
574
734
  cleanupImages()
575
735
 
@@ -615,7 +775,7 @@ export async function callCodexStreaming(
615
775
  globalMsgNum,
616
776
  undefined,
617
777
  exchangeProvenance,
618
- model,
778
+ exchangeModel,
619
779
  )
620
780
  if (imagePaths.length > 0) {
621
781
  replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
@@ -649,6 +809,8 @@ export async function callCodexStreaming(
649
809
  }
650
810
 
651
811
  const totalMs = Date.now() - startTime
812
+ // Key lanes (P11): this turn's settled spend on the key. Keyed turns only.
813
+ const keyUsdFields = keyTurnId ? { usd: Math.round(getSharedKeyMeter().turnSettledUsd(keyTurnId) * 1e6) / 1e6 } : {}
652
814
  logTokenAudit({
653
815
  source: options?.lightweight ? 'g2-voice' : 'g2-query',
654
816
  model,
@@ -656,6 +818,8 @@ export async function callCodexStreaming(
656
818
  outputChars: text.length,
657
819
  durationMs: totalMs,
658
820
  caller: options?.lightweight ? 'voice_query' : 'full_query',
821
+ ...keyAuditFields,
822
+ ...keyUsdFields,
659
823
  })
660
824
  saveEngineSessionBestEffort()
661
825
 
@@ -673,6 +837,7 @@ export async function callCodexStreaming(
673
837
  generation: options?.generation,
674
838
  ...(outputAttachments.length > 0 ? { outputAttachments } : {}),
675
839
  ...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
840
+ ...(keyLane ? { keyLane, ...keyUsdFields } : {}),
676
841
  })
677
842
  if (terminalOwned === false) return
678
843
  } catch (error) {
@@ -689,6 +854,8 @@ export async function callCodexStreaming(
689
854
  async function finalizeError(msg: string, exitCode?: number | null, status: Exclude<CodexRunStatus, 'running'> = 'failed') {
690
855
  if (finalized) return
691
856
  finalized = true
857
+ // Canary R14: a Codex child keeps retrying after the bridge ends the turn; its nonce dies now.
858
+ keyTurn?.revoke('cancel')
692
859
  cleanup()
693
860
  cleanupImages()
694
861
  outputImagePublisher?.cleanup()
@@ -739,6 +906,7 @@ export async function callCodexStreaming(
739
906
  }
740
907
 
741
908
  function handleAbort() {
909
+ keyTurn?.revoke('cancel')
742
910
  void terminateForTerminal('client disconnect', result => finalizeError(
743
911
  'codex-bridge: client disconnected before Codex completed.',
744
912
  result.code,
@@ -784,6 +952,9 @@ export async function callCodexStreaming(
784
952
  if (nextThreadId && nextThreadId !== codexThreadId) {
785
953
  codexThreadId = nextThreadId
786
954
  updateCodexRun(run.runId, { codexThreadId })
955
+ // Key lanes (plan 7.3): recorded before any answer text, so Continue, Fork, attach and
956
+ // handoff refuse this thread from the moment it exists.
957
+ if (keyProfile) recordKeyedCodexThread({ id: nextThreadId, engine: 'codex', profileId: keyProfile.id, cosSessionId: sid })
787
958
  }
788
959
  // 6.62.0 (plan 3.9): the job links to its native thread at the first event naming it,
789
960
  // not only at completion.
@@ -860,6 +1031,7 @@ export async function callCodexStreaming(
860
1031
  })
861
1032
 
862
1033
  proc.on('close', (code) => {
1034
+ keyTurn?.childClosed()
863
1035
  if (terminationRequested) return
864
1036
  if (buffer.trim()) {
865
1037
  try { handleEvent(JSON.parse(buffer.trim())) } catch { /* ignore */ }
@@ -880,6 +1052,7 @@ export async function callCodexStreaming(
880
1052
  })
881
1053
 
882
1054
  proc.on('error', (err) => {
1055
+ keyTurn?.revoke('cancel')
883
1056
  if (terminationRequested) return
884
1057
  finalizeError(`codex-bridge: ${err.message}`)
885
1058
  })
@@ -5,6 +5,7 @@ import {
5
5
  normalizeModelPreference,
6
6
  type CodexModelPreference,
7
7
  } from '../../shared/model-preference.js'
8
+ import { isKeyProfileId } from '../../shared/key-preference.js'
8
9
 
9
10
  export const CODEX_ENGINE_SESSION_TTL_MS = 2 * 60 * 60_000
10
11
 
@@ -21,6 +22,12 @@ export interface CodexEngineSession {
21
22
  lastUsedAt: number
22
23
  expiresAt: string
23
24
  engineFingerprint: string
25
+ /**
26
+ * Key lanes (P11): the key profile this thread runs under. Its own slot, so a keyed turn and a
27
+ * subscription turn of one COS session never overwrite each other's resume. Absent on every
28
+ * subscription row, which keeps its 6.79.0 key and bytes.
29
+ */
30
+ profileId?: string
24
31
  }
25
32
 
26
33
  interface CodexEngineSessionFile {
@@ -28,8 +35,12 @@ interface CodexEngineSessionFile {
28
35
  savedAt: string
29
36
  }
30
37
 
31
- function sessionKey(cosSessionId: string, model: CodexModelPreference): string {
32
- return `${cosSessionId}:${model}`
38
+ function sessionKey(cosSessionId: string, model: CodexModelPreference, profileId?: string): string {
39
+ return profileId ? `${cosSessionId}:${model}:profile:${profileId}` : `${cosSessionId}:${model}`
40
+ }
41
+
42
+ function slotProfile(raw: unknown): string | undefined {
43
+ return isKeyProfileId(raw) ? raw : undefined
33
44
  }
34
45
 
35
46
  export function getCodexEngineSessionPath(): string {
@@ -58,7 +69,8 @@ function readStore(): CodexEngineSessionFile {
58
69
  typeof raw?.lastUsedAt !== 'number' ||
59
70
  typeof raw?.expiresAt !== 'string'
60
71
  ) continue
61
- const key = sessionKey(raw.cosSessionId, model)
72
+ if (raw.profileId !== undefined && !slotProfile(raw.profileId)) continue
73
+ const key = sessionKey(raw.cosSessionId, model, slotProfile(raw.profileId))
62
74
  const normalized = { ...raw, key, model } as CodexEngineSession
63
75
  const prior = sessions[key]
64
76
  if (!prior || normalized.lastUsedAt > prior.lastUsedAt) sessions[key] = normalized
@@ -95,12 +107,13 @@ export function getCodexEngineSession(input: {
95
107
  cwd: string
96
108
  trustMode: CodexTrustMode
97
109
  engineFingerprint?: string
110
+ profileId?: string
98
111
  }): CodexEngineSession | null {
99
112
  const { engineFingerprint = '', ...rest } = input
100
113
  const store = readStore()
101
114
  const now = Date.now()
102
115
  const sessions = pruneExpired(store.sessions, now)
103
- const existing = sessions[sessionKey(rest.cosSessionId, rest.model)]
116
+ const existing = sessions[sessionKey(rest.cosSessionId, rest.model, slotProfile(rest.profileId))]
104
117
  if (!existing) {
105
118
  if (Object.keys(sessions).length !== Object.keys(store.sessions).length) {
106
119
  writeStore({ sessions, savedAt: new Date().toISOString() })
@@ -120,11 +133,13 @@ export function saveCodexEngineSession(input: {
120
133
  trustMode: CodexTrustMode
121
134
  now?: number
122
135
  engineFingerprint?: string
136
+ profileId?: string
123
137
  }): CodexEngineSession {
124
138
  const now = input.now ?? Date.now()
125
139
  const store = readStore()
126
140
  const sessions = pruneExpired(store.sessions, now)
127
- const key = sessionKey(input.cosSessionId, input.model)
141
+ const profileId = slotProfile(input.profileId)
142
+ const key = sessionKey(input.cosSessionId, input.model, profileId)
128
143
  const previous = sessions[key]
129
144
  const session: CodexEngineSession = {
130
145
  key,
@@ -137,13 +152,18 @@ export function saveCodexEngineSession(input: {
137
152
  lastUsedAt: now,
138
153
  expiresAt: expiresAtFrom(now),
139
154
  engineFingerprint: input.engineFingerprint ?? '',
155
+ ...(profileId ? { profileId } : {}),
140
156
  }
141
157
  sessions[key] = session
142
158
  writeStore({ sessions, savedAt: new Date().toISOString() })
143
159
  return session
144
160
  }
145
161
 
146
- export function clearCodexEngineSession(cosSessionId: string, model?: CodexModelPreference): number {
162
+ /**
163
+ * With a model, clears that model's slot of one lane only: the subscription slot, or with
164
+ * `profileId` that profile's slot. Without a model (deleting the COS session), every slot.
165
+ */
166
+ export function clearCodexEngineSession(cosSessionId: string, model?: CodexModelPreference, profileId?: string): number {
147
167
  const store = readStore()
148
168
  const sessions = pruneExpired(store.sessions)
149
169
  let removed = 0
@@ -151,6 +171,7 @@ export function clearCodexEngineSession(cosSessionId: string, model?: CodexModel
151
171
  const session = sessions[key]
152
172
  if (session.cosSessionId !== cosSessionId) continue
153
173
  if (model && session.model !== model) continue
174
+ if (model && (session.profileId ?? undefined) !== slotProfile(profileId)) continue
154
175
  delete sessions[key]
155
176
  removed += 1
156
177
  }