@gotcos/glasses-server 6.12.0 → 6.12.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.12.1
4
+
5
+ Public-safe CLI diagnostics for the COS Glasses Recovery Center.
6
+
7
+ - **Both local agents are visible.** Authenticated clients can inspect a
8
+ versioned Claude Code and Codex status summary at `/api/cli/debug`, including
9
+ provider support, persistence readiness, workspace configuration, and the
10
+ latest run's safe status metadata.
11
+ - **False success is fenced.** Claude Code or Codex output that reports a
12
+ machine-shaped authentication failure while the CLI exits `0` is finalized
13
+ as a typed `auth_error`, never projected as a completed assistant reply.
14
+ - **Build 210 stays compatible.** Sanitized `/api/cli/runs` and
15
+ `/api/codex/runs` projections preserve the fields older Recovery Centers can
16
+ render while newer clients adopt the combined contract.
17
+ - **Diagnostics do not become an exfiltration path.** Responses use explicit
18
+ allowlists and omit commands, filesystem paths, trust modes, prompts,
19
+ answers, tool payloads, content previews, raw run/session/thread IDs,
20
+ resumable handles, environment values, and tokens. Legacy display IDs are
21
+ omitted and workspace state is a fixed label only.
22
+ - **Authentication is mandatory.** All three diagnostic routes remain behind
23
+ the existing `/api` token boundary. Preview-enabled ledger fixtures are
24
+ covered by recursive forbidden-field and private-value tests.
25
+ - **Unauthenticated health is capability-only.** `/api/health` and
26
+ `/api/models` advertise `capabilities.cliDebug`; health exposes only a CLI
27
+ session-availability boolean rather than the resumable session ID.
28
+ - **Backward compatible.** Query, prompt, meeting, media, display, model, and
29
+ recovery behavior is unchanged. Older clients and servers continue to use
30
+ their existing paths; a missing CLI Debug capability remains an unsupported
31
+ feature rather than a connection failure.
32
+
3
33
  ## 6.12.0
4
34
 
5
35
  Local-first transcription policy and capability-safe recovery diagnostics for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.12.0",
3
+ "version": "6.12.1",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -27,6 +27,7 @@ import { archiveRouter } from './routes/archive.js'
27
27
  import { sessionsRouter } from './routes/sessions.js'
28
28
  import { mediaRouter, mediaBodyParser } from './routes/media.js'
29
29
  import { promptDraftsRouter } from './routes/prompt-drafts.js'
30
+ import { cliDebugRouter } from './routes/cli-debug.js'
30
31
  import { prewarmContext } from './lib/context-builder.js'
31
32
  import { preWarmCLI } from './lib/claude-bridge.js'
32
33
  import { getCodexRunConfig } from './lib/codex-run-ledger.js'
@@ -163,6 +164,7 @@ app.use('/api', archiveRouter)
163
164
  app.use('/api', sessionsRouter)
164
165
  app.use('/api', mediaRouter)
165
166
  app.use('/api', promptDraftsRouter)
167
+ app.use('/api', cliDebugRouter)
166
168
 
167
169
  // OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
168
170
  // Mounted at root — routes are /v1/chat/completions and /v1/models
@@ -47,6 +47,7 @@ import {
47
47
  MAX_ATTACHMENTS_PER_PROMPT,
48
48
  type MediaAttachmentRef,
49
49
  } from '../../shared/media-attachment.js'
50
+ import { terminalProviderAuthFailure } from './provider-terminal-error.js'
50
51
 
51
52
  // Inactivity = no stdout data for this long → kill (catches stalls)
52
53
  const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
