@gotcos/glasses-server 6.15.0 → 6.15.2

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,29 @@
1
+ ## 6.15.2
2
+
3
+ - Validate the inherited Kokoro Python runtime before skipping bootstrap, so a
4
+ stale Python 3.13 or partial venv is repaired during a normal managed update.
5
+ - Retry failed Kokoro cold starts with bounded exponential backoff instead of
6
+ latching local speech unavailable until the whole server restarts.
7
+ - Add explicit MCP selector/config support to both Claude query paths and tell
8
+ the model the exact permission selectors without fabricating connector
9
+ health, authentication, or handshake machinery.
10
+ - Add an authenticated, boot-cached transactional provider proof for COS
11
+ Control. It performs a real no-tool model turn and exposes no provider output
12
+ or credentials.
13
+
14
+ ## 6.15.1
15
+
16
+ - Fix prepared TTS playback for native audio clients by allowing only
17
+ `GET`/`HEAD /api/tts/play/<UUID>` through the global API-token boundary. The
18
+ authenticated prepare route mints a random, audio-scoped capability that
19
+ expires after 60 seconds; all other TTS routes remain token-protected.
20
+ - Fix Kokoro first-run provisioning by selecting only Python 3.11 or 3.12,
21
+ the actual compatibility intersection of the pinned `numpy` and `misaki`
22
+ dependencies. Python 3.13 is no longer advertised or selected.
23
+ - Reject an incompatible `COS_TTS_BOOTSTRAP_PYTHON` before installation and
24
+ automatically rebuild stale or partial TTS virtual environments instead of
25
+ repeatedly failing inside pip.
26
+
1
27
  ## 6.15.0
2
28
 
3
29
  - Add local-first spoken reply playback through a Mac-owned Kokoro sidecar on
package/README.md CHANGED
@@ -45,8 +45,8 @@ without silently losing completed replies.
45
45
  _or_ **Codex CLI** (GPT Frontier/Balanced) — https://developers.openai.com/codex/, then `codex login`
46
46
  - **Even G2 glasses** + the **COS Glasses** app from the Even Hub
47
47
  - `brew install whisper-cpp` for free local voice (the launcher can download the model)
48
- - _Optional:_ `brew install python@3.13 ffmpeg espeak-ng` for local Kokoro
49
- spoken replies on Apple silicon (Python 3.11-3.13 is supported). `ffmpeg`
48
+ - _Optional:_ `brew install python@3.12 ffmpeg espeak-ng` for local Kokoro
49
+ spoken replies on Apple silicon (Python 3.11-3.12 is supported). `ffmpeg`
50
50
  also enables phone/output image attachments; text chat remains available
51
51
  without these optional dependencies.
52
52
  - _Optional:_ **Tailscale** so your phone reaches your Mac from anywhere
@@ -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
@@ -166,9 +170,11 @@ BIND_HOST=0.0.0.0 npm run start:server
166
170
  keeps compatible prompt/meeting audio available for retry instead of silently
167
171
  sending it to OpenAI.
168
172
  - *Local spoken replies unavailable?* — on Apple silicon, install
169
- `python@3.13 ffmpeg espeak-ng`, restart the server, and wait for the
173
+ `python@3.12 ffmpeg espeak-ng`, restart the server, and wait for the
170
174
  first-run Kokoro model download. Confirm `/api/health` reports
171
175
  `tts_local.ready: true`.
176
+ If Python lives outside the normal Homebrew paths, set its absolute 3.11 or
177
+ 3.12 path as `COS_TTS_BOOTSTRAP_PYTHON` in `~/.cos-glasses/.env`.
172
178
  Selecting Local never falls back to cloud; set `COS_TTS_ENGINE=openai_primary`
173
179
  only when OpenAI playback is intentionally configured.
174
180
  - *Photos unavailable?* — install `ffmpeg`, restart the server, and confirm `/api/health` reports `features.mediaProcessingReady: true`.
package/bin/cli.cjs CHANGED
@@ -4,7 +4,7 @@
4
4
  // Runs the bundled server for Even G2 smart glasses. The server ships INSIDE this
5
5
  // package — there is no clone. Config persists at ~/.cos-glasses/.
6
6
 
