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