@gotcos/glasses-server 6.15.1 → 6.15.3

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 CHANGED
@@ -61,6 +61,12 @@ BIND_HOST=0.0.0.0
61
61
  # Claude to COS's explicit per-query tool allowlist. Undeclared tools fail
62
62
  # closed without an interactive prompt:
63
63
  # COS_CLAUDE_TRUST_MODE=allowlist
64
+ # Optional comma-separated MCP tool selectors made available to BOTH full and
65
+ # lightweight glasses queries. Only mcp__server__tool / mcp__server__* entries
66
+ # are accepted; built-in Bash/Write access cannot be enabled here.
67
+ # COS_EXTRA_TOOLS=mcp__calendar__*,mcp__gmail__search
68
+ # Optional absolute MCP config when the managed CLI cwd does not contain it.
69
+ # COS_CLAUDE_MCP_CONFIG=/absolute/path/to/.mcp.json
64
70
 
65
71
  # ── VOICE (optional) ────────────────────────────────────────────────────
66
72
  # Local transcription is FREE via whisper.cpp (brew install whisper-cpp; the
package/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ ## 6.15.3
2
+
3
+ - Make `COS_WORKDIR` the authoritative Claude, Codex, and Cursor workspace.
4
+ Legacy provider-specific and `COS_SCRIPTS_DIR` paths remain compatibility
5
+ fallbacks, so pipeline scripts can stay separate from the agent workspace.
6
+ - Add workspace-precedence coverage for migrated LaunchAgent environments.
7
+
8
+ ## 6.15.2
9
+
10
+ - Validate the inherited Kokoro Python runtime before skipping bootstrap, so a
11
+ stale Python 3.13 or partial venv is repaired during a normal managed update.
12
+ - Retry failed Kokoro cold starts with bounded exponential backoff instead of
13
+ latching local speech unavailable until the whole server restarts.
14
+ - Add explicit MCP selector/config support to both Claude query paths and tell
15
+ the model the exact permission selectors without fabricating connector
16
+ health, authentication, or handshake machinery.
17
+ - Add an authenticated, boot-cached transactional provider proof for COS
18
+ Control. It performs a real no-tool model turn and exposes no provider output
19
+ or credentials.
20
+
1
21
  ## 6.15.1
2
22
 
3
23
  - Fix prepared TTS playback for native audio clients by allowing only
package/README.md CHANGED
@@ -125,6 +125,10 @@ optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
125
125
  `COS_TTS_KOKORO_VOICE` (local voice id),
126
126
  `COS_TTS_LOCAL_DISABLE=1` (disable the sidecar), and
127
127
  `COS_TTS_PRONUNCIATIONS_JSON` (optional local/cloud pronunciation overrides),