@@ -325,12 +326,15 @@ export interface StreamCallbacks {
325
326
  /** Claude CLI can emit `subtype: success` with `is_error: true`; the boolean
326
327
  * is authoritative and must win before session ids or result text are saved. */
327
328
  export function claudeResultErrorMessage(event: any): string | null {
328
- if (event?.type !== 'result' || (event?.is_error !== true && event?.subtype !== 'error')) return null
329
+ if (event?.type !== 'result') return null
329
330
  const raw = typeof event?.result === 'string' ? event.result
330
331
  : typeof event?.error === 'string' ? event.error
331
332
  : typeof event?.error?.message === 'string' ? event.error.message
332
333
  : typeof event?.message === 'string' ? event.message
333
334
  : ''
335
+ const authFailure = terminalProviderAuthFailure('claude', raw, event?.error)
336
+ if (authFailure) return authFailure
337
+ if (event?.is_error !== true && event?.subtype !== 'error') return null
334
338
  const detail = raw.replace(/\s+/g, ' ').trim().slice(0, 240)
335
339
  return detail ? `claude-bridge: ${detail}` : 'claude-bridge: Claude CLI returned an error result.'
336
340
  }
@@ -545,6 +549,7 @@ export async function callClaudeStreaming(
545
549
  let stderr = ''
546
550
  let buffer = ''
547
551
  let finalized = false // Guard against double onDone/onError
552
+ let terminalTextError: string | null = null
548
553
  let lastActivity = Date.now() // Tracks last stdout data for inactivity timeout
549
554
  let receivedStreamEvents = false // Track if CLI emits stream_event (vs older assistant-only format)
550
555
  const toolInputs = new Map<number, { name: string; json: string }>()
@@ -570,6 +575,13 @@ export async function callClaudeStreaming(
570
575
 
571
576
  async function finalize(text: string) {
572
577
  if (finalized) return
578
+ const responseAuthFailure = terminalProviderAuthFailure('claude', text)
579
+ const authFailure = responseAuthFailure
580
+ ?? (!text.trim() ? terminalTextError ?? terminalProviderAuthFailure('claude', stderr) : null)
581
+ if (authFailure) {
582
+ await finalizeError(authFailure, 0)
583
+ return
584
+ }
573
585
  finalized = true
574
586
  cleanup()
575
587
  cleanupImages()
@@ -831,9 +843,16 @@ export async function callClaudeStreaming(
831
843
  text = event.content
832
844
  }
833
845
  if (text) {
834
- phase = 'generating'
835
- fullText += text
836
- callbacks.onChunk(text)
846
+ // Withhold only strongly machine-shaped provider auth output so
847
+ // credentials cannot flash through onChunk. Human sign-in
848
+ // instructions do not match the terminal classifier.
849
+ const authFailure = terminalProviderAuthFailure('claude', text)
850
+ if (authFailure) terminalTextError = authFailure
851
+ else {
852
+ phase = 'generating'
853
+ fullText += text
854
+ callbacks.onChunk(text)
855
+ }
837
856
  }
838
857
  }
839
858
  } else if (event.type === 'user') {
@@ -855,7 +874,9 @@ export async function callClaudeStreaming(
855
874
  }
856
875
  // tool_use/tool_result/other events still reset inactivity (we got stdout data)
857
876
  } catch {
858
- // Not valid JSON ignore partial lines
877
+ // Older CLI builds can emit a terminal auth error as plain text while
878
+ // still exiting 0. Remember only the canonical classification.
879
+ terminalTextError ??= terminalProviderAuthFailure('claude', trimmed)
859
880
  }
860
881
  }
861
882
  })
@@ -883,13 +904,19 @@ export async function callClaudeStreaming(
883
904
  } catch { /* ignore */ }
884
905
  }
885
906
 
886
- if (code !== 0 && !fullText) {
887
- finalizeError(`claude-bridge: exit ${code} ${stderr.trim().slice(0, 200)}`, code)
888
- } else if (fullText) {
889
- // If we got text but no explicit result event, still finalize
907
+ if (fullText) {
908
+ // If we got text but no explicit result event, still finalize. A sole,
909
+ // machine-shaped auth response is classified inside finalize.
890
910
  void finalize(fullText)
891
911
  } else {
892
- finalizeError('claude-bridge: Claude completed without a response.', code)
912
+ const authFailure = terminalTextError ?? terminalProviderAuthFailure('claude', stderr, buffer)
913
+ if (authFailure) {
914
+ finalizeError(authFailure, code)
915
+ } else if (code !== 0) {
916
+ finalizeError(`claude-bridge: exit ${code} — ${stderr.trim().slice(0, 200)}`, code)
917
+ } else {
918
+ finalizeError('claude-bridge: Claude completed without a response.', code)
919
+ }
893
920
  }
894
921
  })