7
- const { execSync, spawn } = require('child_process')
7
+ const { execFileSync, execSync, spawn } = require('child_process')
8
8
  const {
9
9
  existsSync,
10
10
  mkdirSync,
@@ -47,7 +47,7 @@ if (process.argv.includes('--help') || process.argv.includes('-h')) {
47
47
  console.log(' - Node.js 20.11+')
48
48
  console.log(' - Claude Code CLI (not Claude Desktop) or Codex CLI')
49
49
  console.log(' - Even G2 smart glasses + the COS Glasses app (Even Hub)')
50
- console.log(' - Optional local Kokoro voice: Apple silicon + Python 3.11-3.13')
50
+ console.log(' - Optional local Kokoro voice: Apple silicon + Python 3.11 or 3.12')
51
51
  console.log('')
52
52
  console.log(' No API key is needed for chat — it runs through your installed CLI.')
53
53
  console.log(' Config persists at ~/.cos-glasses/.env')
@@ -79,6 +79,21 @@ function getCliVersion(command, versionArg = '--version') {
79
79
  return null
80
80
  }
81
81
  }
82
+ function compatibleKokoroPython() {
83
+ const configured = process.env.COS_TTS_BOOTSTRAP_PYTHON?.trim()
84
+ const candidates = configured ? [configured] : ['python3.12', 'python3.11', 'python3']
85
+ for (const command of candidates) {
86
+ let version = null
87
+ try {
88
+ version = execFileSync(command, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 }).trim()
89
+ } catch { /* try the next interpreter */ }
90
+ const match = version?.match(/^Python\s+(\d+)\.(\d+)/i)
91
+ if (match && Number(match[1]) === 3 && [11, 12].includes(Number(match[2]))) {
92
+ return { command, version }
93
+ }
94
+ }
95
+ return null
96
+ }
82
97
  function normalizeCodexVersion(raw) {
83
98
  if (!raw) return 'available'
84
99
  const line = raw.split('\n').map((s) => s.trim()).find((s) => /^codex(?:-cli)?\s+/i.test(s)) || raw.split('\n')[0].trim()
@@ -182,14 +197,12 @@ try {
182
197
  // listener. An invoked agent CLI may still maintain its own user cache.
183
198
  if (process.argv.includes('--prepare-only')) {
184
199
  if (process.platform === 'darwin' && process.arch === 'arm64') {
185
- const python = ['python3.13', 'python3.12', 'python3.11']
186
- .map((command) => ({ command, version: getCliVersion(command, '--version') }))
187
- .find((candidate) => candidate.version)
200
+ const python = compatibleKokoroPython()
188
201
  if (python) {
189
202
  console.log(green(' ✓') + ` Local voice Python ${python.version.replace(/^Python\s+/i, '')}`)
190
203
  } else {
191
- console.log(yellow(' ⚠') + ' Local Kokoro voice needs Python 3.11-3.13')
192
- console.log(' Install: ' + bold('brew install python@3.13 ffmpeg espeak-ng'))
204
+ console.log(yellow(' ⚠') + ' Local Kokoro voice needs Python 3.11 or 3.12')
205
+ console.log(' Install: ' + bold('brew install python@3.12 ffmpeg espeak-ng'))
193
206
  }
194
207
  }
195
208
  console.log('')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.15.0",
3
+ "version": "6.15.2",
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'
@@ -66,10 +67,11 @@ import {
66
67
  isAllowedNetworkOrigin,
67
68
  isTailscaleIpv4,
68
69
  } from './lib/network-policy.js'
69
- import { timingSafeTokenEqual } from './lib/token-auth.js'
70
+ 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,
@@ -135,24 +137,12 @@ app.use(cors({
135
137
  cb(new Error('CORS blocked'))
136
138
  },
137
139
  }))
138
- // Auth middleware — always active (token is auto-generated if not set)
139
- app.use('/api', (req, res, next) => {
140
- // Allow health checks, display stream, and client diagnostics without auth.
141
- // Diagnostics are whitelisted because the client needs to report crashes
142
- // that may happen before the wizard has supplied an API token, and
143
- // enforcing auth on a debug telemetry endpoint adds risk during the exact
144
- // boot window we're trying to observe.
145
- if (
146
- req.path === '/health' ||
147
- req.path === '/display-stream' ||
148
- req.path === '/diag/client' ||
149
- req.path === '/diag/health'
150
- ) return next()
151
- if (!timingSafeTokenEqual(req.headers['x-cos-token'], API_TOKEN)) {
152
- return res.status(401).json({ error: 'unauthorized' })
153
- }
154
- next()
155
- })
140
+ // Auth middleware — always active (token is auto-generated if not set).
141
+ // Mounted before body parsers so rejected uploads cannot consume parse memory.
142
+ // The only capability-URL exception is a canonical /tts/play/<UUID> GET/HEAD;
143
+ // authenticated /tts/prepare mints it for native audio players that cannot set
144
+ // X-Cos-Token headers.
145
+ app.use('/api', requireApiToken(API_TOKEN))
156
146
 
157
147
  // Fail-closed catch-all for mutation routes that do not own a more specific
158
148
  // lifecycle lease below. This closes the admission/drain race for secondary
@@ -170,8 +160,29 @@ app.use('/api', (req, res, next) => {
170
160
  || req.path.startsWith('/maintenance/drain')
171
161
  if (lifecycleOwned) return next()
172
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
+
173
182
  try {
174
- const lease = acquireMaintenanceWork('api_mutation')
183
+ const lease = acquireMaintenanceWork('api_mutation', {
184
+ allowDuringDrain: controllerProof,
185
+ })
175
186
  let released = false
176
187
  const release = () => {
177
188
  if (released) return
@@ -208,6 +219,7 @@ app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
208
219
  prepareAdmission: preparePublicDurableQueryAdmission,
209
220
  }))
210
221
  app.use('/api', queryRouter)
222
+ app.use('/api', providerProofRouter)
211
223
  app.use('/api', transcribeRouter)
212
224
  app.use('/api', displayRouter)
213
225
  app.use('/api', transcribeStreamRouter)
@@ -0,0 +1,34 @@
1
+ import type { RequestHandler } from 'express'
2
+ import { timingSafeTokenEqual } from './token-auth.js'
3
+
4
+ // Recovery/setup clients need these availability surfaces before they have a
5
+ // usable token. Keep private provider state and every mutation route out.
6
+ const PUBLIC_API_PATHS = new Set([
7
+ '/health',
8
+ '/display-stream',
9
+ '/diag/client',
10
+ '/diag/health',
11
+ ])
12
+
13
+ // Native HTML audio requests cannot attach X-Cos-Token. The UUID minted by
14
+ // authenticated POST /tts/prepare is therefore a short-lived bearer
15
+ // capability. Keep this exception exact: GET/HEAD only, one canonical v4 UUID
16
+ // path segment, and no query-token fallback that could leak into URL logs.
17
+ const TTS_PLAYBACK_CAPABILITY_PATH = /^\/tts\/play\/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
18
+
19
+ export function isPublicApiRequest(method: string, path: string): boolean {
20
+ if (PUBLIC_API_PATHS.has(path)) return true
21
+ return (method === 'GET' || method === 'HEAD')
22
+ && TTS_PLAYBACK_CAPABILITY_PATH.test(path)
23
+ }
24
+
25
+ /** Global /api authentication boundary. Mount before all body parsers. */
26
+ export function requireApiToken(apiToken: string): RequestHandler {
27
+ return (req, res, next) => {
28
+ if (isPublicApiRequest(req.method, req.path)) return next()
29
+ if (!timingSafeTokenEqual(req.headers['x-cos-token'], apiToken)) {
30
+ return res.status(401).json({ error: 'unauthorized' })
31
+ }
32
+ next()
33
+ }
34
+ }
@@ -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
 
@@ -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
  }
@@ -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
+ }
@@ -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
+ }
@@ -495,9 +495,10 @@ function sweepStaleByAge(): void {
495
495
  }