128
+ `COS_EXTRA_TOOLS` (comma-separated `mcp__server__tool` or
129
+ `mcp__server__*` selectors shared by full and lightweight Claude paths),
130
+ `COS_CLAUDE_MCP_CONFIG` (optional absolute config path when `.mcp.json` is not
131
+ in the managed CLI working directory),
128
132
  `COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=1` (build 204+
129
133
  server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
130
134
  location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.15.1",
3
+ "version": "6.15.3",
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
@@ -15,6 +15,7 @@ import { randomBytes } from 'node:crypto'
15
15
  import { healthRouter } from './routes/health.js'
16
16
  import { diagRouter } from './routes/diag.js'
17
17
  import { queryRouter } from './routes/query.js'
18
+ import { providerProofRouter } from './routes/provider-proof.js'
18
19
  import { transcribeRouter } from './routes/transcribe.js'
19
20
  import { displayRouter } from './routes/display.js'
20
21
  import { transcribeStreamRouter } from './routes/transcribe-stream.js'
@@ -70,6 +71,7 @@ import { requireApiToken } from './lib/api-auth.js'
70
71
  import { isManagedRuntime } from './lib/managed-runtime.js'
71
72
  import {
72
73
  acquireMaintenanceWork,
74
+ maintenanceOperationCredentialsValid,
73
75
  MaintenanceLifecycleError,
74
76
  maintenanceAdmissionsOpen,
75
77
  maintenanceErrorPayload,
@@ -158,8 +160,29 @@ app.use('/api', (req, res, next) => {
158
160
  || req.path.startsWith('/maintenance/drain')
159
161
  if (lifecycleOwned) return next()
160
162
 
163
+ // COS Control must prove the candidate while a committed cross-boot gate is
164
+ // still closed. Permit only its two bounded loopback proofs, and only with
165
+ // the controller-held operation receipt. Normal phone/LAN admissions stay
166
+ // closed throughout the update.
167
+ const address = req.socket.remoteAddress ?? ''
168
+ const loopback = address === '::1' || address === '127.0.0.1'
169
+ || address.startsWith('127.') || address.startsWith('::ffff:127.')
170
+ const controllerProofPath = req.path === '/diagnostics/provider-proof'
171
+ || req.path === '/tts/prepare'
172
+ const controllerProof = loopback && controllerProofPath
173
+ && maintenanceOperationCredentialsValid({
174
+ leaseId: typeof req.headers['x-cos-maintenance-lease'] === 'string'
175
+ ? req.headers['x-cos-maintenance-lease'] : undefined,
176
+ operationId: typeof req.headers['x-cos-maintenance-operation'] === 'string'
177
+ ? req.headers['x-cos-maintenance-operation'] : undefined,
178
+ nonce: typeof req.headers['x-cos-maintenance-nonce'] === 'string'
179
+ ? req.headers['x-cos-maintenance-nonce'] : undefined,
180
+ })
181
+
161
182
  try {
162
- const lease = acquireMaintenanceWork('api_mutation')
183
+ const lease = acquireMaintenanceWork('api_mutation', {
184
+ allowDuringDrain: controllerProof,
185
+ })
163
186
  let released = false
164
187
  const release = () => {
165
188
  if (released) return
@@ -196,6 +219,7 @@ app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
196
219
  prepareAdmission: preparePublicDurableQueryAdmission,
197
220
  }))
198
221
  app.use('/api', queryRouter)
222
+ app.use('/api', providerProofRouter)
199
223
  app.use('/api', transcribeRouter)
200
224
  app.use('/api', displayRouter)
201
225
  app.use('/api', transcribeStreamRouter)
@@ -11,7 +11,7 @@ import { writeFileSync, readFileSync, unlinkSync, existsSync } from 'node:fs'
11
11
  import { appendFileSync } from 'node:fs'
12
12
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
13
13
  import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
14
- import { cosBrainDir } from './launch-dir.js'
14
+ import { resolveProviderWorkDir } from './launch-dir.js'
15
15
  import { logTokenAudit } from './token-audit.js'
16
16
  import { buildSystemPrompt, buildLightweightSystemPrompt, buildPrewarmSystemPrompt, getCachedContextInstant } from './context-builder.js'
17
17
  import { getHistory, addExchange, setExchangeAttachments, removeExchange, formatHistoryForPrompt, getOrCreateSession, isNewSession, markSessionNotified, getSessionModel, getSessionRaw, replaceLastExchangeWithSummary, type ModelPreference, type PromptReference } from './conversation.js'
@@ -47,6 +47,11 @@ import {
47
47
  MAX_ATTACHMENTS_PER_PROMPT,
48
48
  type MediaAttachmentRef,
49
49
  } from '../../shared/media-attachment.js'
50
+ import {
51
+ buildClaudeToolList,
52
+ claudeMcpConfigArgs,
53
+ claudeToolCapabilityPrompt,
54
+ } from './claude-tool-access.js'
50
55
  import { terminalProviderAuthFailure } from './provider-terminal-error.js'
51
56
  import { claudePermissionArgs, getClaudeTrustMode } from './claude-permissions.js'
52
57
 
@@ -220,7 +225,7 @@ export async function preWarmCLI(): Promise<void> {
220
225
  ], {
221
226
  stdio: ['pipe', 'pipe', 'pipe'],
222
227
  env,
223
- cwd: COS_SCRIPTS_DIR ?? cosBrainDir() ?? process.cwd(),
228
+ cwd: resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR }),
224
229
  })
225
230
 
226
231
  let buffer = ''
@@ -430,6 +435,18 @@ export async function callClaudeStreaming(
430
435
  outputImagePublisher?.cleanup()
431
436
  throw err
432
437
  }