895
922
 
@@ -0,0 +1,213 @@
1
+ import type { ClaudeRunConfig, ClaudeRunRecord } from './claude-run-ledger.js'
2
+ import type { CodexRunConfig, CodexRunRecord } from './codex-run-ledger.js'
3
+
4
+ export const CLI_DEBUG_CAPABILITY = Object.freeze({
5
+ schemaVersion: 1,
6
+ providers: Object.freeze({ claude: true, codex: true }),
7
+ metadataOnly: true,
8
+ })
9
+
10
+ export type SafeCliRunStatus =
11
+ | 'running'
12
+ | 'completed'
13
+ | 'failed'
14
+ | 'cancelled'
15
+ | 'client_disconnected'
16
+
17
+ export interface SafeCliDebugLatestRun {
18
+ status: SafeCliRunStatus
19
+ model: string
20
+ concreteModel?: string
21
+ effort?: string
22
+ resumed: boolean
23
+ durationMs?: number
24
+ updatedAt: string
25
+ errorCode?: string
26
+ }
27
+
28
+ export interface SafeCliDebugProvider {
29
+ supported: true
30
+ persistenceEnabled: boolean
31
+ workspaceConfigured: boolean
32
+ latestRun: SafeCliDebugLatestRun | null
33
+ }
34
+
35
+ export interface SafeCliDebugResponse {
36
+ schemaVersion: 1
37
+ providers: {
38
+ claude: SafeCliDebugProvider
39
+ codex: SafeCliDebugProvider
40
+ }
41
+ }
42
+
43
+ function optionalString(value: unknown): string | undefined {
44
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
45
+ }
46
+
47
+ function optionalFiniteNumber(value: unknown): number | undefined {
48
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
49
+ }
50
+
51
+ function safeStatus(value: unknown): SafeCliRunStatus {
52
+ return value === 'running' || value === 'completed' || value === 'failed'
53
+ || value === 'cancelled' || value === 'client_disconnected'
54
+ ? value
55
+ : 'failed'
56
+ }
57
+
58
+ function safeModelLabel(value: unknown, provider: 'claude' | 'codex'): string | undefined {
59
+ const model = optionalString(value)
60
+ if (!model || model.length > 96) return undefined
61
+ const allowed = provider === 'claude'
62
+ ? /^(?:claude-|opus(?:\[1m\])?$|sonnet(?:\[1m\])?$|fable(?:\[1m\])?$|haiku(?:\[1m\])?$)[a-z0-9._\[\]-]*$/i
63
+ : /^(?:gpt-|codex-)[a-z0-9._\[\]-]*$/i
64
+ return allowed.test(model) ? model : undefined
65
+ }
66
+
67
+ function safeEffort(value: unknown): string | undefined {
68
+ const effort = optionalString(value)?.toLowerCase()
69
+ return effort && ['low', 'medium', 'high', 'xhigh', 'max', 'ultra', 'ultracode'].includes(effort)
70
+ ? effort
71
+ : undefined
72
+ }
73
+
74
+ function safeTimestamp(value: unknown): string {
75
+ const timestamp = optionalString(value)
76
+ const parsed = timestamp ? Date.parse(timestamp) : Number.NaN
77
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date(0).toISOString()
78
+ }
79
+
80
+ function safeErrorCode(provider: 'claude' | 'codex', value: unknown): string | undefined {
81
+ const candidate = optionalString(value)
82
+ if (!candidate) return undefined
83
+ const allowed = new Set([
84
+ `${provider}.cli_unavailable`,
85
+ `${provider}.permission_denied`,
86
+ `${provider}.auth_error`,
87
+ `${provider}.timeout`,
88
+ `${provider}.nonzero_exit`,
89
+ `${provider}.error`,
90
+ `${provider}.interrupted`,
91
+ ])
92
+ return allowed.has(candidate) ? candidate : `${provider}.error`
93
+ }
94
+
95
+ export function safeClaudeLatestRun(run?: ClaudeRunRecord): SafeCliDebugLatestRun | null {
96
+ if (!run) return null
97
+ const concreteModel = safeModelLabel(run.resolvedModelId ?? run.cliModelId, 'claude')
98
+ const effort = safeEffort(run.effortLevel)
99
+ const errorCode = safeErrorCode('claude', run.errorCode)
100
+ return {
101
+ status: safeStatus(run.status),
102
+ model: safeModelLabel(run.model, 'claude') ?? 'unknown',
103
+ ...(concreteModel ? { concreteModel } : {}),
104
+ ...(effort ? { effort } : {}),
105
+ resumed: run.resumed === true,
106
+ ...(optionalFiniteNumber(run.durationMs) !== undefined ? { durationMs: run.durationMs } : {}),
107
+ updatedAt: safeTimestamp(run.updatedAt),
108
+ ...(errorCode ? { errorCode } : {}),
109
+ }
110
+ }
111
+
112
+ export function safeCodexLatestRun(run?: CodexRunRecord): SafeCliDebugLatestRun | null {
113
+ if (!run) return null
114
+ const concreteModel = safeModelLabel(run.cliModel, 'codex')
115
+ const effort = safeEffort(run.reasoningEffort)
116
+ const errorCode = safeErrorCode('codex', run.errorCode)
117
+ return {
118
+ status: safeStatus(run.status),
119
+ model: safeModelLabel(run.model, 'codex') ?? 'unknown',
120
+ ...(concreteModel ? { concreteModel } : {}),
121
+ ...(effort ? { effort } : {}),
122
+ resumed: run.resumed === true,
123
+ ...(optionalFiniteNumber(run.durationMs) !== undefined ? { durationMs: run.durationMs } : {}),
124
+ updatedAt: safeTimestamp(run.updatedAt),
125
+ ...(errorCode ? { errorCode } : {}),
126
+ }
127
+ }
128
+
129
+ export function safeCliDebugResponse(
130
+ claudeConfig: ClaudeRunConfig,
131
+ claudeRun: ClaudeRunRecord | undefined,
132
+ codexConfig: CodexRunConfig,
133
+ codexRun: CodexRunRecord | undefined,
134
+ ): SafeCliDebugResponse {
135
+ return {
136
+ schemaVersion: 1,
137
+ providers: {
138
+ claude: {
139
+ supported: true,
140
+ persistenceEnabled: claudeConfig.persistenceEnabled === true,
141
+ workspaceConfigured: Boolean(claudeConfig.cwd),
142
+ latestRun: safeClaudeLatestRun(claudeRun),
143
+ },
144
+ codex: {
145
+ supported: true,
146
+ persistenceEnabled: codexConfig.persistenceEnabled === true,
147
+ workspaceConfigured: Boolean(codexConfig.cwd),
148
+ latestRun: safeCodexLatestRun(codexRun),
149
+ },
150
+ },
151
+ }
152
+ }
153
+
154
+ /** Compatibility projection for build 210 and earlier Settings panels.
155
+ * Every returned key is an explicit public-safe allowlist entry. */
156
+ export function safeLegacyClaudeResponse(config: ClaudeRunConfig, runs: ClaudeRunRecord[]) {
157
+ return {
158
+ schemaVersion: 1,
159
+ config: {
160
+ persistenceEnabled: config.persistenceEnabled === true,
161
+ workspaceConfigured: Boolean(config.cwd),
162
+ cwd: config.cwd ? 'Configured workspace' : 'Not configured',
163
+ defaultEffortLevel: safeEffort(config.defaultEffortLevel) ?? 'high',
164
+ contentPreviewsEnabled: false,
165
+ },
166
+ runs: runs.map(run => {
167
+ const latest = safeClaudeLatestRun(run)!
168
+ return {
169
+ status: latest.status,
170
+ model: latest.model,
171
+ ...(latest.concreteModel ? {
172
+ cliModelId: latest.concreteModel,
173
+ resolvedModelId: latest.concreteModel,
174
+ } : {}),
175
+ ...(latest.effort ? { effortLevel: latest.effort } : {}),
176
+ ...(typeof latest.resumed === 'boolean' ? { resumed: latest.resumed } : {}),
177
+ ...(latest.durationMs !== undefined ? { durationMs: latest.durationMs } : {}),
178
+ ...(latest.updatedAt ? { updatedAt: latest.updatedAt } : {}),
179
+ ...(latest.errorCode ? { errorCode: latest.errorCode } : {}),
180
+ }
181
+ }),
182
+ }
183
+ }
184
+
185
+ /** Compatibility projection for build 210 and earlier Settings panels.
186
+ * Engine sessions are deliberately absent: their thread ids are resumable
187
+ * runtime handles, not display metadata. */
188
+ export function safeLegacyCodexResponse(config: CodexRunConfig, runs: CodexRunRecord[]) {
189
+ return {
190
+ schemaVersion: 1,
191
+ config: {
192
+ persistenceEnabled: config.persistenceEnabled === true,
193
+ workspaceConfigured: Boolean(config.cwd),
194
+ cwd: config.cwd ? 'Configured workspace' : 'Not configured',
195
+ cliModel: safeModelLabel(config.cliModel, 'codex') ?? 'unknown',
196
+ reasoningEffort: safeEffort(config.reasoningEffort) ?? 'high',
197
+ contentPreviewsEnabled: false,
198
+ },
199
+ runs: runs.map(run => {
200
+ const latest = safeCodexLatestRun(run)!
201
+ return {
202
+ status: latest.status,
203
+ model: latest.model,
204
+ ...(latest.concreteModel ? { cliModel: latest.concreteModel } : {}),
205
+ ...(latest.effort ? { reasoningEffort: latest.effort } : {}),
206
+ ...(typeof latest.resumed === 'boolean' ? { resumed: latest.resumed } : {}),
207
+ ...(latest.durationMs !== undefined ? { durationMs: latest.durationMs } : {}),
208
+ ...(latest.updatedAt ? { updatedAt: latest.updatedAt } : {}),
209
+ ...(latest.errorCode ? { errorCode: latest.errorCode } : {}),
210
+ }
211
+ }),
212
+ }
213
+ }
@@ -60,6 +60,7 @@ import {
60
60
  MAX_ATTACHMENTS_PER_PROMPT,
61
61
  type MediaAttachmentRef,
62
62
  } from '../../shared/media-attachment.js'
