@gotcos/glasses-server 6.15.1 → 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 +13 -0
- package/README.md +4 -0
- package/package.json +1 -1
- package/server/index.ts +25 -1
- 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-local.ts +89 -8
- package/server/routes/provider-proof.ts +19 -0
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,16 @@
|
|
|
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
|
+
|
|
1
14
|
## 6.15.1
|
|
2
15
|
|
|
3
16
|
- Fix prepared TTS playback for native audio clients by allowing only
|
package/README.md
CHANGED
|
@@ -125,6 +125,10 @@ optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
|
|
|
125
125
|
`COS_TTS_KOKORO_VOICE` (local voice id),
|
|
126
126
|
`COS_TTS_LOCAL_DISABLE=1` (disable the sidecar), and
|
|
127
127
|
`COS_TTS_PRONUNCIATIONS_JSON` (optional local/cloud pronunciation overrides),
|
|
128
|
+
`COS_EXTRA_TOOLS` (comma-separated `mcp__server__tool` or
|
|
129
|
+
`mcp__server__*` selectors shared by full and lightweight Claude paths),
|
|
130
|
+
`COS_CLAUDE_MCP_CONFIG` (optional absolute config path when `.mcp.json` is not
|
|
131
|
+
in the managed CLI working directory),
|
|
128
132
|
`COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=1` (build 204+
|
|
129
133
|
server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
|
|
130
134
|
location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
|
package/package.json
CHANGED
package/server/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { randomBytes } from 'node:crypto'
|
|
|
15
15
|
import { healthRouter } from './routes/health.js'
|
|
16
16
|
import { diagRouter } from './routes/diag.js'
|
|
17
17
|
import { queryRouter } from './routes/query.js'
|
|
18
|
+
import { providerProofRouter } from './routes/provider-proof.js'
|
|
18
19
|
import { transcribeRouter } from './routes/transcribe.js'
|
|
19
20
|
import { displayRouter } from './routes/display.js'
|
|
20
21
|
import { transcribeStreamRouter } from './routes/transcribe-stream.js'
|
|
@@ -70,6 +71,7 @@ import { requireApiToken } from './lib/api-auth.js'
|
|
|
70
71
|
import { isManagedRuntime } from './lib/managed-runtime.js'
|
|
71
72
|
import {
|
|
72
73
|
acquireMaintenanceWork,
|
|
74
|
+
maintenanceOperationCredentialsValid,
|
|
73
75
|
MaintenanceLifecycleError,
|
|
74
76
|
maintenanceAdmissionsOpen,
|
|
75
77
|
maintenanceErrorPayload,
|
|
@@ -158,8 +160,29 @@ app.use('/api', (req, res, next) => {
|
|
|
158
160
|
|| req.path.startsWith('/maintenance/drain')
|
|
159
161
|
if (lifecycleOwned) return next()
|
|
160
162
|
|
|
163
|
+
// COS Control must prove the candidate while a committed cross-boot gate is
|
|
164
|
+
// still closed. Permit only its two bounded loopback proofs, and only with
|
|
165
|
+
// the controller-held operation receipt. Normal phone/LAN admissions stay
|
|
166
|
+
// closed throughout the update.
|
|
167
|
+
const address = req.socket.remoteAddress ?? ''
|
|
168
|
+
const loopback = address === '::1' || address === '127.0.0.1'
|
|
169
|
+
|| address.startsWith('127.') || address.startsWith('::ffff:127.')
|
|
170
|
+
const controllerProofPath = req.path === '/diagnostics/provider-proof'
|
|
171
|
+
|| req.path === '/tts/prepare'
|
|
172
|
+
const controllerProof = loopback && controllerProofPath
|
|
173
|
+
&& maintenanceOperationCredentialsValid({
|
|
174
|
+
leaseId: typeof req.headers['x-cos-maintenance-lease'] === 'string'
|
|
175
|
+
? req.headers['x-cos-maintenance-lease'] : undefined,
|
|
176
|
+
operationId: typeof req.headers['x-cos-maintenance-operation'] === 'string'
|
|
177
|
+
? req.headers['x-cos-maintenance-operation'] : undefined,
|
|
178
|
+
nonce: typeof req.headers['x-cos-maintenance-nonce'] === 'string'
|
|
179
|
+
? req.headers['x-cos-maintenance-nonce'] : undefined,
|
|
180
|
+
})
|
|
181
|
+
|
|
161
182
|
try {
|
|
162
|
-
const lease = acquireMaintenanceWork('api_mutation'
|
|
183
|
+
const lease = acquireMaintenanceWork('api_mutation', {
|
|
184
|
+
allowDuringDrain: controllerProof,
|
|
185
|
+
})
|
|
163
186
|
let released = false
|
|
164
187
|
const release = () => {
|
|
165
188
|
if (released) return
|
|
@@ -196,6 +219,7 @@ app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
|
|
|
196
219
|
prepareAdmission: preparePublicDurableQueryAdmission,
|
|
197
220
|
}))
|
|
198
221
|
app.use('/api', queryRouter)
|
|
222
|
+
app.use('/api', providerProofRouter)
|
|
199
223
|
app.use('/api', transcribeRouter)
|
|
200
224
|
app.use('/api', displayRouter)
|
|
201
225
|
app.use('/api', transcribeStreamRouter)
|
|
@@ -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-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
|
+
})
|