496
496
  }
497
497
 
498
- /** Allocate a new session UUID pointing at a (hash, text, voice, format)
499
- * bundle. The play route consumes the session; expired sessions are reaped
500
- * by the periodic sweeper below. */
498
+ /** Allocate a new random v4 session UUID pointing at one
499
+ * (hash, text, voice, format) bundle. The play route may reread it for native
500
+ * Range refills during the 60-second TTL; expired sessions are rejected and
501
+ * reaped by the periodic sweeper below. */
501
502
  export function createSession(s: Omit<SessionEntry, 'expiresAt'>): string {
502
503
  const uuid = randomUUID()
503
504
  sessions.set(uuid, { ...s, expiresAt: Date.now() + SESSION_TTL_MS })
@@ -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
+ })
@@ -810,12 +810,14 @@ ttsRouter.post('/tts/stream', async (req, res) => {
810
810
  // 1. Client POSTs {text, voice, format} here. We strip+trim+budget-check,
811
811
  // hash the (text, voice, format) tuple, and return a session URL.
812
812
  // 2. Client sets audio.src = `${apiBase}${sessionUrl}` and calls .play().
813
- // 3. The browser GETs /api/tts/play/:session, which consumes the session
814
- // and either serves cached bytes (instant) or kicks off OpenAI fresh.
813
+ // 3. The browser GETs /api/tts/play/:session using the session as a bearer
814
+ // capability. Range refills may reuse it during its 60-second lifetime;
815
+ // the route serves cached bytes or starts live generation on a cold miss.
815
816
  //
816
817
  // The two-step pattern is required because authentication on the play route
817
818
  // would force XHR (no Range support, no progressive decoding). The session
818
- // UUID IS the auth — short-lived (60s) and one-shot.
819
+ // UUID IS the auth — cryptographically random, short-lived (60s), and scoped
820
+ // to one prepared audio item. It is re-readable only for native Range refills.
819
821
  ttsRouter.post('/tts/prepare', async (req, res) => {
820
822
  try {
821
823
  const { text, format, instructions, fast } = req.body ?? {}
@@ -5,25 +5,49 @@ ROOT="$(cd "$(dirname "$0")" && pwd)"
5
5
  MODEL_DIR="${COS_TTS_MODEL_DIR:-$HOME/.local/share/cos-tts-models}"
6
6
  VENV="$MODEL_DIR/.venv"
7
7
  PY="${COS_TTS_BOOTSTRAP_PYTHON:-}"
8
- if [[ -z "$PY" ]]; then
9
- if command -v python3.13 >/dev/null 2>&1; then PY="$(command -v python3.13)"
10
- elif command -v python3.12 >/dev/null 2>&1; then PY="$(command -v python3.12)"
11
- elif command -v python3.11 >/dev/null 2>&1; then PY="$(command -v python3.11)"
12
- elif command -v python3 >/dev/null 2>&1; then PY="$(command -v python3)"
13
- else
14
- echo "[cos-tts] Python 3.11-3.13 is required for local Kokoro" >&2
8
+
9
+ # The pinned stack has one strict intersection: numpy 2.4.6 requires 3.11+ and
10
+ # misaki 0.9.4 requires <3.13. Select by the interpreter's real version instead
11
+ # of trusting its filename so a newer `python3` alias cannot poison bootstrap.
12
+ python_minor() {
13
+ "$1" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null
14
+ }
15
+
16
+ python_is_compatible() {
17
+ local minor
18
+ minor="$(python_minor "$1")" || return 1
19
+ [[ "$minor" == "3.11" || "$minor" == "3.12" ]]
20
+ }
21
+
22
+ if [[ -n "$PY" ]]; then
23
+ if [[ ! -x "$PY" ]] || ! python_is_compatible "$PY"; then
24
+ PY_MINOR="$(python_minor "$PY" 2>/dev/null || echo unknown)"
25
+ echo "[cos-tts] COS_TTS_BOOTSTRAP_PYTHON is Python $PY_MINOR; pinned Kokoro requires Python 3.11 or 3.12" >&2
15
26
  exit 2
16
27
  fi
17
- fi
18
- PY_MINOR="$($PY -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
19
- case "$PY_MINOR" in
20
- 3.11|3.12|3.13) ;;
21
- *)
22
- echo "[cos-tts] unsupported Python $PY_MINOR; install Python 3.11, 3.12, or 3.13" >&2
28
+ else
29
+ for candidate in python3.12 python3.11 python3; do
30
+ candidate_path="$(command -v "$candidate" 2>/dev/null || true)"
31
+ if [[ -n "$candidate_path" ]] && python_is_compatible "$candidate_path"; then
32
+ PY="$candidate_path"
33
+ break
34
+ fi
35
+ done
36
+ if [[ -z "$PY" ]]; then
37
+ echo "[cos-tts] compatible Python not found; install Python 3.12 with: brew install python@3.12" >&2
23
38
  exit 2
24
- ;;
25
- esac
39
+ fi
40
+ fi
41
+
26
42
  mkdir -p "$MODEL_DIR"
43
+ if [[ -x "$VENV/bin/python" ]] && ! python_is_compatible "$VENV/bin/python"; then
44
+ VENV_MINOR="$(python_minor "$VENV/bin/python" 2>/dev/null || echo unknown)"
45
+ echo "[cos-tts] rebuilding incompatible Python $VENV_MINOR venv"
46
+ rm -rf -- "$VENV"
47
+ elif [[ -d "$VENV" && ! -x "$VENV/bin/python" ]]; then
48
+ echo "[cos-tts] rebuilding incomplete venv"
49
+ rm -rf -- "$VENV"
50
+ fi
27
51
  if [[ ! -x "$VENV/bin/python" ]]; then
28
52
  echo "[cos-tts] creating venv at $VENV with $PY"
29
53
  "$PY" -m venv "$VENV"