@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
|
@@ -64,6 +64,14 @@ import { claudeSupportsSessionName } from './claude-cli-support.js'
|
|
|
64
64
|
import { applyTurnRequestEnv, createTurnRequestBinding, spawnWithTurnRequest } from './turn-request.js'
|
|
65
65
|
import { STEER_REAPER_MS, STEER_SETTLE_MS, SteerTracker, composeFirstContent, encodeUserLine, steerClock, type SteerPort } from './claude-steer.js'
|
|
66
66
|
import { randomUUID } from 'node:crypto'
|
|
67
|
+
// Key lanes K1 P3 (plan v4 7): Lane B Claude. Every use below sits behind `options.keyProfile`;
|
|
68
|
+
// without one, argv, env, session keys and the pre-warm handoff are the b7c620e ones.
|
|
69
|
+
import { profilePreference } from '../../shared/key-preference.js'
|
|
70
|
+
import type { AnswerKeyFields, KeyKind, KeyProfile, RelayTurnHandle } from './key-relay-contract.js'
|
|
71
|
+
import { applyLaneBClaudeEnv, LANE_B_COPY, laneBClaudePreflight, laneBRelay } from './lane-b-env.js'
|
|
72
|
+
import { recordKeyedSession } from './keyed-sessions.js'
|
|
73
|
+
import { getSharedKeyMeter } from './key-relay-meter.js'
|
|
74
|
+
import { claudeKeySecretsGuardArgs } from './key-secrets-guard.js'
|
|
67
75
|
|
|
68
76
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
69
77
|
|
|
@@ -117,9 +125,19 @@ function cliSessionKey(cosSessionId: string, model: ClaudeModelPreference): stri
|
|
|
117
125
|
return `${cosSessionId}:${model}`
|
|
118
126
|
}
|
|
119
127
|
|
|
128
|
+
/** Key lanes (plan v4 7.1): a keyed turn's CLI session is its own key, never `${sid}:${tier}`. */
|
|
129
|
+
export function keyedCliSessionKey(cosSessionId: string, tier: ClaudeModelPreference, profileId: string): string {
|
|
130
|
+
return `${cosSessionId}:${tier}:p:${profileId}`
|
|
131
|
+
}
|
|
132
|
+
|
|
120
133
|
function getAnyCliSessionId(cosSessionId: string): string | undefined {
|
|
121
134
|
for (const [key, cliId] of cliSessionMap) {
|
|
122
|
-
if (key === cosSessionId || key.startsWith(`${cosSessionId}:`))
|
|
135
|
+
if (key === cosSessionId || key.startsWith(`${cosSessionId}:`)) {
|
|
136
|
+
// Key lanes: a keyed CLI session is never handed out here (handoff, health, Continue). Only
|
|
137
|
+
// the keyed turn itself reaches it, through its own exact key.
|
|
138
|
+
if (key.includes(':p:', cosSessionId.length)) continue
|
|
139
|
+
return cliId
|
|
140
|
+
}
|
|
123
141
|
}
|
|
124
142
|
return undefined
|
|
125
143
|
}
|
|
@@ -235,6 +253,7 @@ export async function preWarmCLI(): Promise<void> {
|
|
|
235
253
|
'--output-format', 'stream-json',
|
|
236
254
|
'--verbose',
|
|
237
255
|
...claudePermissionArgs(getClaudeTrustMode(), null),
|
|
256
|
+
...claudeKeySecretsGuardArgs(),
|
|
238
257
|
'--system-prompt', buildPrewarmSystemPrompt(),
|
|
239
258
|
], {
|
|
240
259
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -324,6 +343,12 @@ export interface ModelRunMetadata {
|
|
|
324
343
|
ollamaRunId?: string
|
|
325
344
|
outputAttachments?: MediaAttachmentRef[]
|
|
326
345
|
outputImageStats?: RunOutputImageCollectionStats
|
|
346
|
+
/** Key lanes (plan v4 3.1): present only on a keyed turn. The `model` beside it is then the timeout tier. */
|
|
347
|
+
keyLane?: AnswerKeyFields
|
|
348
|
+
/** Key lanes (P11): what this keyed answer cost on the key, settled by the relay. Keyed turns only. */
|
|
349
|
+
usd?: number
|
|
350
|
+
/** Key lanes (P5): the direct lane's run ledger id. Direct turns only. */
|
|
351
|
+
directRunId?: string
|
|
327
352
|
}
|
|
328
353
|
|
|
329
354
|
/** Public-safe provider launch metadata for durable job coordination. It
|
|
@@ -417,6 +442,17 @@ export interface CallOptions {
|
|
|
417
442
|
* 6.77.0 one. A dispatch never steers.
|
|
418
443
|
*/
|
|
419
444
|
steer?: SteerPort
|
|
445
|
+
/**
|
|
446
|
+
* Key lanes K1 (plan v4 7): run this turn on a saved key profile (Lane B Claude) through the
|
|
447
|
+
* loopback relay. The profile's timeoutTier sets the timeouts and its model goes on `--model`;
|
|
448
|
+
* the `model` argument is ignored. Absent, every argument, env name, session key and stdin
|
|
449
|
+
* byte is the b7c620e one. Refused (onError, nothing spawned) when lanes are not active, the
|
|
450
|
+
* profile is off or not a Claude profile, its key is gone, a Claude settings file would bypass
|
|
451
|
+
* the relay, no relay is registered, or this is a dispatch.
|
|
452
|
+
*/
|
|
453
|
+
keyProfile?: KeyProfile
|
|
454
|
+
/** Key lanes (QA U9): run this one profile turn without binding the session to the profile. */
|
|
455
|
+
keyTurnOnly?: boolean
|
|
420
456
|
}
|
|
421
457
|
|
|
422
458
|
export async function callClaudeStreaming(
|
|
@@ -431,6 +467,25 @@ export async function callClaudeStreaming(
|
|
|
431
467
|
): Promise<string> {
|
|
432
468
|
// Get or create session
|
|
433
469
|
const sid = getOrCreateSession(sessionId)
|
|
470
|
+
// Key lanes: every check a keyed turn needs runs here, before history, the run ledger or a
|
|
471
|
+
// process. A refusal ends the turn with its lens line; the subscription is never the fallback.
|
|
472
|
+
const keyProfile = options?.keyProfile
|
|
473
|
+
let keyKind: KeyKind | null = null
|
|
474
|
+
if (keyProfile) {
|
|
475
|
+
const preflight = laneBClaudePreflight({
|
|
476
|
+
profile: keyProfile,
|
|
477
|
+
dispatch: !!options?.dispatch,
|
|
478
|
+
cwd: resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR }),
|
|
479
|
+
})
|
|
480
|
+
if (!preflight.ok) {
|
|
481
|
+
await callbacks.onError(preflight.message)
|
|
482
|
+
return sid
|
|
483
|
+
}
|
|
484
|
+
keyKind = preflight.keyKind
|
|
485
|
+
}
|
|
486
|
+
const keyLane: AnswerKeyFields | null = keyProfile && keyKind
|
|
487
|
+
? { lane: 'key', credential: { kind: keyKind, ref: keyProfile.keyRef, profileId: keyProfile.id } }
|
|
488
|
+
: null
|
|
434
489
|
const history = getHistory(sid)
|
|
435
490
|
const session = getSessionRaw(sid)
|
|
436
491
|
const contextBreaks = session?.contextBreaks ?? []
|
|
@@ -439,14 +494,16 @@ export async function callClaudeStreaming(
|
|
|
439
494
|
|
|
440
495
|
// Resolve model: per-message > session preference > public-server default.
|
|
441
496
|
const sessionModel = getSessionModel(sid)
|
|
442
|
-
const resolvedModel: ClaudeModelPreference = model
|
|
443
|
-
?? (sessionModel && isClaudeModel(sessionModel) ? sessionModel : DEFAULT_MODEL)
|
|
497
|
+
const resolvedModel: ClaudeModelPreference = keyProfile ? keyProfile.timeoutTier : (model
|
|
498
|
+
?? (sessionModel && isClaudeModel(sessionModel) ? sessionModel : DEFAULT_MODEL))
|
|
444
499
|
const resolvedEffort = options?.effort
|
|
445
500
|
const cliEffortFlag = resolvedEffort ? resolveCliEffortFlag(resolvedEffort) : getClaudeEffortLevel()
|
|
446
501
|
|
|
447
502
|
// Notify client immediately — model is known before any async work
|
|
448
503
|
// Pass existing CLI session ID if resuming (new sessions get it after first result)
|
|
449
|
-
const resolvedCliKey = cliSessionKey(sid, resolvedModel)
|
|
504
|
+
const resolvedCliKey = keyProfile ? keyedCliSessionKey(sid, resolvedModel, keyProfile.id) : cliSessionKey(sid, resolvedModel)
|
|
505
|
+
// A keyed exchange is stamped with its profile (P6 persists it by syntax), never with the tier.
|
|
506
|
+
const exchangeModel = keyProfile ? profilePreference(keyProfile.id) as unknown as ModelPreference : resolvedModel
|
|
450
507
|
let existingCliSession = options?.dispatch ? undefined : cliSessionMap.get(resolvedCliKey)
|
|
451
508
|
// 6.58.2: decided before the pre-warmed session below stands in: a COS session that has no
|
|
452
509
|
// Claude session of its own yet starts one here, and only that one may be named.
|
|
@@ -454,6 +511,7 @@ export async function callClaudeStreaming(
|
|
|
454
511
|
callbacks.onStart?.(resolvedModel, sid, existingCliSession, {
|
|
455
512
|
clientJobId: options?.clientJobId,
|
|
456
513
|
generation: options?.generation,
|
|
514
|
+
...(keyLane ? { keyLane } : {}),
|
|
457
515
|
})
|
|
458
516
|
|
|
459
517
|
// Phase: context loading (skipped in lightweight mode)
|
|
@@ -531,7 +589,7 @@ export async function callClaudeStreaming(
|
|
|
531
589
|
globalMsgNum,
|
|
532
590
|
inboundAttachments,
|
|
533
591
|
exchangeProvenance,
|
|
534
|
-
|
|
592
|
+
exchangeModel,
|
|
535
593
|
)
|
|
536
594
|
|
|
537
595
|
// Vision queries need the Read tool to see the image files
|
|
@@ -559,7 +617,8 @@ export async function callClaudeStreaming(
|
|
|
559
617
|
// 6.58.2: nor does a named New session (COS Control Work). The warmed session opens with the
|
|
560
618
|
// boot prompt "ready" under the glasses prompt; a session someone will open in the Claude app
|
|
561
619
|
// under its task's name starts clean.
|
|
562
|
-
|
|
620
|
+
// Key lanes: a keyed turn never takes the warmed subscription session.
|
|
621
|
+
if (!options?.dispatch && !options?.sessionName && !existingCliSession && preWarmedCliSessionId && resolvedModel === DEFAULT_MODEL && !keyProfile) {
|
|
563
622
|
// Only the default model consumes the pre-warmed session.
|
|
564
623
|
// Hey Even (Haiku) cold-starts its own session to avoid model contamination.
|
|
565
624
|
existingCliSession = preWarmedCliSessionId
|
|
@@ -582,6 +641,8 @@ export async function callClaudeStreaming(
|
|
|
582
641
|
'--system-prompt', systemPrompt,
|
|
583
642
|
...(options?.dispatch ? [] : mcpConfigArgs),
|
|
584
643
|
]
|
|
644
|
+
// Key lanes (plan 7.1): a keyed turn names the profile slug on --model (never ANTHROPIC_MODEL, no [1m]).
|
|
645
|
+
if (keyProfile) args[2] = keyProfile.model
|
|
585
646
|
|
|
586
647
|
// Full and lightweight paths share the same explicit MCP selector contract.
|
|
587
648
|
if (options?.dispatch) {
|
|
@@ -599,6 +660,9 @@ export async function callClaudeStreaming(
|
|
|
599
660
|
} else {
|
|
600
661
|
args.push(...claudePermissionArgs(getClaudeTrustMode(), tools), '--include-partial-messages')
|
|
601
662
|
}
|
|
663
|
+
// QA B1: deny the key secrets dir while lanes are on and a key is saved ([] otherwise). A keyed
|
|
664
|
+
// turn's layer also forces agent teams off over any settings file (canary R12).
|
|
665
|
+
args.push(...claudeKeySecretsGuardArgs(keyProfile ? { laneB: true } : {}))
|
|
602
666
|
|
|
603
667
|
if (existingCliSession) {
|
|
604
668
|
// Resume prior CLI session — reuses cached context, avoids cold start
|
|
@@ -625,6 +689,33 @@ export async function callClaudeStreaming(
|
|
|
625
689
|
? Math.max(defaultWallMax, WALL_MAX_DEEP_EFFORT_MS)
|
|
626
690
|
: defaultWallMax
|
|
627
691
|
const wallMax = isExtendedQuery(query) ? Math.max(WALL_MAX_EXTENDED_MS, effortWallMax) : effortWallMax
|
|
692
|
+
// Key lanes: one nonce per turn from the relay (P2 contract), then the child env points at it.
|
|
693
|
+
// Only the child's copy is written. A relay that cannot issue ends the turn before spawn.
|
|
694
|
+
let keyTurn: RelayTurnHandle | null = null
|
|
695
|
+
const keyTurnId = keyProfile ? options?.turnId ?? randomUUID() : null
|
|
696
|
+
if (keyProfile) {
|
|
697
|
+
try {
|
|
698
|
+
const relay = await laneBRelay()
|
|
699
|
+
keyTurn = await relay.issueTurn({
|
|
700
|
+
keyRef: keyProfile.keyRef,
|
|
701
|
+
profileId: keyProfile.id,
|
|
702
|
+
turnId: keyTurnId!,
|
|
703
|
+
sessionId: sid,
|
|
704
|
+
engine: 'claude',
|
|
705
|
+
allowedModel: keyProfile.model,
|
|
706
|
+
}, { wallMaxMs: wallMax })
|
|
707
|
+
applyLaneBClaudeEnv(env, { origin: keyTurn.origin, nonce: keyTurn.nonce, slug: keyProfile.model, profileId: keyProfile.id })
|
|
708
|
+
} catch (err) {
|
|
709
|
+
keyTurn?.revoke('cancel')
|
|
710
|
+
outputImagePublisher?.cleanup()
|
|
711
|
+
cleanupModelImageInputs(imageInputs)
|
|
712
|
+
removeExchange(sid, pendingUserExchange)
|
|
713
|
+
console.error(`[claude-bridge] key relay refused the turn: ${err instanceof Error ? err.message.slice(0, 200) : 'unknown'}`)
|
|
714
|
+
await callbacks.onError(LANE_B_COPY.relayUnavailable)
|
|
715
|
+
return sid
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const keyAuditFields = keyProfile && keyKind ? { profile: keyProfile.id, lane: 'key' as const, credentialKind: keyKind } : {}
|
|
628
719
|
const startTime = Date.now()
|
|
629
720
|
// 6.78.0 steer (P7 S7): the wall clock's start on the monotonic steer clock, next to the
|
|
630
721
|
// wall timer it describes, so the coordinator's cut-off reads the same clock after a sleep.
|
|
@@ -639,7 +730,7 @@ export async function callClaudeStreaming(
|
|
|
639
730
|
wallMaxMs: wallMax,
|
|
640
731
|
query: fullQuery,
|
|
641
732
|
effortLevel: resolvedEffort ?? getClaudeEffortLevel(),
|
|
642
|
-
cliModelId: resolveClaudeCliModelId(resolvedModel),
|
|
733
|
+
cliModelId: keyProfile ? keyProfile.model : resolveClaudeCliModelId(resolvedModel),
|
|
643
734
|
})
|
|
644
735
|
|
|
645
736
|
// Ultracode is a CLI-only orchestration keyword; history/chat keeps the
|
|
@@ -699,6 +790,7 @@ export async function callClaudeStreaming(
|
|
|
699
790
|
* CLI session mapping is dropped so the next Ask starts a fresh session.
|
|
700
791
|
*/
|
|
701
792
|
function abandonUncoveredSteers() {
|
|
793
|
+
keyTurn?.revoke('steer_abort')
|
|
702
794
|
steerStdinEnded = true
|
|
703
795
|
steerPort!.close()
|
|
704
796
|
if (cliSessionMap.delete(resolvedCliKey)) scheduleCliSessionSave()
|
|
@@ -791,7 +883,7 @@ export async function callClaudeStreaming(
|
|
|
791
883
|
globalMsgNum,
|
|
792
884
|
undefined,
|
|
793
885
|
exchangeProvenance,
|
|
794
|
-
|
|
886
|
+
exchangeModel,
|
|
795
887
|
)
|
|
796
888
|
if (imagePaths.length > 0) {
|
|
797
889
|
replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
|
|
@@ -827,6 +919,8 @@ export async function callClaudeStreaming(
|
|
|
827
919
|
// Token audit — log every completed claude -p call
|
|
828
920
|
const totalMs = Date.now() - startTime
|
|
829
921
|
const inputEstimate = systemPrompt.length + fullQuery.length + contextPrompt.length
|
|
922
|
+
// Key lanes (P11): this turn's settled spend on the key. Keyed turns only.
|
|
923
|
+
const keyUsdFields = keyTurnId ? { usd: Math.round(getSharedKeyMeter().turnSettledUsd(keyTurnId) * 1e6) / 1e6 } : {}
|
|
830
924
|
logTokenAudit({
|
|
831
925
|
source: options?.lightweight ? 'g2-voice' : 'g2-query',
|
|
832
926
|
model: resolvedModel,
|
|
@@ -834,6 +928,8 @@ export async function callClaudeStreaming(
|
|
|
834
928
|
outputChars: text.length,
|
|
835
929
|
durationMs: totalMs,
|
|
836
930
|
caller: options?.lightweight ? 'voice_query' : 'full_query',
|
|
931
|
+
...keyAuditFields,
|
|
932
|
+
...keyUsdFields,
|
|
837
933
|
})
|
|
838
934
|
|
|
839
935
|
finishClaudeRun(run.runId, {
|
|
@@ -849,6 +945,7 @@ export async function callClaudeStreaming(
|
|
|
849
945
|
generation: options?.generation,
|
|
850
946
|
...(outputAttachments.length > 0 ? { outputAttachments } : {}),
|
|
851
947
|
...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
|
|
948
|
+
...(keyLane ? { keyLane, ...keyUsdFields } : {}),
|
|
852
949
|
})
|
|
853
950
|
if (terminalOwned === false) return
|
|
854
951
|
|
|
@@ -901,6 +998,7 @@ export async function callClaudeStreaming(
|
|
|
901
998
|
}
|
|
902
999
|
|
|
903
1000
|
function handleAbort() {
|
|
1001
|
+
keyTurn?.revoke('cancel')
|
|
904
1002
|
void terminateForTerminal('client disconnect', result => finalizeError(
|
|
905
1003
|
'claude-bridge: client disconnected before Claude completed.',
|
|
906
1004
|
result.code,
|
|
@@ -1022,6 +1120,9 @@ export async function callClaudeStreaming(
|
|
|
1022
1120
|
// recorded as before, so the finished job names the session the result names.
|
|
1023
1121
|
if (!nativeSessionAnnounced && !options?.dispatch && typeof event.session_id === 'string' && event.session_id) {
|
|
1024
1122
|
nativeSessionAnnounced = true
|
|
1123
|
+
// Key lanes (plan 7.3): recorded before any answer text, so Continue, Fork, attach and
|
|
1124
|
+
// handoff refuse this transcript from the moment it exists.
|
|
1125
|
+
if (keyProfile) recordKeyedSession({ id: event.session_id, engine: 'claude', profileId: keyProfile.id, cosSessionId: sid })
|
|
1025
1126
|
updateClaudeRun(run.runId, { cliSessionId: event.session_id })
|
|
1026
1127
|
callbacks.onNativeSession?.({ cliSessionId: event.session_id })
|
|
1027
1128
|
}
|
|
@@ -1112,6 +1213,7 @@ export async function callClaudeStreaming(
|
|
|
1112
1213
|
}
|
|
1113
1214
|
// Capture CLI session ID for future --resume (avoids cold start on next query)
|
|
1114
1215
|
if (event.session_id && !options?.dispatch) {
|
|
1216
|
+
if (keyProfile) recordKeyedSession({ id: event.session_id, engine: 'claude', profileId: keyProfile.id, cosSessionId: sid })
|
|
1115
1217
|
cliSessionMap.set(resolvedCliKey, event.session_id)
|
|
1116
1218
|
scheduleCliSessionSave()
|
|
1117
1219
|
updateClaudeRun(run.runId, { cliSessionId: event.session_id })
|
|
@@ -1160,6 +1262,7 @@ export async function callClaudeStreaming(
|
|
|
1160
1262
|
|
|
1161
1263
|
proc.on('close', (code) => {
|
|
1162
1264
|
childClosed = true
|
|
1265
|
+
keyTurn?.childClosed()
|
|
1163
1266
|
if (steerReaper) clearTimeout(steerReaper)
|
|
1164
1267
|
steerPort?.close()
|
|
1165
1268
|
if (terminationRequested) return
|
|
@@ -1196,6 +1299,7 @@ export async function callClaudeStreaming(
|
|
|
1196
1299
|
})
|
|
1197
1300
|
|
|
1198
1301
|
proc.on('error', (err) => {
|
|
1302
|
+
keyTurn?.revoke('child_close')
|
|
1199
1303
|
if (terminationRequested) return
|
|
1200
1304
|
finalizeError(`claude-bridge: ${err.message}`, null)
|
|
1201
1305
|
})
|
|
@@ -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
|
+
}
|