@gotcos/glasses-server 6.79.1 → 6.80.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.
- package/.env.example +9 -0
- package/CHANGELOG.md +98 -0
- package/README.md +20 -1
- package/managed-runtime-contract.json +6 -1
- package/package.json +9 -1
- package/server/index.ts +11 -0
- package/server/lib/agent-session-search.ts +8 -5
- package/server/lib/agent-session-store.ts +45 -4
- package/server/lib/archive.ts +2 -1
- package/server/lib/attached-provider-adapter.ts +11 -0
- package/server/lib/claude-bridge.ts +112 -8
- package/server/lib/claude-settings-guard.ts +94 -0
- package/server/lib/claude-steer.ts +27 -0
- package/server/lib/codex-bridge.ts +205 -32
- package/server/lib/codex-engine-sessions.ts +27 -6
- package/server/lib/codex-homes.ts +134 -0
- package/server/lib/codex-live-queue.ts +14 -0
- package/server/lib/codex-profile-home.ts +452 -0
- package/server/lib/codex-queue-control.ts +17 -1
- package/server/lib/codex-subagent-activity.ts +22 -0
- package/server/lib/conversation.ts +92 -15
- package/server/lib/dictation-clean.ts +13 -6
- package/server/lib/dictation-fillers.ts +17 -10
- package/server/lib/dictation-polish-need.ts +48 -0
- package/server/lib/dictation-prewarm.ts +209 -0
- package/server/lib/direct-bridge.ts +853 -0
- package/server/lib/direct-sse.ts +262 -0
- package/server/lib/fork-thread.ts +11 -0
- package/server/lib/key-answer-fields.ts +49 -0
- package/server/lib/key-catalog.ts +301 -0
- package/server/lib/key-lanes-pricing.ts +12 -0
- package/server/lib/key-lanes-wiring.ts +346 -0
- package/server/lib/key-lanes.ts +145 -0
- package/server/lib/key-ledger.ts +123 -0
- package/server/lib/key-notices.ts +486 -0
- package/server/lib/key-profile-rejects.ts +25 -0
- package/server/lib/key-profile-routing.ts +173 -0
- package/server/lib/key-profiles.ts +182 -0
- package/server/lib/key-relay-contract.ts +262 -0
- package/server/lib/key-relay-errors.ts +211 -0
- package/server/lib/key-relay-meter.ts +303 -0
- package/server/lib/key-relay.ts +1018 -0
- package/server/lib/key-secrets-guard.ts +52 -0
- package/server/lib/key-store.ts +228 -0
- package/server/lib/keyed-sessions.ts +154 -0
- package/server/lib/lane-b-env.ts +169 -0
- package/server/lib/live-cues-v2-settings.ts +51 -13
- package/server/lib/live-cues-v2.ts +147 -19
- package/server/lib/model-router.ts +96 -7
- package/server/lib/morning-brief-config.ts +3 -0
- package/server/lib/native-head.ts +13 -1
- package/server/lib/occupancy-probes.ts +4 -0
- package/server/lib/ollama-run-ledger.ts +96 -13
- package/server/lib/prompt-edit.ts +2 -0
- package/server/lib/query-job-coordinator.ts +2 -1
- package/server/lib/query-job-runtime.ts +67 -15
- package/server/lib/query-job-store.ts +7 -1
- package/server/lib/query-job-types.ts +15 -1
- package/server/lib/session-hook-events.ts +28 -0
- package/server/lib/session-hook-ledger.ts +10 -0
- package/server/lib/thread-occupancy.ts +22 -1
- package/server/lib/token-audit.ts +18 -4
- package/server/lib/work-handoff-requests.ts +3 -0
- package/server/routes/agent-session-bindings.ts +15 -0
- package/server/routes/agent-sessions.ts +17 -5
- package/server/routes/handoffs.ts +22 -1
- package/server/routes/health.ts +5 -0
- package/server/routes/key-lanes.ts +442 -0
- package/server/routes/live-cues.ts +39 -3
- package/server/routes/openai-compat.ts +28 -3
- package/server/routes/prompt-drafts.ts +75 -8
- package/server/routes/query.ts +20 -6
- package/shared/key-preference.ts +57 -0
- package/shared/model-preference.ts +20 -2
|
@@ -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
|
-
|
|
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 })
|
|
@@ -81,6 +81,28 @@ async function childState(file: string, root: string, now: number, budget: { byt
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/** One bounded read-only SQLite snapshot per list/detail read. */
|
|
84
|
+
/**
|
|
85
|
+
* Key lanes (P11): readCodexSubagents over every Codex home. Each parent is read in the home that
|
|
86
|
+
* holds its thread (`rootOf`, a keyed thread's profile home), every other one in the server home.
|
|
87
|
+
* With one root (key lanes off, or no profile home) it is exactly the one server-home call.
|
|
88
|
+
*/
|
|
89
|
+
export async function readCodexSubagentsAcross(
|
|
90
|
+
parents: readonly string[], sessionsRoots: readonly string[], now?: number, rootOf?: (id: string) => string | undefined,
|
|
91
|
+
): Promise<Map<string, SubagentActivity>> {
|
|
92
|
+
const server = sessionsRoots[0]!
|
|
93
|
+
const read = (ids: readonly string[], root: string) => now === undefined ? readCodexSubagents(ids, root) : readCodexSubagents(ids, root, now)
|
|
94
|
+
if (sessionsRoots.length <= 1 || !rootOf) return read(parents, server)
|
|
95
|
+
const groups = new Map<string, string[]>()
|
|
96
|
+
for (const id of parents) {
|
|
97
|
+
const own = rootOf(id)
|
|
98
|
+
const root = own && sessionsRoots.includes(own) ? own : server
|
|
99
|
+
groups.set(root, [...(groups.get(root) ?? []), id])
|
|
100
|
+
}
|
|
101
|
+
const out = new Map<string, SubagentActivity>()
|
|
102
|
+
for (const [root, ids] of groups) for (const [id, activity] of await read(ids, root)) out.set(id, activity)
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
84
106
|
export async function readCodexSubagents(
|
|
85
107
|
parents: readonly string[], sessionsRoot: string, now = Date.now(),
|
|
86
108
|
): Promise<Map<string, SubagentActivity>> {
|