438
+ const allowedToolList = buildClaudeToolList({
439
+ includeRead: imagePaths.length > 0,
440
+ publisherTool: outputImagePublisher?.claudeAllowedTool,
441
+ })
442
+ let mcpConfigArgs: string[]
443
+ try {
444
+ mcpConfigArgs = claudeMcpConfigArgs()
445
+ } catch (err) {
446
+ outputImagePublisher?.cleanup()
447
+ throw err
448
+ }
449
+ systemPrompt = `${systemPrompt}\n\n${claudeToolCapabilityPrompt(allowedToolList)}`
433
450
  if (outputImagePublisher) systemPrompt = `${systemPrompt}\n\n${outputImagePublisher.promptInstructions}`
434
451
 
435
452
  // Phase: thinking (waiting for Claude to start)
@@ -456,10 +473,7 @@ export async function callClaudeStreaming(
456
473
  )
457
474
 
458
475
  // Vision queries need the Read tool to see the image files
459
- const baseTools = imagePaths.length > 0 ? 'WebSearch,WebFetch,Read' : 'WebSearch,WebFetch'
460
- const tools = outputImagePublisher
461
- ? `${baseTools},${outputImagePublisher.claudeAllowedTool}`
462
- : baseTools
476
+ const tools = allowedToolList.join(',')
463
477
 
464
478
  // Prepend image instruction when photos are attached
465
479
  let fullQuery: string
@@ -495,16 +509,12 @@ export async function callClaudeStreaming(
495
509
  '--output-format', 'stream-json',
496
510
  '--verbose', // Required: stream-json requires --verbose
497
511
  '--system-prompt', systemPrompt,
512
+ ...mcpConfigArgs,
498
513
  ]
499
514
 
500
- // Full COS path gets tools + partial messages; lightweight gets web search only
515
+ // Full and lightweight paths share the same explicit MCP selector contract.
501
516
  if (options?.lightweight) {
502
- if (imagePaths.length > 0) {
503
- args.push(...claudePermissionArgs(getClaudeTrustMode(), tools))
504
- } else {
505
- // Lightweight: web search for general questions, no Bash/Read/Write (saves 5-10s)
506
- args.push(...claudePermissionArgs(getClaudeTrustMode(), 'WebSearch,WebFetch'))
507
- }
517
+ args.push(...claudePermissionArgs(getClaudeTrustMode(), tools))
508
518
  } else {
509
519
  args.push(...claudePermissionArgs(getClaudeTrustMode(), tools), '--include-partial-messages')
510
520
  }
@@ -518,7 +528,7 @@ export async function callClaudeStreaming(
518
528
  const env = { ...process.env }
519
529
  delete env.CLAUDECODE
520
530
  if (outputImagePublisher) Object.assign(env, outputImagePublisher.env)
521
- const cliCwd = COS_SCRIPTS_DIR ?? cosBrainDir() ?? process.cwd()
531
+ const cliCwd = resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR })
522
532
  const inactivityMs = INACTIVITY_BY_MODEL[resolvedModel]
523
533
  const defaultWallMax = WALL_MAX_BY_MODEL[resolvedModel]
524
534
  const effortWallMax = resolvedEffort === 'max' || resolvedEffort === 'ultracode'
