@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,134 @@
1
+ // Key lanes K1 P4 (plan v4 8.2): every Codex home a reader must look in.
2
+ //
3
+ // A keyed (Lane B) Codex turn runs in its own home, `data/codex-homes/<profileId>/`, so its
4
+ // rollouts, writer locks and queue database never land in the user's `~/.codex`. Readers that
5
+ // FIND threads (the session list, the native head, the queue counts, occupancy, hook events)
6
+ // look in every home; readers of config, catalog or models stay on the server home.
7
+ //
8
+ // codexHomes(serverHome) the server home FIRST, then each profile home. With key lanes off
9
+ // or no profile home on disk it is exactly `[serverHome]`, so every
10
+ // reader built on it is identical to 963fe25.
11
+ //
12
+ // HARD RULE (zero impact without a key): nothing here creates a file or a directory. A profile
13
+ // home is listed only while key lanes are enabled AND `data/codex-homes/` exists. Importing this
14
+ // module reads nothing.
15
+ import { lstatSync, readdirSync } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import { join, resolve, sep } from 'node:path'
18
+ import { dataPath } from './data-dir.js'
19
+ import { keyLanesEnabled } from './key-lanes.js'
20
+ import { isKeyProfileId } from '../../shared/key-preference.js'
21
+
22
+ /** The directory under the data dir. P1's Remove all keys deletes it (`routes/key-lanes.ts`). */
23
+ export const CODEX_PROFILE_HOMES_DIR = 'codex-homes'
24
+ /** A bound on how many profile homes one read lists (the profile store caps at 20). */
25
+ export const MAX_CODEX_PROFILE_HOMES = 64
26
+
27
+ /** The server's own Codex home: `CODEX_HOME`, else `~/.codex` (as the hooks installer reads it). */
28
+ export function serverCodexHome(env: NodeJS.ProcessEnv = process.env): string {
29
+ const raw = env.CODEX_HOME?.trim()
30
+ return raw ? resolve(raw) : join(homedir(), '.codex')
31
+ }
32
+
33
+ export function codexProfileHomesRoot(): string {
34
+ return dataPath(CODEX_PROFILE_HOMES_DIR)
35
+ }
36
+
37
+ /** `data/codex-homes/<id>`. Throws on a malformed id (a caller bug, never user input). */
38
+ export function codexProfileHome(profileId: string): string {
39
+ if (!isKeyProfileId(profileId)) throw new Error('invalid key profile id')
40
+ return join(codexProfileHomesRoot(), profileId)
41
+ }
42
+
43
+ export interface CodexProfileHomeEntry {
44
+ profileId: string
45
+ home: string
46
+ }
47
+
48
+ /**
49
+ * Profile homes on disk, sorted by id. Empty unless key lanes are enabled and the root exists.
50
+ * A symlink, a file or a badly named entry is skipped, never followed.
51
+ */
52
+ export function profileCodexHomes(env: NodeJS.ProcessEnv = process.env): CodexProfileHomeEntry[] {
53
+ if (!keyLanesEnabled(env)) return []
54
+ const root = codexProfileHomesRoot()
55
+ let names: string[]
56
+ try {
57
+ if (!lstatSync(root).isDirectory()) return []
58
+ names = readdirSync(root)
59
+ } catch {
60
+ return []
61
+ }
62
+ const out: CodexProfileHomeEntry[] = []
63
+ for (const name of names.sort()) {
64
+ if (!isKeyProfileId(name)) continue
65
+ const home = join(root, name)
66
+ try { if (!lstatSync(home).isDirectory()) continue } catch { continue }
67
+ out.push({ profileId: name, home })
68
+ if (out.length >= MAX_CODEX_PROFILE_HOMES) break
69
+ }
70
+ return out
71
+ }
72
+
73
+ /**
74
+ * Every Codex home, the server home first. A reader passes its own server home (some honour
75
+ * `COS_AGENT_SESSIONS_HOME`, some `CODEX_HOME`) so the first entry is exactly what it read before.
76
+ */
77
+ export function codexHomes(serverHome: string = serverCodexHome(), env: NodeJS.ProcessEnv = process.env): string[] {
78
+ return [serverHome, ...profileCodexHomes(env).map(entry => entry.home)]
79
+ }
80
+
81
+ /** The profile homes only, joined with a subpath (`sessions`, `thread-writer-locks`). */
82
+ export function profileCodexHomeSubdirs(sub: string, env: NodeJS.ProcessEnv = process.env): string[] {
83
+ return profileCodexHomes(env).map(entry => join(entry.home, sub))
84
+ }
85
+
86
+ /**
87
+ * The profile id a path lives under (`data/codex-homes/<id>/...`), or undefined. Prefix-exact on
88
+ * the real root, so a user folder that happens to be called `codex-homes` never matches.
89
+ */
90
+ export function profileIdForCodexPath(path: unknown): string | undefined {
91
+ if (typeof path !== 'string' || !path) return undefined
92
+ const root = codexProfileHomesRoot() + sep
93
+ if (!path.startsWith(root)) return undefined
94
+ const id = path.slice(root.length).split(sep)[0]
95
+ return isKeyProfileId(id) ? id : undefined
96
+ }
97
+
98
+ const ROLLOUT_ID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i
99
+ const MAX_DIR_ENTRIES = 512
100
+ const MAX_FILES = 20_000
101
+
102
+ /**
103
+ * The profile whose home holds this Codex thread's rollout, or undefined. Walks
104
+ * `sessions/<yyyy>/<mm>/<dd>/rollout-*-<id>.jsonl` in each profile home, bounded like
105
+ * native-head's walk. With no profile home it returns at once without touching the disk.
106
+ */
107
+ export function profileForCodexThread(threadId: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
108
+ const homes = profileCodexHomes(env)
109
+ if (homes.length === 0) return undefined
110
+ const needle = typeof threadId === 'string' ? threadId.trim().toLowerCase() : ''
111
+ if (!/^[0-9a-f-]{36}$/.test(needle)) return undefined
112
+ const list = (dir: string): string[] => {
113
+ try {
114
+ const names = readdirSync(dir)
115
+ return names.length > MAX_DIR_ENTRIES ? [] : names
116
+ } catch { return [] }
117
+ }
118
+ for (const { profileId, home } of homes) {
119
+ let scanned = 0
120
+ const root = join(home, 'sessions')
121
+ for (const y of list(root)) for (const m of list(join(root, y))) for (const d of list(join(root, y, m))) {
122
+ for (const name of list(join(root, y, m, d))) {
123
+ if (++scanned > MAX_FILES) break
124
+ if (!name.startsWith('rollout-')) continue
125
+ if (ROLLOUT_ID_RE.exec(name)?.[1]?.toLowerCase() === needle) return profileId
126
+ }
127
+ }
128
+ }
129
+ return undefined
130
+ }
131
+
132
+ export function isProfileCodexThread(threadId: string, env: NodeJS.ProcessEnv = process.env): boolean {
133
+ return profileForCodexThread(threadId, env) !== undefined
134
+ }
@@ -54,6 +54,7 @@
54
54
  // sentence into the thread twice. A clap usage error (exit 2) queued nothing.
