@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 +6 -0
- package/CHANGELOG.md +26 -0
- package/README.md +9 -3
- package/bin/cli.cjs +20 -7
- package/package.json +1 -1
- package/server/index.ts +32 -20
- package/server/lib/api-auth.ts +34 -0
- package/server/lib/claude-bridge.ts +21 -11
- package/server/lib/claude-tool-access.ts +56 -0
- package/server/lib/maintenance-lifecycle.ts +16 -0
- package/server/lib/provider-proof.ts +150 -0
- package/server/lib/tts-cache.ts +4 -3
- package/server/lib/tts-local.ts +89 -8
- package/server/routes/provider-proof.ts +19 -0
- package/server/routes/tts.ts +5 -3
- package/server/tts-sidecar/bootstrap.sh +39 -15
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.
|
|
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
|
|
@@ -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.
|
|
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
|
|
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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
|
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
|
|
515
|
+
// Full and lightweight paths share the same explicit MCP selector contract.
|
|
501
516
|
if (options?.lightweight) {
|
|
502
|
-
|
|
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
|
+
}
|
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/lib/tts-local.ts
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 =
|
|
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 =
|
|
123
|
-
if (!py) lastError = 'TTS bootstrap finished but
|
|
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 =
|
|
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
|
+
})
|
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"
|