@@ -0,0 +1,56 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ // COS_EXTRA_TOOLS is intentionally limited to Claude MCP selectors. The
5
+ // server's built-in Web/Read tools remain code-owned, so a remotely reachable
6
+ // glasses query cannot turn a local env typo into Bash/Write access.
7
+ const MCP_SELECTOR = /^mcp__[A-Za-z0-9][A-Za-z0-9_.:@/-]*__[A-Za-z0-9*][A-Za-z0-9_.*:@/-]*$/
8
+
9
+ export function configuredClaudeExtraTools(
10
+ env: NodeJS.ProcessEnv = process.env,
11
+ ): string[] {
12
+ const raw = env.COS_EXTRA_TOOLS ?? ''
13
+ const seen = new Set<string>()
14
+ const tools: string[] = []
15
+ for (const value of raw.split(',')) {
16
+ const tool = value.trim()
17
+ if (!tool || !MCP_SELECTOR.test(tool) || seen.has(tool)) continue
18
+ seen.add(tool)
19
+ tools.push(tool)
20
+ }
21
+ return tools
22
+ }
23
+
24
+ export function buildClaudeToolList(input: {
25
+ includeRead?: boolean
26
+ publisherTool?: string
27
+ env?: NodeJS.ProcessEnv
28
+ } = {}): string[] {
29
+ const tools = ['WebSearch', 'WebFetch']
30
+ if (input.includeRead) tools.push('Read')
31
+ tools.push(...configuredClaudeExtraTools(input.env))
32
+ if (input.publisherTool) tools.push(input.publisherTool)
33
+ return [...new Set(tools)]
34
+ }
35
+
36
+ export function claudeToolCapabilityPrompt(tools: string[]): string {
37
+ return `TOOL CAPABILITY CONTRACT:
38
+ This request is configured with only these tool selectors: ${tools.join(', ') || '(none)'}.
39
+ Selectors are permissions, not proof that a connector is online. Use a tool only when it is actually present in this session. If the user asks for a tool or connector that is absent, or a tool call fails, say that it is unavailable. Never invent connector health, sign-in handshakes, token loading, endpoints, or authentication state.`
40
+ }
41
+
42
+ /** Optional explicit MCP config for managed launches whose CLI cwd differs
43
+ * from the COS brain. Normal project-local `.mcp.json` discovery needs no flag. */
44
+ export function claudeMcpConfigArgs(
45
+ env: NodeJS.ProcessEnv = process.env,
46
+ ): string[] {
47
+ const configured = env.COS_CLAUDE_MCP_CONFIG?.trim()
48
+ if (!configured) return []
49
+ const path = resolve(configured)
50
+ let regular = false
51
+ try { regular = existsSync(path) && statSync(path).isFile() } catch { regular = false }
52
+ if (!regular) {
53
+ throw new Error(`claude-bridge: COS_CLAUDE_MCP_CONFIG is not a readable file: ${path}`)
54
+ }
55
+ return ['--mcp-config', path]
56
+ }
@@ -2,7 +2,7 @@ import crypto from 'node:crypto'
2
2
  import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
3
3
  import { dirname, resolve } from 'node:path'
4
4
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
5
- import { cosBrainDir } from './launch-dir.js'
5
+ import { resolveProviderWorkDir } from './launch-dir.js'
6
6
  import { CODEX_ENGINE_SESSION_TTL_MS, type CodexTrustMode } from './codex-engine-sessions.js'
