@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 +13 -0
- package/README.md +5 -3
- package/bin/cli.cjs +20 -7
- package/package.json +1 -1
- package/server/index.ts +7 -19
- package/server/lib/api-auth.ts +34 -0
- package/server/lib/tts-cache.ts +4 -3
- package/server/routes/tts.ts +5 -3
- package/server/tts-sidecar/bootstrap.sh +39 -15
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.
|
|
49
|
-
spoken replies on Apple silicon (Python 3.11-3.
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
|
192
|
-
console.log(' Install: ' + bold('brew install python@3.
|
|
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
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 {
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
}
|
package/server/lib/tts-cache.ts
CHANGED
|
@@ -495,9 +495,10 @@ function sweepStaleByAge(): void {
|
|
|
495
495
|
}
|
|
496
496
|
}
|
|
497
497
|
|
|
498
|
-
/** Allocate a new session UUID pointing at
|
|
499
|
-
* bundle. The play route
|
|
500
|
-
*
|
|
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 })
|
package/server/routes/tts.ts
CHANGED
|
@@ -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
|
|
814
|
-
//
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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"
|