63
+ import { terminalProviderAuthFailure } from './provider-terminal-error.js'
63
64
 
64
65
  const INACTIVITY_MS = 180_000
65
66
  const WALL_MAX_MS = 900_000
@@ -366,6 +367,7 @@ export async function callCodexStreaming(
366
367
  let stderr = ''
367
368
  let buffer = ''
368
369
  let finalized = false
370
+ let terminalTextError: string | null = null
369
371
  let lastActivity = Date.now()
370
372
  const emittedBlocks = new Set<string>()
371
373
 
@@ -425,6 +427,13 @@ export async function callCodexStreaming(
425
427
 
426
428
  async function finalize(text: string) {
427
429
  if (finalized) return
430
+ const responseAuthFailure = terminalProviderAuthFailure('codex', text)
431
+ const authFailure = responseAuthFailure
432
+ ?? (!text.trim() ? terminalTextError ?? terminalProviderAuthFailure('codex', stderr) : null)
433
+ if (authFailure) {
434
+ await finalizeError(authFailure, 0)
435
+ return
436
+ }
428
437
  finalized = true
429
438
  cleanup()
430
439
  cleanupImages()
@@ -614,13 +623,32 @@ export async function callCodexStreaming(
614
623
  }
615
624
 
616
625
  const text = extractCodexResponseText(event)
617
- if (text) emitText(text)
626
+ if (text) {
627
+ // Withhold only strongly machine-shaped provider auth output so
628
+ // credentials cannot flash through onChunk. Human sign-in instructions
629
+ // do not match the terminal classifier.
630
+ const authFailure = terminalProviderAuthFailure('codex', text)
631
+ if (authFailure) terminalTextError = authFailure
632
+ else emitText(text)
633
+ }
618
634
 
619
635
  const type = String(event?.type ?? '')
620
636
  if (type === 'turn.completed') {
621
637
  void finalize(fullText)
622
638
  } else if (type === 'turn.failed' || type === 'error') {
623
- finalizeError(`codex-bridge: ${event?.error ?? event?.message ?? 'unknown error'}`)
639
+ const rawProviderError = typeof event?.error === 'string' ? event.error
640
+ : typeof event?.error?.message === 'string' ? event.error.message
641
+ : typeof event?.message === 'string' ? event.message
642
+ : 'unknown error'
643
+ const structuredProviderError = event?.error && typeof event.error === 'object'
644
+ ? JSON.stringify(event.error)
645
+ : ''
646
+ const authenticationError = terminalProviderAuthFailure(
647
+ 'codex',
648
+ rawProviderError,
649
+ structuredProviderError,
650
+ )
651
+ finalizeError(authenticationError ?? `codex-bridge: ${rawProviderError}`)
624
652
  }
625
653
  }
626
654
 
@@ -636,7 +664,9 @@ export async function callCodexStreaming(
636
664
  try {
637
665
  handleEvent(JSON.parse(trimmed))
638
666
  } catch {
639
- // Ignore non-JSON status lines from older CLI builds.
667
+ // Older CLI builds can emit a terminal auth error as plain text while
668
+ // still exiting 0. Remember only the canonical classification.
669
+ terminalTextError ??= terminalProviderAuthFailure('codex', trimmed)
640
670
  }
641
671
  }
642
672
  })
