@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,52 @@
1
+ // Key lanes K1, QA B1 (main session decision, 2026-10-19): while key lanes are ENABLED and a key
2
+ // is saved, every Claude spawn that can use tools gets deny rules for the key secrets directory,
3
+ // as one `--settings` layer (deny rules from every layer apply; a flag layer cannot be turned off
4
+ // by a user or project settings file).
5
+ //
6
+ // Read, Edit, Write the directory and everything under it, by its path and its real path
7
+ // Bash any command line naming the directory or the key file
8
+ //
9
+ // This narrows, it does not wall off: a process running on this Mac as the same user can still
10
+ // read a file the server can read (a Bash line that builds the path, another app, Codex, whose
11
+ // sandbox has no read exclusion). That residual risk is stated plainly in key-notices.ts
12
+ // (KEY_LANE_SAME_MAC_COPY) and the CHANGELOG; the provider's own spending limit is the real stop.
13
+ //
14
+ // HARD RULE: with key lanes off this returns [] before touching the disk, so every spawn's argv is
15
+ // byte-identical to 6.79.1. Enabled with no key saved, there is nothing to protect: [] too.
16
+ import { existsSync, realpathSync } from 'node:fs'
17
+ import { keyLanesEnabled } from './key-lanes.js'
18
+ import { KEY_SECRETS_DIR, KEY_STORE_FILE, keySecretsDir, keyStorePath } from './key-store.js'
19
+
20
+ /** The deny rules for the secrets directory (exported for the tests and the real-CLI canary). */
21
+ export function keySecretsDenyRules(dir: string = keySecretsDir()): string[] {
22
+ const dirs = new Set([dir])
23
+ try { dirs.add(realpathSync(dir)) } catch { /* not created yet: the plain path still applies */ }
24
+ const rules: string[] = []
25
+ for (const d of dirs) {
26
+ // `//abs/path` is Claude Code's absolute-path form for Read, Edit and Write rules.
27
+ for (const tool of ['Read', 'Edit', 'Write']) rules.push(`${tool}(/${d}/**)`)
28
+ }
29
+ rules.push(`Bash(*${KEY_SECRETS_DIR}*)`, `Bash(*${KEY_STORE_FILE}*)`)
30
+ return rules
31
+ }
32
+
33
+ /**
34
+ * Canary R12: settings `env` names a keyed (Lane B) run forces, whatever a user or project settings
35
+ * file says. A flag `--settings` layer outranks both (MU's project file turns agent teams on).
36
+ */
37
+ export const LANE_B_FORCED_SETTINGS_ENV: Readonly<Record<string, string>> = {
38
+ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '0',
39
+ }
40
+
41
+ /**
42
+ * `['--settings', <JSON>]` while lanes are enabled and a key is saved, else []. A keyed run
43
+ * (`laneB`) also gets LANE_B_FORCED_SETTINGS_ENV in the same layer (one `--settings` per spawn).
44
+ */
45
+ export function claudeKeySecretsGuardArgs(opts: { laneB?: boolean } = {}): string[] {
46
+ if (!keyLanesEnabled()) return []
47
+ if (!existsSync(keyStorePath())) return []
48
+ return ['--settings', JSON.stringify({
49
+ permissions: { deny: keySecretsDenyRules() },
50
+ ...(opts.laneB ? { env: { ...LANE_B_FORCED_SETTINGS_ENV } } : {}),
51
+ })]
52
+ }
@@ -0,0 +1,228 @@
1
+ // Key lanes (K1, plan v4 sections 4, 10.3, 12): the saved provider keys, `data/key-secrets/provider-keys.json`
2
+ // (QA B1: a dedicated 0700 directory, so the deny rules every Claude spawn gets name one place).
3
+ //
4
+ // The only file on the Mac that holds a real key. 0600, written atomically into the 0700 data
5
+ // dir, never followed through a symlink. Nothing here logs a secret, and the only function that
6
+ // returns one is readKeySecretForRelay (P2's relay and P5's in-process direct lane). Routes,
7
+ // health, diagnostics and ledgers get PublicKeyRecord: ref, kind, last 4, cap, times.
8
+ //
9
+ // Reading never creates: a missing file is an empty store, a corrupt one reads as empty and is
10
+ // left exactly where it is.
11
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmdirSync, statSync, unlinkSync } from 'node:fs'
12
+ import { dirname, join } from 'node:path'
13
+ import { randomBytes } from 'node:crypto'
14
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
15
+ import { DATA_DIR, dataPath } from './data-dir.js'
16
+ import type { KeyKind, KeyRef, PublicKeyRecord } from './key-relay-contract.js'
17
+ import { KEY_KINDS, KEY_REF_RE } from './key-relay-contract.js'
18
+
19
+ export const KEY_STORE_FILE = 'provider-keys.json'
20
+ /** QA B1: the secrets live alone in this 0700 directory under the data dir. */
21
+ export const KEY_SECRETS_DIR = 'key-secrets'
22
+ export const DEFAULT_KEY_CAP_USD = 1.0
23
+ export const KEY_CAP_LIMITS = { minUsd: 0.01, maxUsd: 1000 } as const
24
+ const MAX_SECRET_CHARS = 512
25
+
26
+ interface StoredKey extends PublicKeyRecord {
27
+ secret: string
28
+ }
29
+ interface KeyStoreFile {
30
+ v: 1
31
+ keys: StoredKey[]
32
+ }
33
+
34
+ export function keySecretsDir(): string {
35
+ return dataPath(KEY_SECRETS_DIR)
36
+ }
37
+
38
+ export function keyStorePath(): string {
39
+ return join(keySecretsDir(), KEY_STORE_FILE)
40
+ }
41
+
42
+ /** Parse a private JSON state file without creating, repairing or moving anything. */
43
+ export function readPrivateJsonFile(file: string): unknown {
44
+ if (!existsSync(file)) return undefined
45
+ const st = lstatSync(file)
46
+ if (st.isSymbolicLink() || !st.isFile()) return undefined
47
+ try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return undefined }
48
+ }
49
+
50
+ /** Atomic 0600 write into its 0700 directory (the data dir, or the key secrets dir). */
51
+ export function writePrivateJsonFile(file: string, value: unknown): void {
52
+ mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 })
53
+ const dir = dirname(file)
54
+ if (dir !== DATA_DIR) {
55
+ if (existsSync(dir) && lstatSync(dir).isSymbolicLink()) throw new Error('refusing to write through a symlinked directory')
56
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
57
+ chmodSync(dir, 0o700)
58
+ }
59
+ if (existsSync(file) && lstatSync(file).isSymbolicLink()) throw new Error('refusing to write through a symlink')
60
+ durableAtomicWriteFileSync(file, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 })
61
+ }
62
+
63
+ function isStoredKey(v: unknown): v is StoredKey {
64
+ if (!v || typeof v !== 'object') return false
65
+ const k = v as Partial<StoredKey>
66
+ return typeof k.ref === 'string' && KEY_REF_RE.test(k.ref)
67
+ && typeof k.kind === 'string' && (KEY_KINDS as readonly string[]).includes(k.kind)
68
+ && typeof k.secret === 'string' && k.secret.length > 0
69
+ && typeof k.capUsd === 'number' && Number.isFinite(k.capUsd)
70
+ && typeof k.savedAt === 'string'
71
+ }
72
+
73
+ let cache: { stamp: string; keys: StoredKey[] } | null = null
74
+
75
+ function readStore(): StoredKey[] {
76
+ const file = keyStorePath()
77
+ let stamp: string
78
+ try {
79
+ const st = statSync(file)
80
+ stamp = `${st.ino}:${st.size}:${st.mtimeMs}`
81
+ } catch {
82
+ cache = null
83
+ return []
84
+ }
85
+ if (cache?.stamp === stamp) return cache.keys
86
+ const parsed = readPrivateJsonFile(file) as Partial<KeyStoreFile> | undefined
87
+ const keys = parsed && Array.isArray(parsed.keys) ? parsed.keys.filter(isStoredKey) : []
88
+ cache = { stamp, keys }
89
+ return keys
90
+ }
91
+
92
+ function writeStore(keys: StoredKey[]): void {
93
+ const body: KeyStoreFile = { v: 1, keys }
94
+ writePrivateJsonFile(keyStorePath(), body)
95
+ cache = null
96
+ }
97
+
98
+ function toPublic(k: StoredKey): PublicKeyRecord {
99
+ return {
100
+ ref: k.ref,
101
+ kind: k.kind,
102
+ last4: k.last4,
103
+ capUsd: k.capUsd,
104
+ savedAt: k.savedAt,
105
+ ...(k.validatedAt ? { validatedAt: k.validatedAt } : {}),
106
+ ...(k.kind === 'openai-compatible' && k.baseUrl ? { baseUrl: k.baseUrl } : {}),
107
+ }
108
+ }
109
+
110
+ export function listPublicKeys(): PublicKeyRecord[] {
111
+ return readStore().map(toPublic)
112
+ }
113
+
114
+ export function getPublicKey(ref: string): PublicKeyRecord | undefined {
115
+ const k = readStore().find(x => x.ref === ref)
116
+ return k ? toPublic(k) : undefined
117
+ }
118
+
119
+ /** P2 relay and P5 direct lane ONLY. No route, health field, log or diagnostic may call this. */
120
+ export function readKeySecretForRelay(ref: KeyRef): string | null {
121
+ return readStore().find(x => x.ref === ref)?.secret ?? null
122
+ }
123
+
124
+ // ── Validation (shape only; the provider check is in routes/key-lanes.ts) ──
125
+
126
+ export function isKeyKind(v: unknown): v is KeyKind {
127
+ return typeof v === 'string' && (KEY_KINDS as readonly string[]).includes(v)
128
+ }
129
+
130
+ /** A plain-language refusal, or null when the secret has the right shape for its kind. */
131
+ export function keyShapeProblem(kind: KeyKind, secret: unknown): string | null {
132
+ if (typeof secret !== 'string' || !secret.trim()) return 'Paste the key.'
133
+ const s = secret.trim()
134
+ if (s.length > MAX_SECRET_CHARS || /\s/.test(s) || !/^[\x21-\x7e]+$/.test(s)) return 'That does not look like a key. Paste it again.'
135
+ if (kind === 'openrouter' && !s.startsWith('sk-or-')) return 'That is not an OpenRouter key. It starts with sk-or-.'
136
+ if (kind === 'anthropic' && !s.startsWith('sk-ant-')) return 'That is not an Anthropic key. It starts with sk-ant-.'
137
+ if (kind === 'openai' && !s.startsWith('sk-')) return 'That is not an OpenAI key. It starts with sk-.'
138
+ if (s.length < 20) return 'That key is too short. Paste the whole key.'
139
+ return null
140
+ }
141
+
142
+ /** A cap in USD rounded to cents, or null when out of range. Absent means the default. */
143
+ export function normalizeCapUsd(v: unknown): number | null {
144
+ if (v === undefined) return DEFAULT_KEY_CAP_USD
145
+ if (typeof v !== 'number' || !Number.isFinite(v)) return null
146
+ const cents = Math.round(v * 100) / 100
147
+ return cents >= KEY_CAP_LIMITS.minUsd && cents <= KEY_CAP_LIMITS.maxUsd ? cents : null
148
+ }
149
+
150
+ /** A custom OpenAI-compatible base URL (Mac only), normalized without a trailing slash, or null. */
151
+ /** 127.0.0.0/8, ::1 and localhost. */
152
+ export function isLoopbackHost(hostname: string): boolean {
153
+ const h = hostname.replace(/^\[|\]$/g, '').toLowerCase()
154
+ return h === 'localhost' || h === '::1' || /^127(?:\.\d{1,3}){3}$/.test(h)
155
+ }
156
+
157
+ export function normalizeCompatibleBaseUrl(v: unknown): string | null {
158
+ if (typeof v !== 'string' || v.length > 300) return null
159
+ try {
160
+ const u = new URL(v.trim())
161
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') return null
162
+ // QA N5: plain http only to this Mac (a local model server); anything remote is https.
163
+ if (u.protocol === 'http:' && !isLoopbackHost(u.hostname)) return null
164
+ if (u.username || u.password || u.search || u.hash) return null
165
+ return u.toString().replace(/\/+$/, '')
166
+ } catch {
167
+ return null
168
+ }
169
+ }
170
+
171
+ export interface SaveKeyInput {
172
+ kind: KeyKind
173
+ secret: string
174
+ capUsd: number
175
+ validatedAt?: string
176
+ baseUrl?: string
177
+ }
178
+
179
+ /** Save a validated key. The same secret saved twice keeps its ref (cap and times refresh). */
180
+ export function saveKey(input: SaveKeyInput, now: () => Date = () => new Date()): PublicKeyRecord {
181
+ const secret = input.secret.trim()
182
+ const keys = [...readStore()]
183
+ const at = now().toISOString()
184
+ const existing = keys.findIndex(k => k.kind === input.kind && k.secret === secret)
185
+ const record: StoredKey = {
186
+ ref: existing >= 0 ? keys[existing].ref : `key_${randomBytes(6).toString('hex')}`,
187
+ kind: input.kind,
188
+ secret,
189
+ last4: secret.slice(-4),
190
+ capUsd: input.capUsd,
191
+ savedAt: existing >= 0 ? keys[existing].savedAt : at,
192
+ ...(input.validatedAt ? { validatedAt: input.validatedAt } : {}),
193
+ ...(input.kind === 'openai-compatible' && input.baseUrl ? { baseUrl: input.baseUrl } : {}),
194
+ }
195
+ if (existing >= 0) keys[existing] = record
196
+ else keys.push(record)
197
+ writeStore(keys)
198
+ return toPublic(record)
199
+ }
200
+
201
+ /** Change one key's daily cap (Control's last saved value is the one that counts, plan 4.5). */
202
+ export function updateKeyCap(ref: string, capUsd: number): PublicKeyRecord | undefined {
203
+ const keys = [...readStore()]
204
+ const i = keys.findIndex(k => k.ref === ref)
205
+ if (i < 0) return undefined
206
+ keys[i] = { ...keys[i], capUsd }
207
+ writeStore(keys)
208
+ return toPublic(keys[i])
209
+ }
210
+
211
+ /** Remove one key (plan 17 item 3): the store is rewritten atomically without it. True when it existed. */
212
+ export function deleteKey(ref: string): boolean {
213
+ const keys = readStore()
214
+ const next = keys.filter(k => k.ref !== ref)
215
+ if (next.length === keys.length) return false
216
+ writeStore(next)
217
+ return true
218
+ }
219
+
220
+ /** Remove all keys (plan 12). True when a file was deleted. */
221
+ export function deleteKeyStore(): boolean {
222
+ cache = null
223
+ const file = keyStorePath()
224
+ if (!existsSync(file)) return false
225
+ unlinkSync(file)
226
+ try { rmdirSync(keySecretsDir()) } catch { /* not empty or already gone */ }
227
+ return true
228
+ }
@@ -0,0 +1,154 @@
1
+ // Key lanes K1 P3 (plan v4 7.3, W11): the CLI sessions that ran on a key, `data/keyed-sessions.json`.
2
+ //
3
+ // A Lane B Claude turn writes its transcript into the shared ~/.claude/projects, where COS
4
+ // Control's Continue and Fork, a handoff pickup, and an attach would otherwise resume it on the
5
+ // subscription. Every Claude CLI session id (and, from P4, Codex thread id) created under a
6
+ // profile is recorded here with its profile id, and those entry points refuse it with 409
7
+ // `keyed_session`.
8
+ //
9
+ // The file is written only by a keyed turn, so with key lanes never used it never exists and
10
+ // every check below is one failed stat. Reading never creates. Remove all keys (plan 12) keeps
11
+ // this file on purpose: a keyed transcript stays refused after the keys are gone. A manual
12
+ // `claude --resume` in a terminal is documented, not blocked.
13
+ import { statSync } from 'node:fs'
14
+ import { dataPath } from './data-dir.js'
15
+ import { readPrivateJsonFile, writePrivateJsonFile } from './key-store.js'
16
+ import { registerKeyLanesStatusProvider } from './key-lanes.js'
17
+ import type { RelayEngine } from './key-relay-contract.js'
18
+
19
+ export const KEYED_SESSIONS_FILE = 'keyed-sessions.json'
20
+ /** Far past any real use; the oldest entries go first past it. */
21
+ export const MAX_KEYED_SESSIONS = 20_000
22
+ const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
23
+
24
+ /** The 409 body every refusing entry point returns (plan 7.3, 11.3). */
25
+ export const KEYED_SESSION_REFUSAL = {
26
+ error: 'keyed_session',
27
+ message: 'This session ran on your key. Continue it from COS.',
28
+ } as const
29
+
30
+ export interface KeyedSessionRecord {
31
+ /** The Claude CLI session id or Codex thread id. */
32
+ id: string
33
+ engine: RelayEngine
34
+ profileId: string
35
+ /** The COS conversation the turn belonged to, when known. */
36
+ cosSessionId?: string
37
+ recordedAt: string
38
+ }
39
+
40
+ interface KeyedSessionsFile {
41
+ v: 1
42
+ sessions: KeyedSessionRecord[]
43
+ }
44
+
45
+ export function keyedSessionsPath(): string {
46
+ return dataPath(KEYED_SESSIONS_FILE)
47
+ }
48
+
49
+ function isRecord(v: unknown): v is KeyedSessionRecord {
50
+ if (!v || typeof v !== 'object') return false
51
+ const r = v as Partial<KeyedSessionRecord>
52
+ return typeof r.id === 'string' && SESSION_ID_RE.test(r.id)
53
+ && (r.engine === 'claude' || r.engine === 'codex')
54
+ && typeof r.profileId === 'string'
55
+ && typeof r.recordedAt === 'string'
56
+ }
57
+
58
+ // Ids recorded this boot. A failed write still refuses for the rest of the boot.
59
+ const memory = new Map<string, KeyedSessionRecord>()
60
+ let cache: { stamp: string; records: KeyedSessionRecord[] } | null = null
61
+
62
+ function fileRecords(): KeyedSessionRecord[] {
63
+ const file = keyedSessionsPath()
64
+ let stamp: string
65
+ try {
66
+ const st = statSync(file)
67
+ stamp = `${st.ino}:${st.size}:${st.mtimeMs}`
68
+ } catch {
69
+ cache = null
70
+ return []
71
+ }
72
+ if (cache?.stamp === stamp) return cache.records
73
+ const parsed = readPrivateJsonFile(file) as Partial<KeyedSessionsFile> | undefined
74
+ const records = parsed && Array.isArray(parsed.sessions) ? parsed.sessions.filter(isRecord) : []
75
+ cache = { stamp, records }
76
+ return records
77
+ }
78
+
79
+ export function listKeyedSessions(): KeyedSessionRecord[] {
80
+ const byId = new Map<string, KeyedSessionRecord>()
81
+ for (const r of fileRecords()) byId.set(r.id, r)
82
+ for (const r of memory.values()) if (!byId.has(r.id)) byId.set(r.id, r)
83
+ return [...byId.values()]
84
+ }
85
+
86
+ /** True once anything was ever recorded. The cheap short-circuit every refusing site starts with. */
87
+ export function anyKeyedSessions(): boolean {
88
+ return memory.size > 0 || fileRecords().length > 0
89
+ }
90
+
91
+ export function isKeyedSession(id: unknown): boolean {
92
+ if (typeof id !== 'string' || !id) return false
93
+ if (memory.has(id)) return true
94
+ return fileRecords().some(r => r.id === id)
95
+ }
96
+
97
+ /** True when any keyed CLI session belongs to this COS conversation. */
98
+ export function isKeyedCosSession(cosSessionId: unknown): boolean {
99
+ if (typeof cosSessionId !== 'string' || !cosSessionId) return false
100
+ return listKeyedSessions().some(r => r.cosSessionId === cosSessionId)
101
+ }
102
+
103
+ /** Canary R7: the key profile a recorded CLI session ran under, by session id (any case), or undefined. */
104
+ export function keyedSessionProfile(id: unknown): string | undefined {
105
+ if (typeof id !== 'string' || !id) return undefined
106
+ const needle = id.toLowerCase()
107
+ for (const r of memory.values()) if (r.id.toLowerCase() === needle) return r.profileId
108
+ return fileRecords().find(r => r.id.toLowerCase() === needle)?.profileId
109
+ }
110
+
111
+ export function keyedSessionIds(): string[] {
112
+ return listKeyedSessions().map(r => r.id)
113
+ }
114
+
115
+ /**
116
+ * Record a session created under a profile. Idempotent per id. 0600, atomic. Called by the
117
+ * bridge the moment the CLI names its session, before any answer text.
118
+ */
119
+ export function recordKeyedSession(input: {
120
+ id: string
121
+ engine: RelayEngine
122
+ profileId: string
123
+ cosSessionId?: string
124
+ }, now: () => Date = () => new Date()): void {
125
+ if (!SESSION_ID_RE.test(input.id)) return
126
+ const record: KeyedSessionRecord = {
127
+ id: input.id,
128
+ engine: input.engine,
129
+ profileId: input.profileId,
130
+ ...(input.cosSessionId ? { cosSessionId: input.cosSessionId } : {}),
131
+ recordedAt: now().toISOString(),
132
+ }
133
+ if (!memory.has(input.id)) memory.set(input.id, record)
134
+ const records = fileRecords()
135
+ if (records.some(r => r.id === input.id)) return
136
+ const next = [...records, record].slice(-MAX_KEYED_SESSIONS)
137
+ try {
138
+ const body: KeyedSessionsFile = { v: 1, sessions: next }
139
+ writePrivateJsonFile(keyedSessionsPath(), body)
140
+ cache = null
141
+ } catch (err) {
142
+ // The id stays refused in memory for this boot; the next keyed turn retries the write.
143
+ console.error(`[keyed-sessions] could not persist a keyed session: ${err instanceof Error ? err.message : err}`)
144
+ }
145
+ }
146
+
147
+ /** Tests only. */
148
+ export function __resetKeyedSessionsForTests(): void {
149
+ memory.clear()
150
+ cache = null
151
+ }
152
+
153
+ // P1's status route reads this while lanes are enabled. In-memory registration only.
154
+ registerKeyLanesStatusProvider({ keyedSessionIds })
@@ -0,0 +1,169 @@
1
+ // Key lanes K1 P3 (plan v4 sections 4.4, 7.1, 7.2): the Lane B Claude child environment and the
2
+ // checks a keyed Claude turn passes before anything is spawned.
3
+ //
4
+ // Lane B runs the user's Claude Code CLI with the full harness, but pointed at the loopback relay
5
+ // (P2) instead of the subscription. The child never sees a real key: it gets a turn nonce that
6
+ // works only on 127.0.0.1, only during the turn, only under the cap.
7
+ //
8
+ // HARD RULE (zero impact without a key): nothing here runs unless a caller passes a key profile.
9
+ // Importing this module reads no file, opens no socket and starts no timer.
10
+ import { keyLanesActive } from './key-lanes.js'
11
+ import { KEY_LANE_REFUSAL_COPY } from './key-notices.js'
12
+ import { getPublicKey } from './key-store.js'
13
+ import { claudeSettingsRefusal, claudeSettingsWatched } from './claude-settings-guard.js'
14
+ import { KEY_LANE_CHILD_ENV, RELAY_BASE_PATHS } from './key-relay-contract.js'
15
+ import type { KeyKind, KeyProfile, KeyRelay } from './key-relay-contract.js'
16
+
17
+ /**
18
+ * Provider-credential names a Lane B child never inherits (plan 7.2). Every other name (PATH,
19
+ * HOME, COS_API_TOKEN, MCP and hook settings) is kept, unlike dictationChildEnv, which strips
20
+ * every *API_KEY and rewrites PATH.
21
+ */
22
+ export const LANE_B_STRIPPED_NAMES: readonly string[] = [
23
+ 'ANTHROPIC_API_KEY',
24
+ 'ANTHROPIC_AUTH_TOKEN',
25
+ 'ANTHROPIC_BASE_URL',
26
+ 'ANTHROPIC_MODEL',
27
+ 'ANTHROPIC_SMALL_FAST_MODEL',
28
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
29
+ 'CLAUDE_CODE_USE_BEDROCK',
30
+ 'CLAUDE_CODE_USE_VERTEX',
31
+ 'CLAUDE_CODE_USE_FOUNDRY',
32
+ 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',
33
+ 'OPENAI_API_KEY',
34
+ 'OPENAI_BASE_URL',
35
+ 'OPENROUTER_API_KEY',
36
+ // QA W4: other ways a child authenticates to someone else.
37
+ 'CLAUDE_CODE_OAUTH_TOKEN',
38
+ 'ANTHROPIC_CUSTOM_HEADERS',
39
+ 'AWS_BEARER_TOKEN_BEDROCK',
40
+ ]
41
+ /** Plus every `ANTHROPIC_DEFAULT_<TIER>_MODEL`, whatever the tier is called. */
42
+ export const LANE_B_STRIPPED_PATTERN = /^ANTHROPIC_DEFAULT_[A-Z0-9_]+_MODEL$/
43
+
44
+ export function isLaneBStrippedName(name: string): boolean {
45
+ return LANE_B_STRIPPED_NAMES.includes(name) || LANE_B_STRIPPED_PATTERN.test(name)
46
+ }
47
+
48
+ /** Plan 4.4: every model name the CLI may read is the profile slug. ANTHROPIC_MODEL is never set: the slug goes on `--model`. */
49
+ export const LANE_B_CLAUDE_MODEL_NAMES: readonly string[] = [
50
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
51
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
52
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
53
+ 'ANTHROPIC_SMALL_FAST_MODEL',
54
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
55
+ ]
56
+ /** Plan 4.6: the child retries a 429 or 5xx twice; the relay never retries itself. */
57
+ export const LANE_B_CLAUDE_MAX_RETRIES = '2'
58
+
59
+ /** The names applyLaneBClaudeEnv sets. With LANE_B_STRIPPED_NAMES this is the whole A-vs-B difference. */
60
+ export const LANE_B_CLAUDE_ADDED_NAMES: readonly string[] = [
61
+ KEY_LANE_CHILD_ENV.claudeBaseUrl,
62
+ KEY_LANE_CHILD_ENV.claudeToken,
63
+ 'ANTHROPIC_API_KEY',
64
+ 'CLAUDE_CODE_MAX_RETRIES',
65
+ ...LANE_B_CLAUDE_MODEL_NAMES,
66
+ KEY_LANE_CHILD_ENV.profileMarker,
67
+ ]
68
+
69
+ const LOOPBACK_ORIGIN_RE = /^http:\/\/127\.0\.0\.1:\d{1,5}$/
70
+
71
+ /** A copy of `base` minus the provider-credential names. `base` is never written. */
72
+ export function laneBBaseEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
73
+ const env = { ...base }
74
+ for (const name of Object.keys(env)) {
75
+ if (isLaneBStrippedName(name)) delete env[name]
76
+ }
77
+ return env
78
+ }
79
+
80
+ export interface LaneBClaudeEnvSpec {
81
+ /** The relay's `http://127.0.0.1:<port>` for this boot. */
82
+ origin: string
83
+ nonce: string
84
+ /** The profile's model slug. */
85
+ slug: string
86
+ profileId: string
87
+ }
88
+
89
+ /**
90
+ * Point a child env object (a copy, never process.env) at the relay. Strips the credential
91
+ * names, then sets the relay base URL, the nonce as the Bearer, an empty API key, the retry
92
+ * count, every model name to the slug and the profile marker. Agent teams stay off (stripped,
93
+ * never re-added). Throws on a non-loopback origin: the relay only ever listens on 127.0.0.1.
94
+ */
95
+ export function applyLaneBClaudeEnv(env: NodeJS.ProcessEnv, spec: LaneBClaudeEnvSpec): void {
96
+ if (env === process.env) throw new Error('lane-b-env: refusing to write process.env')
97
+ if (!LOOPBACK_ORIGIN_RE.test(spec.origin)) throw new Error('lane-b-env: the relay origin is not loopback')
98
+ if (!spec.nonce || !spec.slug || !spec.profileId) throw new Error('lane-b-env: incomplete spec')
99
+ for (const name of Object.keys(env)) {
100
+ if (isLaneBStrippedName(name)) delete env[name]
101
+ }
102
+ env[KEY_LANE_CHILD_ENV.claudeBaseUrl] = spec.origin + RELAY_BASE_PATHS.claude
103
+ env[KEY_LANE_CHILD_ENV.claudeToken] = spec.nonce
104
+ env.ANTHROPIC_API_KEY = ''
105
+ env.CLAUDE_CODE_MAX_RETRIES = LANE_B_CLAUDE_MAX_RETRIES
106
+ for (const name of LANE_B_CLAUDE_MODEL_NAMES) env[name] = spec.slug
107
+ env[KEY_LANE_CHILD_ENV.profileMarker] = spec.profileId
108
+ }
109
+
110
+ // ── The relay seam ──────────────────────────────────────────────────────────
111
+ // P2 owns the relay; this package consumes only the KeyRelay contract. P11 wires the real one
112
+ // in index.ts (`registerLaneBRelay(() => getKeyRelay())` or similar). Unregistered, every keyed
113
+ // turn is refused before spawn: there is no fallback to the subscription.
114
+
115
+ type RelayResolver = () => KeyRelay | Promise<KeyRelay>
116
+ let relayResolver: RelayResolver | null = null
117
+
118
+ export function registerLaneBRelay(resolver: RelayResolver | null): void {
119
+ relayResolver = resolver
120
+ }
121
+
122
+ export function laneBRelayRegistered(): boolean {
123
+ return relayResolver !== null
124
+ }
125
+
126
+ export async function laneBRelay(): Promise<KeyRelay> {
127
+ if (!relayResolver) throw new Error('lane-b-env: no key relay registered')
128
+ return await relayResolver()
129
+ }
130
+
131
+ // ── Lens copy ───────────────────────────────────────────────────────────────
132
+ // P8's key-notices.ts is the single source of key-lane strings (P11 moved these lines there).
133
+ export const LANE_B_COPY = {
134
+ lanesOff: KEY_LANE_REFUSAL_COPY.lanesOff,
135
+ profileOff: KEY_LANE_REFUSAL_COPY.profileOff,
136
+ wrongEngine: KEY_LANE_REFUSAL_COPY.wrongEngineClaude,
137
+ keyMissing: KEY_LANE_REFUSAL_COPY.keyMissing,
138
+ scheduled: KEY_LANE_REFUSAL_COPY.scheduled,
139
+ relayUnavailable: KEY_LANE_REFUSAL_COPY.relayUnavailable,
140
+ } as const
141
+
142
+ export type LaneBClaudePreflight = { ok: true; keyKind: KeyKind } | { ok: false; message: string }
143
+
144
+ /**
145
+ * Everything a keyed Claude turn must pass before it touches history, the run ledger or a
146
+ * process (plan 7.1, 7.2). Fails closed: any refusal ends the turn with its lens line and the
147
+ * subscription is never used instead.
148
+ */
149
+ export function laneBClaudePreflight(input: {
150
+ profile: KeyProfile
151
+ dispatch: boolean
152
+ cwd: string
153
+ home?: string
154
+ }): LaneBClaudePreflight {
155
+ const { profile } = input
156
+ if (!keyLanesActive()) return { ok: false, message: LANE_B_COPY.lanesOff }
157
+ if (profile.engine !== 'claude') return { ok: false, message: LANE_B_COPY.wrongEngine(profile.label) }
158
+ if (!profile.enabled) return { ok: false, message: LANE_B_COPY.profileOff(profile.label) }
159
+ if (input.dispatch) return { ok: false, message: LANE_B_COPY.scheduled }
160
+ const key = getPublicKey(profile.keyRef)
161
+ if (!key) return { ok: false, message: LANE_B_COPY.keyMissing(profile.label) }
162
+ const settings = claudeSettingsRefusal({ cwd: input.cwd, home: input.home })
163
+ if (settings) return { ok: false, message: settings.message }
164
+ for (const w of claudeSettingsWatched({ cwd: input.cwd, home: input.home })) {
165
+ console.info(`[key-lanes] ${w.name} is set by a Claude settings file; this keyed run forces it off`)
166
+ }
167
+ if (!laneBRelayRegistered()) return { ok: false, message: LANE_B_COPY.relayUnavailable }
168
+ return { ok: true, keyKind: key.kind }
169
+ }