@gotcos/glasses-server 6.15.0 → 6.15.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,3 +1,16 @@
1
+ ## 6.15.1
2
+
3
+ - Fix prepared TTS playback for native audio clients by allowing only
4
+ `GET`/`HEAD /api/tts/play/<UUID>` through the global API-token boundary. The
5
+ authenticated prepare route mints a random, audio-scoped capability that
6
+ expires after 60 seconds; all other TTS routes remain token-protected.
7
+ - Fix Kokoro first-run provisioning by selecting only Python 3.11 or 3.12,
8
+ the actual compatibility intersection of the pinned `numpy` and `misaki`
9
+ dependencies. Python 3.13 is no longer advertised or selected.
10
+ - Reject an incompatible `COS_TTS_BOOTSTRAP_PYTHON` before installation and
11
+ automatically rebuild stale or partial TTS virtual environments instead of
12
+ repeatedly failing inside pip.
13
+
1
14
  ## 6.15.0
2
15
 
3
16
  - 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
@@ -166,9 +166,11 @@ BIND_HOST=0.0.0.0 npm run start:server
166
166
  keeps compatible prompt/meeting audio available for retry instead of silently
167
167
  sending it to OpenAI.
168
168
  - *Local spoken replies unavailable?* — on Apple silicon, install
169
- `python@3.13 ffmpeg espeak-ng`, restart the server, and wait for the
169
+ `python@3.12 ffmpeg espeak-ng`, restart the server, and wait for the
170
170
  first-run Kokoro model download. Confirm `/api/health` reports
171
171
  `tts_local.ready: true`.
172
+ If Python lives outside the normal Homebrew paths, set its absolute 3.11 or
173
+ 3.12 path as `COS_TTS_BOOTSTRAP_PYTHON` in `~/.cos-glasses/.env`.
172
174
  Selecting Local never falls back to cloud; set `COS_TTS_ENGINE=openai_primary`
173
175
  only when OpenAI playback is intentionally configured.
174
176
  - *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.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
@@ -66,7 +66,7 @@ import {
66
66
  isAllowedNetworkOrigin,
67
67
  isTailscaleIpv4,
68
68
  } from './lib/network-policy.js'
69
- import { timingSafeTokenEqual } from './lib/token-auth.js'
69
+ import { requireApiToken } from './lib/api-auth.js'
70
70
  import { isManagedRuntime } from './lib/managed-runtime.js'
71
71
  import {
72
72
  acquireMaintenanceWork,
@@ -135,24 +135,12 @@ app.use(cors({
135
135
  cb(new Error('CORS blocked'))
136
136
  },
137
137
  }))
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
- })
138
+ // Auth middleware — always active (token is auto-generated if not set).
139
+ // Mounted before body parsers so rejected uploads cannot consume parse memory.
140
+ // The only capability-URL exception is a canonical /tts/play/<UUID> GET/HEAD;
141
+ // authenticated /tts/prepare mints it for native audio players that cannot set
142
+ // X-Cos-Token headers.
143
+ app.use('/api', requireApiToken(API_TOKEN))
156
144
 
157
145
  // Fail-closed catch-all for mutation routes that do not own a more specific
158
146
  // lifecycle lease below. This closes the admission/drain race for secondary
@@ -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
+ }
@@ -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 })
@@ -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"