55
55
 
56
56
  import { isValidNativeThreadId } from './native-thread-id.js'
57
+ import { isProfileCodexThread } from './codex-homes.js'
57
58
 
58
59
  export type CodexLiveReason =
59
60
  /** The row is in the holder's queue. */
@@ -71,6 +72,11 @@ export type CodexLiveReason =
71
72
  | 'refused'
72
73
  /** Something may be queued. Hold; never retry, never spawn over it. */
73
74
  | 'unverified'
75
+ /**
76
+ * Key lanes K1 (plan v4 8.1): the thread lives in a keyed profile home, never the Codex app's
77
+ * queue. Nothing sent; the steer lands after the running turn (CODEX_KEYED_STEER_NOTICE).
78
+ */
79
+ | 'keyed_session'
74
80
 
75
81
  export interface CodexLiveResult {
76
82
  ok: boolean
@@ -100,8 +106,13 @@ export interface CodexLiveDeps {
100
106
  run: (binary: string, args: readonly string[], timeoutMs: number) => Promise<CodexQueueRun>
101
107
  now: () => number
102
108
  log?: (level: 'info' | 'warn', event: string, detail: Record<string, unknown>) => void
109
+ /** Key lanes: is this thread a keyed profile thread? Defaults to a look in the profile homes. */
110
+ isKeyedThread?: (threadId: string) => boolean
103
111
  }
104
112
 
113
+ /** Plan 8.1 lens line for a steer into a keyed Codex session. */
114
+ export const CODEX_KEYED_STEER_NOTICE = 'Steer lands after this turn.'
115
+
105
116
  export interface CodexLiveRequest {
106
117
  provider: string
107
118
  sessionId: string
@@ -182,6 +193,9 @@ export async function deliverOverCodexQueue(request: CodexLiveRequest, deps: Cod
182
193
  })
183
194
  if (!request.enabled) return done('disabled')
184
195
  if (request.provider !== 'codex' || request.foreignHolder !== true) return done('no_holder')
196
+ // Key lanes: a keyed profile thread is never the Codex app's, so it never joins its queue.
197
+ // With no profile home this is one predicate call that touches nothing.
198
+ if ((deps.isKeyedThread ?? isProfileCodexThread)(request.sessionId)) return done('keyed_session')
185
199
  let args: string[]
186
200
  try {
187
201
  args = buildCodexQueueArgs(request.sessionId, request.prompt)
@@ -0,0 +1,452 @@
1
+ // Key lanes K1 P4 (plan v4 8.1, D9): the generated Codex home a keyed (Lane B) Codex turn runs in.
2
+ //
3
+ // A keyed Codex turn never runs in the user's `~/.codex`. Canary C2d (2026-10-19): even with
4
+ // `-c` overrides on the real home, Codex rewrote `auth.json` (a ChatGPT token refresh) during a
5
+ // key run. So each profile gets `data/codex-homes/<id>/` (0700), regenerated before every turn:
6
+ //
7
+ // config.toml the loopback relay as the ONLY provider (`wire_api = "responses"`, C2a/C2b; the
8
+ // chat wire is refused at config load), retries 2, plus these tables copied
9
+ // read-only from the server home's config: `[mcp_servers.*]` (D9), `[projects.*]`
10
+ // (folder trust, so project hooks and config load as they do on Lane A),
11
+ // `[shell_environment_policy*]`, `[sandbox_workspace_write]`, and the hook trust
12
+ // entries that still describe the hooks written here.
13
+ // hooks.json the COS hook blocks, from the installer's own writer, for exactly the events the
14
+ // user's `~/.codex/hooks.json` carries ours for (mirror, never more).
15
+ // auth.json NEVER. One found there is removed before the turn: the key reaches Codex only as
16
+ // a turn nonce in `COS_RELAY_TOKEN`, and the real key only ever lives in the relay.
17
+ //
18
+ // The user's `~/.codex` is read, never written. HARD RULE: nothing here runs unless a caller has
19
+ // a key profile in hand; importing this module reads no file, opens no socket and starts no timer.
20
+ import { createHash } from 'node:crypto'
21
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs'
22
+ import { dirname, join, resolve as resolvePath } from 'node:path'
23
+ import { homedir } from 'node:os'
24
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
25
+ import { currentHookPaths, type HookPaths } from './claude-hooks-installer.js'
26
+ import { codexSubscribedEvents, mergeCodexHooks, stableCodexHookScriptPath } from './codex-hooks-installer.js'
27
+ import { codexProfileHome, codexProfileHomesRoot, serverCodexHome } from './codex-homes.js'
28
+ import { CODEX_KEYED_STEER_NOTICE } from './codex-live-queue.js'
29
+ import { keyLanesActive } from './key-lanes.js'
30
+ import { KEY_LANE_REFUSAL_COPY } from './key-notices.js'
31
+ import { isLaneBStrippedName, LANE_B_STRIPPED_NAMES } from './lane-b-env.js'
32
+ import { engineModelProblem } from './key-profiles.js'
33
+ import { KEY_LANE_CHILD_ENV, RELAY_BASE_PATHS } from './key-relay-contract.js'
34
+ import type { KeyKind, KeyProfile, KeyRelay } from './key-relay-contract.js'
35
+ import { getPublicKey } from './key-store.js'
36
+
37
+ /** The provider id the generated config defines and argv selects. */
38
+ export const CODEX_RELAY_PROVIDER_ID = 'cos-relay'
39
+ /** Plan 4.6: the child retries a 429 or 5xx twice; the relay never retries itself. R15 checks the names. */
40
+ export const CODEX_RELAY_RETRIES = 2
41
+ /**
42
+ * Canary R14 / R15: Codex multiplies the two (each stream reconnect runs its own request retries:
43
+ * 7 to 9 upstream calls per failure). Stream reconnects are off, so one failure costs at most
44
+ * 1 + CODEX_RELAY_RETRIES = 3 attempts, the plan 4.6 budget.
45
+ */
46
+ export const CODEX_RELAY_STREAM_RETRIES = 0
47
+ /** Top-level config tables copied from the server home (first key segment). */
48
+ export const CODEX_COPIED_TABLES: readonly string[] = ['mcp_servers', 'projects', 'shell_environment_policy', 'sandbox_workspace_write']
49
+
50
+ /** Call options a keyed Codex turn adds. Same field name and type P3 adds to CallOptions. */
51
+ export interface CodexKeyLaneCallOptions {
52
+ keyProfile?: KeyProfile
53
+ }
54
+
55
+ // ── Child environment (plan 7.2 base, 8.1 additions) ────────────────────────
56
+
57
+ /**
58
+ * Provider-credential names a Lane B child never inherits: P3's one list (lane-b-env.ts, deduped by
59
+ * P11), plus `CODEX_HOME`, which the profile home replaces. Every other name is kept.
60
+ */
61
+ export const CODEX_LANE_B_STRIPPED_NAMES: readonly string[] = [...LANE_B_STRIPPED_NAMES, 'CODEX_HOME']
62
+
63
+ export function isCodexLaneBStrippedName(name: string): boolean {
64
+ return name === 'CODEX_HOME' || isLaneBStrippedName(name)
65
+ }
66
+
67
+ /** The names applyCodexLaneBEnv sets. With the stripped names this is the whole A-vs-B difference. */
68
+ export const CODEX_LANE_B_ADDED_NAMES: readonly string[] = [
69
+ 'CODEX_HOME',
70
+ KEY_LANE_CHILD_ENV.codexToken,
71
+ KEY_LANE_CHILD_ENV.profileMarker,
72
+ ]
73
+
74
+ /** Point a child env copy (never process.env) at the profile home and the relay nonce. */
75
+ export function applyCodexLaneBEnv(env: NodeJS.ProcessEnv, spec: { home: string; nonce: string; profileId: string }): void {
76
+ if (env === process.env) throw new Error('codex-profile-home: refusing to write process.env')
77
+ if (!spec.home || !spec.nonce || !spec.profileId) throw new Error('codex-profile-home: incomplete spec')
78
+ for (const name of Object.keys(env)) {
79
+ if (isCodexLaneBStrippedName(name)) delete env[name]
80
+ }
81
+ env.CODEX_HOME = spec.home
82
+ env[KEY_LANE_CHILD_ENV.codexToken] = spec.nonce
83
+ env[KEY_LANE_CHILD_ENV.profileMarker] = spec.profileId
84
+ }
85
+
86
+ // ── Seams (P2 relay, P3 keyed-session record). P11 wires both. ───────────────
87
+
88
+ type RelayResolver = () => KeyRelay | Promise<KeyRelay>
89
+ let relayResolver: RelayResolver | null = null
90
+
91
+ export function registerCodexKeyRelay(resolver: RelayResolver | null): void {
92
+ relayResolver = resolver
93
+ }
94
+ export function codexKeyRelayRegistered(): boolean {
95
+ return relayResolver !== null
96
+ }
97
+ export async function codexKeyRelay(): Promise<KeyRelay> {
98
+ if (!relayResolver) throw new Error('codex-profile-home: no key relay registered')
99
+ return await relayResolver()
100
+ }
101
+
102
+ export interface KeyedCodexThread {
103
+ id: string
104
+ engine: 'codex'
105
+ profileId: string
106
+ cosSessionId: string
107
+ }
108
+ type KeyedThreadRecorder = (thread: KeyedCodexThread) => void
109
+ let keyedThreadRecorder: KeyedThreadRecorder | null = null
110
+
111
+ /** Plan 7.3: P11 points this at P3's `recordKeyedSession`. */
112
+ export function registerCodexKeyedThreadRecorder(recorder: KeyedThreadRecorder | null): void {
113
+ keyedThreadRecorder = recorder
114
+ }
115
+ export function recordKeyedCodexThread(thread: KeyedCodexThread): void {
116
+ try { keyedThreadRecorder?.(thread) } catch (err) {
117
+ console.error(`[codex-profile-home] keyed thread record failed: ${err instanceof Error ? err.message.slice(0, 120) : 'unknown'}`)
118
+ }
119
+ }
120
+
121
+ /** Tests only. */
122
+ export function __resetCodexProfileSeamsForTests(): void {
123
+ relayResolver = null
124
+ keyedThreadRecorder = null
125
+ }
126
+
127
+ // ── Lens copy (plan 11.3). P8's key-notices.ts is the single source once merged. ─────────────
128
+
129
+ export const CODEX_LANE_B_COPY = {
130
+ lanesOff: KEY_LANE_REFUSAL_COPY.lanesOff,
131
+ profileOff: KEY_LANE_REFUSAL_COPY.profileOff,
132
+ wrongEngine: KEY_LANE_REFUSAL_COPY.wrongEngineCodex,
133
+ keyMissing: KEY_LANE_REFUSAL_COPY.keyMissing,
134
+ modelNotAllowed: KEY_LANE_REFUSAL_COPY.codexModelNotAllowed,
135
+ scheduled: KEY_LANE_REFUSAL_COPY.scheduled,
136
+ relayUnavailable: KEY_LANE_REFUSAL_COPY.relayUnavailable,
137
+ /** Plan 8.1: a keyed Codex session never joins the Codex app queue. */
138
+ steerAfterTurn: CODEX_KEYED_STEER_NOTICE,
139
+ } as const
140
+
141
+ export type CodexProfilePreflight = { ok: true; keyKind: KeyKind } | { ok: false; message: string }
142
+
143
+ /** Project-config names that could point a keyed Codex turn at a provider other than the relay. */
144
+ const CODEX_PROJECT_PROVIDER_RE = /^\s*(?:\[\s*model_providers\b|(?:model_provider|model_providers|openai_base_url|chatgpt_base_url)\s*[=.])/m
145
+
146
+ /**
147
+ * QA W4: a trusted project's `.codex/config.toml` is merged over the profile home's config, so
148
+ * one that sets a provider (or redefines `cos-relay`) could send a keyed turn around the meter.
149
+ * Walks from the working directory up to the repository root (a `.git` entry) or the filesystem
150
+ * root, never into the user's home itself (that `.codex` is the global config the profile home
151
+ * replaces). Returns the file and the name it sets, or null. Read only on a keyed turn.
152
+ */
153
+ export function codexProjectConfigRefusal(cwd: string, home: string = homedir()): { file: string; name: string } | null {
154
+ let dir = resolvePath(cwd)
155
+ for (let i = 0; i < 32; i++) {
156
+ if (dir !== home) {
157
+ const file = join(dir, '.codex', 'config.toml')
158
+ try {
159
+ if (lstatSync(file).isFile()) {
160
+ const m = CODEX_PROJECT_PROVIDER_RE.exec(readFileSync(file, 'utf8'))
161
+ if (m) return { file, name: m[0].replace(/[\s\[=.]/g, '') || 'model_provider' }
162
+ }
163
+ } catch { /* none here */ }
164
+ try { if (lstatSync(join(dir, '.git'))) break } catch { /* not the repo root */ }
165
+ }
166
+ const up = dirname(dir)
167
+ if (up === dir) break
168
+ dir = up
169
+ }
170
+ return null
171
+ }
172
+
173
+ /** Everything a keyed Codex turn passes before history, the run ledger or a process. Fails closed. */
174
+ export function codexProfilePreflight(input: { profile: KeyProfile; dispatch: boolean; cwd?: string }): CodexProfilePreflight {
175
+ const { profile } = input
176
+ if (!keyLanesActive()) return { ok: false, message: CODEX_LANE_B_COPY.lanesOff }
177
+ if (profile.engine !== 'codex') return { ok: false, message: CODEX_LANE_B_COPY.wrongEngine(profile.label) }
178
+ if (!profile.enabled) return { ok: false, message: CODEX_LANE_B_COPY.profileOff(profile.label) }
179
+ if (input.dispatch) return { ok: false, message: CODEX_LANE_B_COPY.scheduled }
180
+ const key = getPublicKey(profile.keyRef)
181
+ if (!key) return { ok: false, message: CODEX_LANE_B_COPY.keyMissing(profile.label) }
182
+ if (engineModelProblem('codex', profile.model, key.kind)) return { ok: false, message: CODEX_LANE_B_COPY.modelNotAllowed(profile.label) }
183
+ const project = input.cwd ? codexProjectConfigRefusal(input.cwd) : null
184
+ if (project) return { ok: false, message: KEY_LANE_REFUSAL_COPY.codexProjectConfig(project.name) }
185
+ if (!codexKeyRelayRegistered()) return { ok: false, message: CODEX_LANE_B_COPY.relayUnavailable }
186
+ return { ok: true, keyKind: key.kind }
187
+ }
188
+
189
+ // ── TOML, just enough (the server has no TOML dependency) ───────────────────
190
+
191
+ const BARE_KEY_RE = /^[A-Za-z0-9_-]+$/
192
+
193
+ /** A dotted table key (`a."b c".d`) to its segments, or null when it is not a key. */
194
+ export function parseTomlKeyPath(inner: string): string[] | null {
195
+ const segs: string[] = []
196
+ let i = 0
197
+ const s = inner.trim()
198
+ const skipWs = () => { while (i < s.length && (s[i] === ' ' || s[i] === '\t')) i++ }
199
+ while (i < s.length) {
200
+ skipWs()
201
+ if (s[i] === '"') {
202
+ let out = ''
203
+ i++
204
+ for (;;) {
205
+ if (i >= s.length) return null
206
+ const ch = s[i]
207
+ if (ch === '"') { i++; break }
208
+ if (ch === '\\') {
209
+ const nx = s[i + 1]
210
+ const map: Record<string, string> = { '"': '"', '\\': '\\', n: '\n', t: '\t', r: '\r', b: '\b', f: '\f' }
211
+ if (nx && nx in map) { out += map[nx]; i += 2; continue }
212
+ if (nx === 'u' || nx === 'U') {
213
+ const len = nx === 'u' ? 4 : 8
214
+ const hex = s.slice(i + 2, i + 2 + len)
215
+ if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length !== len) return null
216
+ out += String.fromCodePoint(parseInt(hex, 16))
217
+ i += 2 + len
218
+ continue
219
+ }
220
+ return null
221
+ }
222
+ out += ch
223
+ i++
224
+ }
225
+ segs.push(out)
226
+ } else if (s[i] === "'") {
227
+ const end = s.indexOf("'", i + 1)
228
+ if (end < 0) return null
229
+ segs.push(s.slice(i + 1, end))
230
+ i = end + 1
231
+ } else {
232
+ const m = /^[A-Za-z0-9_-]+/.exec(s.slice(i))
233
+ if (!m) return null
234
+ segs.push(m[0])
235
+ i += m[0].length
236
+ }
237
+ skipWs()
238
+ if (i >= s.length) break
239
+ if (s[i] !== '.') return null
240
+ i++
241
+ }
242
+ return segs.length ? segs : null
243
+ }
244
+
245
+ export function encodeTomlKeySegment(seg: string): string {
246
+ return BARE_KEY_RE.test(seg) ? seg : tomlBasicString(seg)
247
+ }
248
+
249
+ /** A TOML basic string, always quoted. */
250
+ export function tomlBasicString(seg: string): string {
251
+ let out = '"'
252
+ for (const ch of seg) {
253
+ const cp = ch.codePointAt(0)!
254
+ if (ch === '"') out += '\\"'
255
+ else if (ch === '\\') out += '\\\\'
256
+ else if (cp < 0x20 || cp === 0x7f) out += `\\u${cp.toString(16).padStart(4, '0')}`
257
+ else out += ch
258
+ }
259
+ return out + '"'
260
+ }
261
+
262
+ export interface TomlSection {
263
+ /** Null for the leading top-level keys, before any header. */
264
+ path: string[] | null
265
+ arrayTable: boolean
266
+ header: string
267
+ body: string[]
268
+ }
269
+
270
+ const HEADER_RE = /^\s*(\[\[?)\s*(.+?)\s*(\]\]?)\s*(#.*)?$/
271
+
272
+ /** Split TOML text into sections at table headers. Multi-line strings never split a section. */
273
+ export function splitTomlSections(text: string): TomlSection[] {
274
+ const sections: TomlSection[] = [{ path: null, arrayTable: false, header: '', body: [] }]
275
+ let inMulti: '"""' | "'''" | null = null
276
+ for (const line of text.split(/\r?\n/)) {
277
+ if (inMulti) {
278
+ sections[sections.length - 1]!.body.push(line)
279
+ if ((line.split(inMulti).length - 1) % 2 === 1) inMulti = null
280
+ continue
281
+ }
282
+ const m = HEADER_RE.exec(line)
283
+ const open = m?.[1], close = m?.[3]
284
+ const path = m && ((open === '[' && close === ']') || (open === '[[' && close === ']]')) ? parseTomlKeyPath(m[2]!) : null
285
+ if (m && path) {
286
+ sections.push({ path, arrayTable: open === '[[', header: line, body: [] })
287
+ continue
288
+ }
289
+ sections[sections.length - 1]!.body.push(line)
290
+ for (const q of ['"""', "'''"] as const) {
291
+ if ((line.split(q).length - 1) % 2 === 1) { inMulti = q; break }
292
+ }
293
+ }
294
+ return sections
295
+ }
296
+
297
+ function headerFor(path: string[], arrayTable: boolean): string {
298
+ const name = path.map(encodeTomlKeySegment).join('.')
299
+ return arrayTable ? `[[${name}]]` : `[${name}]`
300
+ }
301
+
302
+ function camelToSnake(event: string): string {
303
+ return event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
304
+ }
305
+
306
+ const HOOK_TRUST_KEY_RE = /^(.*):([a-z_]+):(\d+):(\d+)$/
307
+
308
+ // ── The generated home ───────────────────────────────────────────────────────
309
+
310
+ export interface CodexProfileHomeSources {
311
+ /** The server's Codex home, read only. Default `CODEX_HOME` or `~/.codex`. */
312
+ serverHome?: string
313
+ scriptPath?: string
314
+ hookPaths?: HookPaths
315
+ }
316
+
317
+ export interface RenderedCodexProfileHome {
318
+ profileId: string
319
+ home: string
320
+ /** config.toml with the relay origin still a placeholder. */
321
+ configTemplate: string
322
+ hooksText: string
323
+ /** Plan 8.1 resume binding: `profile:<id>:<sha>` over the template and hooks (never the port). */
324
+ engineFingerprint: string
325
+ copiedTables: string[]
326
+ mirroredHookEvents: string[]
327
+ }
328
+
329
+ const ORIGIN_PLACEHOLDER = '__COS_RELAY_ORIGIN__'
330
+
331
+ function readTextOrNull(path: string): string | null {
332
+ try {
333
+ if (lstatSync(path).isSymbolicLink()) return null
334
+ return readFileSync(path, 'utf8')
335
+ } catch { return null }
336
+ }
337
+
338
+ /** Pure apart from READING the server home's config.toml and hooks.json. Writes nothing. */
339
+ export function renderCodexProfileHome(profile: KeyProfile, sources: CodexProfileHomeSources = {}): RenderedCodexProfileHome {
340
+ const serverHome = sources.serverHome ?? serverCodexHome()
341
+ const home = codexProfileHome(profile.id)
342
+ const scriptPath = sources.scriptPath ?? stableCodexHookScriptPath()
343
+ const hookPaths = sources.hookPaths ?? currentHookPaths()
344
+ const serverHooksPath = join(serverHome, 'hooks.json')
345
+ const profileHooksPath = join(home, 'hooks.json')
346
+
347
+ // hooks.json: our canonical blocks, for exactly the events the user's file has ours for.
348
+ let userHooks: unknown = {}
349
+ const userHooksText = readTextOrNull(serverHooksPath)
350
+ if (userHooksText !== null) { try { userHooks = JSON.parse(userHooksText) } catch { userHooks = {} } }
351
+ const subscribed = codexSubscribedEvents(userHooks, scriptPath, hookPaths).subscribed
352
+ const merged = mergeCodexHooks({}, scriptPath, hookPaths)
353
+ const allBlocks = merged.ok ? (merged.settings.hooks as Record<string, unknown>) : {}
354
+ const hooks: Record<string, unknown> = {}
355
+ for (const event of subscribed) if (allBlocks[event] !== undefined) hooks[event] = allBlocks[event]
356
+ const hooksText = JSON.stringify({ hooks }, null, 2) + '\n'
357
+
358
+ // Where our block sits in the user's file, per event (snake name), for the trust remap.
359
+ const userTable = userHooks && typeof userHooks === 'object' ? (userHooks as { hooks?: Record<string, unknown> }).hooks ?? {} : {}
360
+ const ourIndex = new Map<string, number>()
361
+ for (const event of subscribed) {
362
+ const blocks = Array.isArray(userTable[event]) ? userTable[event] as unknown[] : []
363
+ const canonical = JSON.stringify((allBlocks[event] as unknown[] | undefined)?.[0])
364
+ const at = blocks.findIndex(b => JSON.stringify(b) === canonical)
365
+ if (at >= 0) ourIndex.set(camelToSnake(event), at)
366
+ }
367
+
368
+ const copied: string[] = []
369
+ const out: string[] = []
370
+ const configText = readTextOrNull(join(serverHome, 'config.toml'))
371
+ if (configText !== null) {
372
+ for (const section of splitTomlSections(configText)) {
373
+ const path = section.path
374
+ if (!path) continue
375
+ if (CODEX_COPIED_TABLES.includes(path[0]!)) {
376
+ out.push(section.header, ...section.body)
377
+ copied.push(path.join('.'))
378
+ continue
379
+ }
380
+ // Hook trust: Codex keys each hook by `<file>:<event>:<block>:<hook>`. A project file's key
381
+ // names the same file on both lanes and is copied as it stands. A key on the user's own
382
+ // hooks.json is moved to this home's hooks.json, only where our block is the one trusted.
383
+ if (path[0] === 'hooks' && path[1] === 'state' && path.length === 3) {
384
+ const key = path[2]!
385
+ const parsed = HOOK_TRUST_KEY_RE.exec(key)
386
+ if (parsed && parsed[1] === serverHooksPath) {
387
+ const at = ourIndex.get(parsed[2]!)
388
+ if (at === undefined || Number(parsed[3]) !== at) continue
389
+ const remapped = `${profileHooksPath}:${parsed[2]}:0:${parsed[4]}`
390
+ out.push(headerFor(['hooks', 'state', remapped], false), ...section.body)
391
+ copied.push(`hooks.state.${parsed[2]}`)
392
+ continue
393
+ }
394
+ if (parsed && parsed[1]!.startsWith(home)) continue
395
+ out.push(section.header, ...section.body)
396
+ copied.push('hooks.state')
397
+ }
398
+ }
399
+ }
400
+
401
+ const head = [
402
+ `# Generated by COS for key profile ${profile.id} before every keyed turn. COS rewrites it; do not edit.`,
403
+ `model = ${tomlBasicString(profile.model)}`,
404
+ `model_provider = "${CODEX_RELAY_PROVIDER_ID}"`,
405
+ '',
406
+ `[model_providers.${CODEX_RELAY_PROVIDER_ID}]`,
407
+ 'name = "COS key relay"',
408
+ `base_url = "${ORIGIN_PLACEHOLDER}${RELAY_BASE_PATHS.codex}"`,
409
+ `env_key = "${KEY_LANE_CHILD_ENV.codexToken}"`,
410
+ 'wire_api = "responses"',
411
+ `request_max_retries = ${CODEX_RELAY_RETRIES}`,
412
+ `stream_max_retries = ${CODEX_RELAY_STREAM_RETRIES}`,
413
+ '',
414
+ ]
415
+ const tail = out.length ? [`# Copied read-only from ${join(serverHome, 'config.toml')} (plan D9).`, ...out] : []
416
+ const configTemplate = [...head, ...tail].join('\n').replace(/\n*$/, '\n')
417
+ const sha = createHash('sha256').update(configTemplate).update('\0').update(hooksText).digest('hex').slice(0, 16)
418
+ return {
419
+ profileId: profile.id,
420
+ home,
421
+ configTemplate,
422
+ hooksText,
423
+ engineFingerprint: `profile:${profile.id}:${sha}`,
424
+ copiedTables: [...new Set(copied)],
425
+ mirroredHookEvents: Object.keys(hooks),
426
+ }
427
+ }
428
+
429
+ const LOOPBACK_ORIGIN_RE = /^http:\/\/127\.0\.0\.1:\d{1,5}$/
430
+
431
+ /**
432
+ * Write the rendered home with the relay origin filled in: dirs 0700, files 0600, atomic. Refuses
433
+ * a symlinked root or home. Any `auth.json` in the home is removed: a keyed home never holds one.
434
+ */
435
+ export function writeCodexProfileHome(rendered: RenderedCodexProfileHome, relayOrigin: string): { configPath: string; hooksPath: string } {
436
+ if (!LOOPBACK_ORIGIN_RE.test(relayOrigin)) throw new Error('codex-profile-home: the relay origin is not loopback')
437
+ const root = codexProfileHomesRoot()
438
+ for (const dir of [root, rendered.home]) {
439
+ let st: ReturnType<typeof lstatSync> | null = null
440
+ try { st = lstatSync(dir) } catch { /* created below */ }
441
+ if (st && (st.isSymbolicLink() || !st.isDirectory())) throw new Error('codex-profile-home: refusing a symlinked or non-directory home')
442
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
443
+ chmodSync(dir, 0o700)
444
+ }
445
+ const auth = join(rendered.home, 'auth.json')
446
+ try { lstatSync(auth); unlinkSync(auth) } catch { /* none, as it should be */ }
447
+ const configPath = join(rendered.home, 'config.toml')
448
+ const hooksPath = join(rendered.home, 'hooks.json')
449
+ durableAtomicWriteFileSync(configPath, rendered.configTemplate.split(ORIGIN_PLACEHOLDER).join(relayOrigin), { mode: 0o600 })
450
+ durableAtomicWriteFileSync(hooksPath, rendered.hooksText, { mode: 0o600 })
451
+ return { configPath, hooksPath }
452
+ }
@@ -6,6 +6,7 @@ import { existsSync } from 'node:fs'
6
6
  import { homedir } from 'node:os'
7
7
  import { join } from 'node:path'
8
8
  import { resolveProviderBinary } from './provider-binary.js'
9
+ import { codexHomes } from './codex-homes.js'
9
10
 
10
11
  export interface NativeQueuedSubmission { id: string; input: Array<{ type: string; text?: string; text_elements?: unknown[] }>; clientUserMessageId: string }
11
12
  export interface NativeQueueControl {
@@ -89,7 +90,22 @@ export const nativeQueueControl: NativeQueueControl = {
89
90
  // One read-only aggregate per session-list request, not 80 protocol processes.
90
91
  // No queue file is ever edited via SQL; mutations use Codex's own protocol above.
91
92
  export async function nativeQueuedCounts(): Promise<Map<string, number> | null> {
92
- const db = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'queue_1.sqlite')
93
+ // Key lanes K1 (plan v4 8.2): the server home first, then each keyed profile home. With no
94
+ // profile home this is exactly the one read it always was.
95
+ const homes = codexHomes(process.env.CODEX_HOME || join(homedir(), '.codex'))
96
+ if (homes.length === 1) return queuedCountsIn(homes[0]!)
97
+ const total = new Map<string, number>()
98
+ for (const home of homes) {
99
+ const counts = await queuedCountsIn(home)
100
+ // An unreadable keyed home never degrades the server home's answer.
101
+ if (counts === null) { if (home === homes[0]) return null; continue }
102
+ for (const [thread, n] of counts) total.set(thread, (total.get(thread) ?? 0) + n)
103
+ }
104
+ return total
105
+ }
106
+
107
+ async function queuedCountsIn(home: string): Promise<Map<string, number> | null> {
108
+ const db = join(home, 'queue_1.sqlite')
93
109
  if (!existsSync(db)) return new Map()
94
110
  try {
95
111
  const { stdout } = await promisify(execFile)('/usr/bin/sqlite3', ['-readonly', '-json', db, 'SELECT thread_id, count(*) AS n FROM queued_items GROUP BY thread_id'], { timeout: 2000, maxBuffer: 1024 * 1024 })