@@ -651,10 +681,15 @@ export async function callCodexStreaming(
651
681
  try { handleEvent(JSON.parse(buffer.trim())) } catch { /* ignore */ }
652
682
  }
653
683
  if (finalized) return
654
- if (code !== 0) {
655
- finalizeError(`codex-bridge: exit ${code} — ${stderr.trim().slice(0, 240)}`, code)
656
- } else if (fullText) {
684
+ if (fullText) {
657
685
  void finalize(fullText)
686
+ return
687
+ }
688
+ const authFailure = terminalTextError ?? terminalProviderAuthFailure('codex', stderr, buffer)
689
+ if (authFailure) {
690
+ finalizeError(authFailure, code)
691
+ } else if (code !== 0) {
692
+ finalizeError(`codex-bridge: exit ${code} — ${stderr.trim().slice(0, 240)}`, code)
658
693
  } else {
659
694
  finalizeError('codex-bridge: Codex completed without a response.')
660
695
  }
@@ -0,0 +1,55 @@
1
+ export type CliProvider = 'claude' | 'codex'
2
+
3
+ const AUTH_CODE = /^(?:401|403|unauthori[sz]ed|forbidden|authentication_error|authorization_error|invalid_api_key|not_authenticated)$/i
4
+ // Match whole, terminal-looking provider failures only. Natural assistant
5
+ // answers such as "Please sign in to the customer portal, then..." must not
6
+ // be reinterpreted as failures merely because their first words mention auth.
7
+ const AUTH_FAILURE_PREFIX = /^(?:\s*(?:api\s+|http\s+|request\s+)?error(?:\[[^\]]+\])?\s*:\s*.{0,180}\b(?:401|403|unauthori[sz]ed|forbidden|authentication\s+(?:failed|required)|login\s+required|not\s+(?:logged|signed)\s+in)\b.*|\s*(?:http\s+)?(?:401|403)(?:\s+(?:unauthori[sz]ed|forbidden))?(?:\s*[:.\-]\s*.*)?|\s*(?:unauthori[sz]ed|forbidden)(?:\s*[:.\-]\s*.*)?|\s*(?:authentication|authorization)\s+(?:failed|required|error|missing|denied)(?:\s*[:.\-]\s*.*)?|\s*(?:login|sign[ -]?in)\s+(?:required|failed)(?:\s*[:.\-]\s*.*)?|\s*(?:you(?:'re| are)\s+)?not\s+(?:logged|signed)\s+in(?:\s*[:.\-]\s*.*)?|\s*please\s+run\s+(?:[`'"]?(?:claude|codex)[`'"]?\s+)?(?:[`'"]?\/?login[`'"]?|[`'"]?auth(?:enticate)?[`'"]?)\b.*)\s*$/i
8
+
9
+ function stripAnsi(value: string): string {
10
+ // eslint-disable-next-line no-control-regex
11
+ return value.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
12
+ }
13
+
14
+ function structuredAuthFailure(value: unknown): boolean {
15
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
16
+ const record = value as Record<string, unknown>
17
+ if (record.status === 401 || record.status === 403 || record.statusCode === 401 || record.statusCode === 403) return true
18
+ for (const key of ['code', 'type']) {
19
+ if (typeof record[key] === 'string' && AUTH_CODE.test(record[key])) return true
20
+ }
21
+ if (typeof record.error === 'string') {
22
+ return AUTH_CODE.test(record.error.trim()) || AUTH_FAILURE_PREFIX.test(record.error)
23
+ }
24
+ return structuredAuthFailure(record.error)
25
+ }
26
+
27
+ function looksLikeTerminalAuthFailure(value: unknown): boolean {
28
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
29
+ return structuredAuthFailure(value)
30
+ }
31
+ if (typeof value !== 'string') return false
32
+ const text = stripAnsi(value).trim()
33
+ if (!text || text.length > 2_000) return false
34
+ if (AUTH_FAILURE_PREFIX.test(text.replace(/\s+/g, ' '))) return true
35
+ if (!(text.startsWith('{') && text.endsWith('}'))) return false
36
+ try {
37
+ return structuredAuthFailure(JSON.parse(text))
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Some CLI versions report authentication failures as successful process
45
+ * output and exit 0. Detect only terminal, machine-shaped auth messages and
46
+ * return a canonical error that cannot echo credentials or provider output.
47
+ */
48
+ export function terminalProviderAuthFailure(
49
+ provider: CliProvider,
50
+ ...terminalValues: unknown[]
51
+ ): string | null {
52
+ return terminalValues.some(looksLikeTerminalAuthFailure)
53
+ ? `${provider}-bridge: authentication required.`
54
+ : null
55
+ }
@@ -0,0 +1,66 @@
1
+ import { Router } from 'express'
2
+ import { normalizeModelPreference, isClaudeModel } from '../../shared/model-preference.js'
3
+ import {
4
+ getClaudeRunConfig,
5
+ listClaudeRuns,
6
+ } from '../lib/claude-run-ledger.js'
7
+ import {
8
+ getCodexRunConfig,
9
+ listCodexRuns,
10
+ } from '../lib/codex-run-ledger.js'
11
+ import {
12
+ safeCliDebugResponse,
13
+ safeLegacyClaudeResponse,
14
+ safeLegacyCodexResponse,
15
+ } from '../lib/cli-debug-view.js'
16
+
17
+ export const cliDebugRouter = Router()
18
+
19
+ function boundedLimit(value: unknown): number {
20
+ const raw = Number(value ?? 20)
21
+ return Number.isFinite(raw) && raw > 0 ? Math.min(Math.floor(raw), 50) : 20
22
+ }
23
+
24
+ function optionalSessionId(value: unknown): string | undefined {
25
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
26
+ }
27
+
28
+ function optionalClaudeModel(value: unknown) {
29
+ const raw = typeof value === 'string' ? normalizeModelPreference(value) : undefined
30
+ return raw && isClaudeModel(raw) ? raw : undefined
31
+ }
32
+
33
+ // Versioned public-safe view consumed by Recovery Center. Global /api auth
34
+ // protects this route; it must never be added to the unauthenticated allowlist.
35
+ cliDebugRouter.get('/cli/debug', (req, res) => {
36
+ const limit = boundedLimit(req.query.limit)
37
+ const sessionId = optionalSessionId(req.query.sessionId)
38
+ const model = optionalClaudeModel(req.query.model)
39
+ const claudeConfig = getClaudeRunConfig()
40
+ const codexConfig = getCodexRunConfig()
41
+ const claudeRuns = listClaudeRuns(limit, sessionId, model)
42
+ const codexRuns = listCodexRuns(limit, sessionId)
43
+ res.json(safeCliDebugResponse(
44
+ claudeConfig,
45
+ claudeRuns[0],
46
+ codexConfig,
47
+ codexRuns[0],
48
+ ))
49
+ })
50
+
51
+ // Build-210 compatibility. These legacy shapes retain only the fields the old
52
+ // panel can render safely; they do not expose raw ledger records.
53
+ cliDebugRouter.get('/cli/runs', (req, res) => {
54
+ const limit = boundedLimit(req.query.limit)
55
+ const sessionId = optionalSessionId(req.query.sessionId)
56
+ const model = optionalClaudeModel(req.query.model)
57
+ const config = getClaudeRunConfig()
58
+ res.json(safeLegacyClaudeResponse(config, listClaudeRuns(limit, sessionId, model)))
59
+ })
60
+
61
+ cliDebugRouter.get('/codex/runs', (req, res) => {
62
+ const limit = boundedLimit(req.query.limit)
63
+ const sessionId = optionalSessionId(req.query.sessionId)
64
+ const config = getCodexRunConfig()
65
+ res.json(safeLegacyCodexResponse(config, listCodexRuns(limit, sessionId)))
66
+ })
@@ -20,6 +20,7 @@ import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
20
20
  import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
21
21
  import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
22
22
  import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
23
+ import { CLI_DEBUG_CAPABILITY } from '../lib/cli-debug-view.js'
23
24
 
24
25
  export const healthRouter = Router()
25
26
 
@@ -53,7 +54,7 @@ function durableQueryJobStatus() {
53
54
  }
54
55
 
55
56
  healthRouter.get('/health', async (_req, res) => {
56
- const checks: Record<string, string | number> = {
57
+ const checks: Record<string, string | number | boolean> = {
57
58
  status: 'ok',
58
59
  mode: COS_MODE ? 'cos' : 'standalone',
59
60
  server: 'ok',
@@ -128,9 +129,11 @@ healthRouter.get('/health', async (_req, res) => {
128
129
 
129
130
  checks.silero_vad = isSileroAvailable() ? 'active' : 'disabled'
130
131
 
131
- // Include CLI session ID if available (pre-warmed or active)
132
+ // Health is unauthenticated. Publish only availability; the actual CLI
133
+ // session id is a resumable runtime handle and belongs on authenticated
134
+ // query/debug surfaces.
132
135
  const cliSid = getAvailableCliSessionId()
133
- if (cliSid) checks.cli_session_id = cliSid
136
+ checks.cli_session_available = Boolean(cliSid)
134
137
 
135
138
  // Feature summary for client capability detection.
136
139
  // v5.9.5 — voice.hasKey reflects the centralized resolver (env > saved file >
@@ -185,6 +188,7 @@ healthRouter.get('/health', async (_req, res) => {
185
188
  capabilities: {
186
189
  transcription,
187
190
  recovery,
191
+ cliDebug: CLI_DEBUG_CAPABILITY,
188
192
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
189
193
  },
190
194
  // /api/health is intentionally unauthenticated for setup diagnostics.
@@ -215,6 +219,7 @@ healthRouter.get('/models', async (req, res) => {
215
219
  protocolVersion: durableJobs.protocolVersion,
216
220
  },
217
221
  transcription,
222
+ cliDebug: CLI_DEBUG_CAPABILITY,
218
223
  recovery: {
219
224
  status: false,
220
225
  restartWhisper: false,