7
7
  import {
8
8
  CODEX_FRONTIER_MODEL,
@@ -84,16 +84,10 @@ export function areCodexContentPreviewsEnabled(): boolean {
84
84
  }
85
85
 
86
86
  export function getCodexExecutionCwd(): string {
87
- const configured = process.env.CODEX_GLASSES_WORKDIR?.trim()
88
- if (configured) return resolve(configured)
89
- if (COS_SCRIPTS_DIR) return resolve(COS_SCRIPTS_DIR, '..', '..')
90
- // A Starter-Kit COS in the directory the user launched npx from — Codex
91
- // loads its AGENTS.md brain natively when run there.
92
- const brain = cosBrainDir()
93
- if (brain) return brain
94
- // Last resort when neither CODEX_GLASSES_WORKDIR nor COS_SCRIPTS_DIR is set:
95
- // the server's own working dir (codex glasses is an optional, env-configured feature).
96
- return process.cwd()
87
+ return resolveProviderWorkDir({
88
+ legacyProviderDir: process.env.CODEX_GLASSES_WORKDIR,
89
+ scriptsDir: COS_SCRIPTS_DIR,
90
+ })
97
91
  }
98
92
 
99
93
  export function getCodexTrustMode(): CodexTrustMode {
@@ -7,8 +7,9 @@
7
7
  // spawns of claude/codex use IT as their working directory — so the CLIs load
8
8
  // the user's brain exactly as they would in a terminal session in that folder.
9
9
  //
10
- // Precedence stays: COS_SCRIPTS_DIR (full COS pipeline) > detected brain dir >
11
- // process.cwd(). Explicit config always wins.
10
+ // Provider precedence is: selected COS_WORKDIR > legacy provider override >
11
+ // COS_SCRIPTS_DIR repository root > process.cwd(). Pipeline data still uses
12
+ // COS_SCRIPTS_DIR independently.
12
13
  import { existsSync } from 'node:fs'
13
14
  import { join, resolve } from 'node:path'
14
15
 
@@ -33,3 +34,28 @@ export function cosBrainDir(): string | null {
33
34
  cached = resolveCosBrainDir(process.env.COS_WORKDIR ?? process.env.COS_LAUNCH_DIR)
34
35
  return cached
35
36
  }
37
+
38
+ /**
39
+ * Resolve the provider working directory from one authoritative contract.
40
+ * COS_WORKDIR (via cosBrainDir) is the user-selected agent workspace and must
41
+ * outrank legacy provider-specific and pipeline locations. COS_SCRIPTS_DIR is
42
+ * still used by the Python pipeline; it is only a cwd fallback here.
43
+ */
44
+ export function resolveProviderWorkDir(options: {
45
+ legacyProviderDir?: string | null
46
+ scriptsDir?: string | null
47
+ fallback?: string
48
+ } = {}): string {
49
+ const brain = cosBrainDir()
50
+ if (brain) return brain
51
+ const legacy = options.legacyProviderDir?.trim()
52
+ if (legacy) return resolve(legacy)
53
+ const scripts = options.scriptsDir?.trim()
54
+ if (scripts) return resolve(scripts, '..', '..')
55
+ return resolve(options.fallback ?? process.cwd())
56
+ }
57
+
58
+ /** Test-only cache reset for environment-precedence coverage. */
59
+ export function resetCosBrainDirCacheForTests(): void {
60
+ cached = undefined
61
+ }
@@ -436,6 +436,16 @@ export class MaintenanceLifecycle {
436
436
  }
437
437
  }
438
438
 
439
+ /** Verify only the controller-held operation secret. This does not adopt or
440
+ * release a gate; it lets the loopback controller run bounded post-boot
441
+ * proofs while normal admissions remain closed. */
442
+ credentialsValid(credentials: MaintenanceOperationCredentials): boolean {
443
+ this.expireSameBootGateIfPermitted()
444
+ if (!this.gate || this.blockedGateReason) return false
445
+ const proof = this.credentialsMatch(credentials)
446
+ return proof.leaseMatches && proof.operationMatches && proof.nonceMatches
447
+ }
448
+
439
449
  beginDrain(request: MaintenanceDrainRequest): string {
440
450
  this.expireSameBootGateIfPermitted()
441
451
  if (!this.managed()) {
@@ -720,6 +730,12 @@ export function acquireMaintenanceWork(
720
730
  return maintenanceLifecycle.acquire(kind, options)
721
731
  }
722
732
 
733
+ export function maintenanceOperationCredentialsValid(
734
+ credentials: MaintenanceOperationCredentials,
735
+ ): boolean {
736
+ return maintenanceLifecycle.credentialsValid(credentials)
737
+ }
738
+
723
739
  /** Read-only boot/background-worker admission check. */
724
740
  export function maintenanceAdmissionsOpen(): boolean {
725
741
  return maintenanceLifecycle.snapshot().admissionsOpen
@@ -0,0 +1,150 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { cosBrainDir } from './launch-dir.js'
3
+
4
+ export type ProofProvider = 'claude' | 'codex'
5
+
6
+ export interface ProviderProofResult {
7
+ provider: ProofProvider
8
+ ok: boolean
9
+ durationMs: number
10
+ cached: boolean
11
+ error?: string
12
+ }
13
+
14
+ const PROOF_TOKEN = 'COS_CONTROL_OK'
15
+ const PROOF_PROMPT = `This is an automated local readiness check. Do not use tools. Reply with exactly ${PROOF_TOKEN} and nothing else.`
16
+ const successCache = new Map<ProofProvider, ProviderProofResult>()
17
+ const inFlight = new Map<ProofProvider, Promise<ProviderProofResult>>()
18
+
19
+ interface ProcessResult {
20
+ code: number | null
21
+ stdout: string
22
+ stderr: string
23
+ timedOut: boolean
24
+ }
25
+
26
+ function runBounded(
27
+ command: string,
28
+ args: string[],
29
+ input: string,
30
+ timeoutMs = 120_000,
31
+ ): Promise<ProcessResult> {
32
+ return new Promise((resolvePromise) => {
33
+ const env = { ...process.env }
34
+ delete env.CLAUDECODE
35
+ const child = spawn(command, args, {
36
+ cwd: cosBrainDir() ?? process.cwd(),
37
+ env,
38
+ stdio: ['pipe', 'pipe', 'pipe'],
39
+ detached: false,
40
+ })
41
+ let stdout = ''
42
+ let stderr = ''
43
+ let settled = false
44
+ const cap = (value: string) => value.slice(-256_000)
45
+ child.stdout.on('data', chunk => { stdout = cap(stdout + chunk.toString()) })
46
+ child.stderr.on('data', chunk => { stderr = cap(stderr + chunk.toString()) })
47
+ const finish = (result: ProcessResult) => {
48
+ if (settled) return
49
+ settled = true
50
+ clearTimeout(timer)
51
+ resolvePromise(result)
52
+ }
53
+ const timer = setTimeout(() => {
54
+ try { child.kill('SIGKILL') } catch { /* already exited */ }
55
+ finish({ code: null, stdout, stderr, timedOut: true })
56
+ }, timeoutMs)
57
+ timer.unref?.()
58
+ child.once('error', err => finish({ code: null, stdout, stderr: err.message, timedOut: false }))
59
+ child.once('close', code => finish({ code, stdout, stderr, timedOut: false }))
60
+ child.stdin.on('error', () => { /* close/error is authoritative */ })
61
+ child.stdin.end(input)
62
+ })
63
+ }
64
+
65
+ export function claudeProofText(stdout: string): string {
66
+ try {
67
+ const parsed = JSON.parse(stdout) as { result?: unknown }
68
+ return typeof parsed.result === 'string' ? parsed.result.trim() : ''
69
+ } catch {
70
+ return ''
71
+ }
72
+ }
73
+
74
+ export function codexProofText(stdout: string): string {
75
+ const parts: string[] = []
76
+ for (const line of stdout.split('\n')) {
77
+ if (!line.trim()) continue
78
+ try {
79
+ const event = JSON.parse(line) as any
80
+ const item = event?.item ?? event?.payload ?? event?.message ?? event
81
+ const eventType = String(event?.type ?? '').toLowerCase()
82
+ const itemType = String(item?.type ?? '').toLowerCase()
83
+ const assistant = /(?:^|[._-])(agent_message|assistant_message|output_text)(?:$|[._-])/.test(eventType)
84
+ || /^(?:agent_message|assistant_message|output_text)$/.test(itemType)
85
+ if (!assistant) continue
86
+ const text = typeof event?.text === 'string' ? event.text
87
+ : typeof event?.delta === 'string' ? event.delta
88
+ : typeof item?.text === 'string' ? item.text
89
+ : ''
90
+ if (text) parts.push(text)
91
+ } catch { /* ignore non-JSON CLI chatter */ }
92
+ }
93
+ return parts.join('').trim()
94
+ }
95
+
96
+ function safeProofError(result: ProcessResult): string {
97
+ if (result.timedOut) return 'provider proof timed out'
98
+ if (result.code !== 0) return `provider process exited ${result.code ?? 'before launch'}`
99
+ return 'provider returned no valid proof response'
100
+ }
101
+
102
+ async function executeProof(provider: ProofProvider): Promise<ProviderProofResult> {
103
+ const started = Date.now()
104
+ const result = provider === 'claude'
105
+ ? await runBounded('claude', [
106
+ '-p',
107
+ '--output-format', 'json',
108
+ '--permission-mode', 'dontAsk',
109
+ '--tools', '',
110
+ '--allowedTools', '',
111
+ '--system-prompt', PROOF_PROMPT,
112
+ PROOF_PROMPT,
113
+ ], '')
114
+ : await runBounded('codex', [
115
+ 'exec',
116
+ '--sandbox', 'read-only',
117
+ '--skip-git-repo-check',
118
+ '--json',
119
+ '--cd', cosBrainDir() ?? process.cwd(),
120
+ '--ephemeral',
121
+ '-',
122
+ ], PROOF_PROMPT)
123
+ const text = provider === 'claude'
124
+ ? claudeProofText(result.stdout)
125
+ : codexProofText(result.stdout)
126
+ const ok = result.code === 0 && text === PROOF_TOKEN
127
+ return {
128
+ provider,
129
+ ok,
130
+ durationMs: Date.now() - started,
131
+ cached: false,
132
+ ...(ok ? {} : { error: safeProofError(result) }),
133
+ }
134
+ }
135
+
136
+ /** Actual no-tool model turn, cached only after success for this server boot. */
137
+ export async function runProviderProof(provider: ProofProvider): Promise<ProviderProofResult> {
138
+ const cached = successCache.get(provider)
139
+ if (cached) return { ...cached, cached: true }
140
+ const existing = inFlight.get(provider)
141
+ if (existing) return existing
142
+ const operation = executeProof(provider).then(result => {
143
+ if (result.ok) successCache.set(provider, result)
144
+ return result
145
+ }).finally(() => {
146
+ inFlight.delete(provider)
147
+ })
148
+ inFlight.set(provider, operation)
149
+ return operation
150
+ }
@@ -35,6 +35,9 @@ let localVoice: string | null = null
35
35
  let lastFallbackToOpenAI: { at: string; reason: string } | null = null
36
36
  let lastHealthProbeAt = 0
37
37
  let healthProbeInFlight: Promise<boolean> | null = null
38
+ let retryTimer: ReturnType<typeof setTimeout> | null = null
39
+ let retryAttempt = 0
40
+ let stopRequested = false
38
41
 
39
42
  const HEALTH_REFRESH_INTERVAL_MS = 2_000
40
43
  const HEALTH_REFRESH_TIMEOUT_MS = 400
@@ -84,13 +87,54 @@ function candidatePythons(): string[] {
84
87
  return out.filter((p) => p && existsSync(p))
85
88
  }
86
89
 
87
- function resolvePython(): string | null {
88
- const hits = candidatePythons()
89
- return hits[0] ?? null
90
+ /** A path existing is not enough. Upgrades may inherit a Python 3.13 venv or
91
+ * a partial environment whose imports fail. Probe the exact sidecar runtime
92
+ * before bypassing bootstrap. */
93
+ async function ttsPythonReady(path: string): Promise<boolean> {
94
+ const probe = [
95
+ 'import sys',
96
+ 'assert (sys.version_info.major, sys.version_info.minor) in ((3, 11), (3, 12))',
97
+ 'import fastapi, mlx_audio, misaki, numpy, soundfile, uvicorn',
98
+ ].join('; ')
99
+ return await new Promise<boolean>((resolvePromise) => {
100
+ let settled = false
101
+ const child = spawn(path, ['-c', probe], {
102
+ stdio: 'ignore',
103
+ detached: false,
104
+ env: process.env,
105
+ })
106
+ const timer = setTimeout(() => {
107
+ if (settled) return
108
+ settled = true
109
+ try { child.kill('SIGKILL') } catch { /* already exited */ }
110
+ resolvePromise(false)
111
+ }, 15_000)
112
+ timer.unref?.()
113
+ child.once('error', () => {
114
+ if (settled) return
115
+ settled = true
116
+ clearTimeout(timer)
117
+ resolvePromise(false)
118
+ })
119
+ child.once('exit', (code) => {
120
+ if (settled) return
121
+ settled = true
122
+ clearTimeout(timer)
123
+ resolvePromise(code === 0)
124
+ })
125
+ })
126
+ }
127
+
128
+ async function resolveReadyPython(): Promise<string | null> {
129
+ for (const path of candidatePythons()) {
130
+ if (await ttsPythonReady(path)) return path
131
+ console.warn(`[tts-local] existing Python runtime is incompatible or incomplete: ${path}`)
132
+ }
133
+ return null
90
134
  }
91
135
 
92
136
  async function ensureBootstrap(): Promise<string | null> {
93
- let py = resolvePython()
137
+ let py = await resolveReadyPython()
94
138
  if (py) return py
95
139
  if (!existsSync(BOOTSTRAP)) {
96
140
  lastError = `TTS bootstrap missing at ${BOOTSTRAP}`
@@ -119,11 +163,36 @@ async function ensureBootstrap(): Promise<string | null> {
119
163
  console.error('[tts-local]', lastError)
120
164
  return null
121
165
  }
122
- py = resolvePython()
123
- if (!py) lastError = 'TTS bootstrap finished but python still missing'
166
+ py = await resolveReadyPython()
167
+ if (!py) lastError = 'TTS bootstrap finished but its Python runtime is incompatible or incomplete'
124
168
  return py
125
169
  }
126
170
 
171
+ function clearRetryTimer(): void {
172
+ if (!retryTimer) return
173
+ clearTimeout(retryTimer)
174
+ retryTimer = null
175
+ }
176
+
177
+ function scheduleLocalTtsRetry(): void {
178
+ if (stopRequested || retryTimer || serverStarting || serverAvailable) return
179
+ // Retry forever but cap the quiet background cadence at five minutes. This
180
+ // recovers interrupted first-run model downloads and dependencies installed
181
+ // after boot without hot-looping a permanently unsupported setup.
182
+ const delayMs = Math.min(300_000, 10_000 * (2 ** Math.min(retryAttempt, 5)))
183
+ retryAttempt += 1
184
+ console.warn(`[tts-local] retrying Kokoro startup in ${Math.round(delayMs / 1000)}s`)
185
+ retryTimer = setTimeout(() => {
186
+ retryTimer = null
187
+ void startLocalTtsServer().catch((err) => {
188
+ lastError = err instanceof Error ? err.message : String(err)
189
+ console.error('[tts-local] retry failed:', lastError)
190
+ scheduleLocalTtsRetry()
191
+ })
192
+ }, delayMs)
193
+ retryTimer.unref?.()
194
+ }
195
+
127
196
  async function probeHealth(timeoutMs = 1500): Promise<boolean> {
128
197
  try {
129
198
  const res = await fetch(`${TTS_BASE}/health`, {
@@ -227,6 +296,8 @@ export async function startLocalTtsServer(): Promise<void> {
227
296
  console.warn('[tts-local]', lastError)
228
297
  return
229
298
  }
299
+ stopRequested = false
300
+ clearRetryTimer()
230
301
  serverStarting = true
231
302
  try {
232
303
  if (await probeHealth(1500)) {
@@ -268,19 +339,24 @@ export async function startLocalTtsServer(): Promise<void> {
268
339
  },
269
340
  )
270
341
  serverProcess = child
342
+ let childExited = false
271
343
  child.on('exit', (code, signal) => {
344
+ childExited = true
272
345
  if (serverProcess === child) {
273
346
  serverProcess = null
274
347
  serverAvailable = false
275
348
  lastError = `sidecar exited code=${code} signal=${signal}`
276
349
  console.warn('[tts-local]', lastError)
350
+ scheduleLocalTtsRetry()
277
351
  }
278
352
  })
279
353
 
280
354
  const maxWaitMs = 120_000
281
355
  const started = Date.now()
282
- while (Date.now() - started < maxWaitMs) {
356
+ while (Date.now() - started < maxWaitMs && !childExited) {
283
357
  if (await probeHealth(1500)) {
358
+ retryAttempt = 0
359
+ clearRetryTimer()
284
360
  console.log(
285
361
  `[tts-local] ready on ${TTS_PORT} engine=${engineVersion} voice=${localVoice} ` +
286
362
  `(${((Date.now() - started) / 1000).toFixed(1)}s)`,
@@ -289,17 +365,22 @@ export async function startLocalTtsServer(): Promise<void> {
289
365
  }
290
366
  await new Promise((r) => setTimeout(r, 1500))
291
367
  }
292
- lastError = `sidecar startup timeout (${maxWaitMs / 1000}s)`
368
+ lastError = childExited
369
+ ? lastError || 'sidecar exited before becoming ready'
370
+ : `sidecar startup timeout (${maxWaitMs / 1000}s)`
293
371
  console.error('[tts-local]', lastError)
294
372
  try { child.kill('SIGKILL') } catch { /* ignore */ }
295
373
  serverProcess = null
296
374
  serverAvailable = false
297
375
  } finally {
298
376
  serverStarting = false
377
+ if (!serverAvailable) scheduleLocalTtsRetry()
299
378
  }
300
379
  }
301
380
 
302
381
  export function stopLocalTtsServer(): void {
382
+ stopRequested = true
383
+ clearRetryTimer()
303
384
  if (!serverProcess) return
304
385
  try {
305
386
  serverProcess.kill('SIGTERM')
@@ -0,0 +1,19 @@
1
+ import { Router } from 'express'
2
+ import { runProviderProof, type ProofProvider } from '../lib/provider-proof.js'
3
+
4
+ export const providerProofRouter = Router()
5
+
6
+ // Authenticated by the global /api token boundary. This performs one real,
7
+ // no-tool model turn and exposes no provider output or credentials.
8
+ providerProofRouter.post('/diagnostics/provider-proof', async (req, res) => {
9
+ const address = req.socket.remoteAddress ?? ''
10
+ const loopback = address === '::1' || address === '127.0.0.1'
11
+ || address.startsWith('127.') || address.startsWith('::ffff:127.')
12
+ if (!loopback) return res.status(403).json({ error: 'loopback_required' })
13
+ const provider = req.body?.provider
14
+ if (provider !== 'claude' && provider !== 'codex') {
15
+ return res.status(400).json({ error: 'provider must be claude or codex' })
16
+ }
17
+ const result = await runProviderProof(provider as ProofProvider)
18
+ return res.status(result.ok ? 200 : 503).json(result)
19
+ })