@gotcos/glasses-server 6.73.14 → 6.75.0

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.
@@ -0,0 +1,262 @@
1
+ // 6.74.0: trust for a fluidaudiocli that COS Control ships signed (its Contents/Helpers).
2
+ //
3
+ // FluidAudio publishes no binary for v0.17.4, so COS Control builds it from the pinned
4
+ // source tag and signs it with the COS Developer ID. That build cannot match the SHA-256
5
+ // pin (parakeet-manifest.ts) because the signature is part of the bytes, so it is trusted
6
+ // on three proofs instead, all required:
7
+ //
8
+ // 1. `codesign --verify --strict` passes, and passes again against the requirement
9
+ // that the leaf certificate chains to Apple and names the COS team (a self-signed
10
+ // certificate can claim any team name; `anchor apple generic` rules that out).
11
+ // 2. The TeamIdentifier codesign reports is COS_TEAM_ID.
12
+ // 3. The codesign Identifier, and the CFBundleIdentifier of the Info.plist embedded in
13
+ // the binary, are both CLI_IDENTIFIER.
14
+ // 4. That embedded Info.plist is bound by the signature (`Info.plist entries=`) and its
15
+ // CFBundleShortVersionString is 0.17.4. The upstream 0.17.4 CLI has no version
16
+ // command, so the binary is never run to ask.
17
+ //
18
+ // The hand-placed SHA-256 pinned copy (parakeet.ts isPinnedCli) is unchanged.
19
+
20
+ import { execFile } from 'node:child_process'
21
+ import { createHash } from 'node:crypto'
22
+ import { constants as fsConstants, createReadStream, fstatSync, openSync, readSync, closeSync, statSync } from 'node:fs'
23
+ import { homedir } from 'node:os'
24
+ import { basename, join } from 'node:path'
25
+ import { pipeline } from 'node:stream/promises'
26
+ import { Transform } from 'node:stream'
27
+ import { PARAKEET_CLI } from './parakeet-manifest.js'
28
+
29
+ /** The COS Developer ID team (read from /Applications/COS Control.app with codesign -dv, 2026-10-16). */
30
+ export const COS_TEAM_ID = 'NV3X46LLCR'
31
+ export const CODESIGN = '/usr/bin/codesign'
32
+ export const TRUST_REQUIREMENT = `=anchor apple generic and certificate leaf[subject.OU] = "${COS_TEAM_ID}"`
33
+ /** The codesign identifier and embedded CFBundleIdentifier of COS Control's build (agreed with Control). */
34
+ export const CLI_IDENTIFIER = 'com.gotcos.control.fluidaudiocli'
35
+
36
+ export type CliTrustReason = 'missing' | 'unsigned' | 'signature_invalid' | 'wrong_team' | 'wrong_identifier' | 'wrong_version'
37
+
38
+ export type CliTrust =
39
+ | { trusted: true; trust: 'developer-id'; path: string; teamId: string; version: string; versionSource: 'info-plist'; sha256: string; size: number }
40
+ | { trusted: false; path: string; reason: CliTrustReason; detail: string }
41
+
42
+ export interface CliTrustDeps {
43
+ /** codesign -dv: what it prints (stderr), and its exit code. */
44
+ display: (path: string) => Promise<{ code: number; output: string }>
45
+ /** codesign --verify --strict [-R requirement]. */
46
+ verify: (path: string, requirement?: string) => Promise<{ code: number; output: string }>
47
+ /** The Info.plist embedded in the Mach-O, or null. */
48
+ embeddedPlist: (path: string) => string | null
49
+ sha256: (path: string) => Promise<string>
50
+ }
51
+
52
+ function run(command: string, args: string[], timeoutMs: number): Promise<{ code: number; output: string }> {
53
+ return new Promise(resolve => {
54
+ execFile(command, args, { timeout: timeoutMs, maxBuffer: 256 * 1024, env: { PATH: '/usr/bin:/bin' } }, (error, stdout, stderr) => {
55
+ const code = error ? (typeof (error as { code?: unknown }).code === 'number' ? (error as { code: number }).code : 1) : 0
56
+ resolve({ code, output: `${stdout ?? ''}${stderr ?? ''}` })
57
+ })
58
+ })
59
+ }
60
+
61
+ async function sha256File(path: string): Promise<string> {
62
+ const hash = createHash('sha256')
63
+ await pipeline(createReadStream(path), new Transform({ transform(chunk, _e, cb) { hash.update(chunk); cb(null) } }))
64
+ return hash.digest('hex')
65
+ }
66
+
67
+ export const defaultCliTrustDeps: CliTrustDeps = {
68
+ display: path => run(CODESIGN, ['-dv', path], 10_000),
69
+ verify: (path, requirement) => run(CODESIGN, ['--verify', '--strict', ...(requirement ? ['-R', requirement] : []), path], 15_000),
70
+ embeddedPlist: path => readEmbeddedInfoPlist(path),
71
+ sha256: sha256File,
72
+ }
73
+
74
+ // ── Mach-O __TEXT,__info_plist ────────────────────────────────────────────────
75
+
76
+ const MH_MAGIC_64 = 0xfeedfacf
77
+ const FAT_MAGIC = 0xcafebabe
78
+ const LC_SEGMENT_64 = 0x19
79
+ const CPU_TYPE_ARM64 = 0x0100000c
80
+
81
+ function readAt(fd: number, offset: number, length: number): Buffer {
82
+ const buf = Buffer.alloc(length)
83
+ const n = readSync(fd, buf, 0, length, offset)
84
+ return buf.subarray(0, n)
85
+ }
86
+
87
+ function cstr(buf: Buffer, start: number): string {
88
+ const raw = buf.subarray(start, start + 16)
89
+ const end = raw.indexOf(0)
90
+ return raw.subarray(0, end < 0 ? 16 : end).toString('ascii')
91
+ }
92
+
93
+ /** The __TEXT,__info_plist section of a thin 64-bit or fat Mach-O (arm64 slice), or null. */
94
+ export function readEmbeddedInfoPlist(path: string): string | null {
95
+ let fd: number
96
+ // Non-blocking and no-follow: the path can come from a client, and a FIFO would
97
+ // block this synchronous open, and with it the whole server (hazard-invariants.test.ts).
98
+ try { fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW) } catch { return null }
99
+ try { if (!fstatSync(fd).isFile()) { closeSync(fd); return null } } catch { closeSync(fd); return null }
100
+ try {
101
+ let base = 0
102
+ const head = readAt(fd, 0, 8)
103
+ if (head.length < 8) return null
104
+ if (head.readUInt32BE(0) === FAT_MAGIC) {
105
+ const count = head.readUInt32BE(4)
106
+ if (count > 16) return null
107
+ const arches = readAt(fd, 8, count * 20)
108
+ base = -1
109
+ for (let i = 0; i < count; i++) {
110
+ if (arches.readUInt32BE(i * 20) === CPU_TYPE_ARM64) { base = arches.readUInt32BE(i * 20 + 8); break }
111
+ }
112
+ if (base < 0) return null
113
+ }
114
+ const header = readAt(fd, base, 32)
115
+ if (header.length < 32 || header.readUInt32LE(0) !== MH_MAGIC_64) return null
116
+ const ncmds = header.readUInt32LE(16)
117
+ const sizeofcmds = header.readUInt32LE(20)
118
+ if (sizeofcmds > 4 * 1024 * 1024) return null
119
+ const cmds = readAt(fd, base + 32, sizeofcmds)
120
+ let off = 0
121
+ for (let i = 0; i < ncmds && off + 8 <= cmds.length; i++) {
122
+ const cmd = cmds.readUInt32LE(off)
123
+ const cmdsize = cmds.readUInt32LE(off + 4)
124
+ if (cmdsize < 8) return null
125
+ if (cmd === LC_SEGMENT_64 && cstr(cmds, off + 8) === '__TEXT') {
126
+ const nsects = cmds.readUInt32LE(off + 64)
127
+ for (let s = 0; s < nsects; s++) {
128
+ const sec = off + 72 + s * 80
129
+ if (sec + 80 > cmds.length) return null
130
+ if (cstr(cmds, sec) !== '__info_plist') continue
131
+ const size = Number(cmds.readBigUInt64LE(sec + 40))
132
+ const offset = cmds.readUInt32LE(sec + 48)
133
+ if (size <= 0 || size > 64 * 1024) return null
134
+ return readAt(fd, base + offset, size).toString('utf8').replace(/\0+$/, '')
135
+ }
136
+ }
137
+ off += cmdsize
138
+ }
139
+ return null
140
+ } catch { return null } finally { closeSync(fd) }
141
+ }
142
+
143
+ function plistString(xml: string, key: string): string | null {
144
+ const m = new RegExp(`<key>${key}</key>\\s*<string>([^<]{1,128})</string>`).exec(xml)
145
+ return m ? m[1].trim() : null
146
+ }
147
+ /** CFBundleShortVersionString from an XML plist. */
148
+ export function plistVersion(xml: string): string | null { return plistString(xml, 'CFBundleShortVersionString') }
149
+ export function plistIdentifier(xml: string): string | null { return plistString(xml, 'CFBundleIdentifier') }
150
+
151
+ /** 0.17.4 as a whole token, so 0.17.40 or 10.17.4 never pass. */
152
+ export function versionMatches(text: string, want: string = PARAKEET_CLI.version): boolean {
153
+ const escaped = want.replace(/\./g, '\\.')
154
+ return new RegExp(`(^|[^0-9.])v?${escaped}(?![0-9]|\\.[0-9])`).test(text)
155
+ }
156
+
157
+ export function parseCodesignDisplay(output: string): { teamId: string | null; identifier: string | null; adhoc: boolean; unsigned: boolean; plistBound: boolean } {
158
+ const team = /^TeamIdentifier=(.+)$/m.exec(output)?.[1]?.trim() ?? null
159
+ return {
160
+ teamId: team && team !== 'not set' ? team : null,
161
+ identifier: /^Identifier=(.+)$/m.exec(output)?.[1]?.trim() ?? null,
162
+ adhoc: /^Signature=adhoc$/m.test(output) || /flags=0x[0-9a-f]*\([^)]*adhoc/.test(output),
163
+ unsigned: /code object is not signed at all/i.test(output),
164
+ plistBound: /^Info\.plist entries=\d+/m.test(output),
165
+ }
166
+ }
167
+
168
+ /** Every proof, in order; the first that fails names the reason. Never throws. */
169
+ export async function checkDeveloperIdCli(path: string, deps: CliTrustDeps = defaultCliTrustDeps): Promise<CliTrust> {
170
+ const no = (reason: CliTrustReason, detail: string): CliTrust => ({ trusted: false, path, reason, detail })
171
+ let size: number
172
+ try { const st = statSync(path); if (!st.isFile()) return no('missing', 'not a file'); size = st.size } catch { return no('missing', 'not found') }
173
+ try {
174
+ const shown = await deps.display(path)
175
+ const d = parseCodesignDisplay(shown.output)
176
+ if (d.unsigned || shown.code !== 0) return no('unsigned', 'the speech tool is not signed')
177
+ if (d.adhoc || !d.teamId) return no('unsigned', 'the speech tool has no Developer ID signature')
178
+ const strict = await deps.verify(path)
179
+ if (strict.code !== 0) return no('signature_invalid', strict.output.trim().split('\n').slice(-1)[0]?.slice(0, 200) || 'the signature does not verify')
180
+ if (d.teamId !== COS_TEAM_ID) return no('wrong_team', `signed by team ${d.teamId}`)
181
+ const anchored = await deps.verify(path, TRUST_REQUIREMENT)
182
+ if (anchored.code !== 0) return no('wrong_team', 'the signature does not chain to Apple for the COS team')
183
+ if (d.identifier !== CLI_IDENTIFIER) return no('wrong_identifier', `signed as ${d.identifier ?? 'no identifier'}`)
184
+ // Only a plist the signature binds counts; the binary is never run to ask its version.
185
+ const plist = d.plistBound ? deps.embeddedPlist(path) : null
186
+ if (!plist) return no('wrong_version', 'no signed Info.plist in the speech tool')
187
+ const bundleId = plistIdentifier(plist)
188
+ if (bundleId !== CLI_IDENTIFIER) return no('wrong_identifier', `its Info.plist names ${bundleId ?? 'no identifier'}`)
189
+ const named = plistVersion(plist)
190
+ if (!named || !versionMatches(named) || named.replace(/^v/, '') !== PARAKEET_CLI.version) return no('wrong_version', `version ${named ?? 'unknown'}, not FluidAudio ${PARAKEET_CLI.version}`)
191
+ const sha256 = await deps.sha256(path)
192
+ // The bytes hashed must be the bytes checked.
193
+ if (statSync(path).size !== size) return no('signature_invalid', 'the file changed while it was checked')
194
+ return { trusted: true, trust: 'developer-id', path, teamId: COS_TEAM_ID, version: PARAKEET_CLI.version, versionSource: 'info-plist', sha256, size }
195
+ } catch (error) {
196
+ return no('signature_invalid', error instanceof Error ? error.message.slice(0, 200) : String(error))
197
+ }
198
+ }
199
+
200
+ // ── Where COS Control's copy is, and a per-process cache for GET ──────────────
201
+
202
+ const HELPER_REL = join('COS Control.app', 'Contents', 'Helpers', 'fluidaudiocli')
203
+
204
+ /** COS Control in /Applications or ~/Applications, plus any path Control registered. */
205
+ export function controlCliCandidates(home: string = homedir()): string[] {
206
+ return [...offered, join(applicationsRoot, HELPER_REL), join(home, 'Applications', HELPER_REL)]
207
+ }
208
+ let applicationsRoot = '/Applications'
209
+ /** Tests only: keep the real /Applications out of a test's view. */
210
+ export function __setApplicationsRootForTests(root: string | null): void { applicationsRoot = root ?? '/Applications' }
211
+
212
+ const offered: string[] = []
213
+ /** An absolute path whose file name is fluidaudiocli. Anything else is never checked or run. */
214
+ export function isCliPathShape(path: unknown): path is string {
215
+ return typeof path === 'string' && path.startsWith('/') && path.length <= 1024 && !path.includes('\0') && basename(path) === 'fluidaudiocli'
216
+ }
217
+ /** Control tells the server where its bundled CLI is (POST /api/stt-settings/parakeet/cli). */
218
+ export function offerControlCli(path: string): void {
219
+ if (!isCliPathShape(path)) return
220
+ if (!offered.includes(path)) offered.unshift(path)
221
+ if (offered.length > 4) offered.length = 4
222
+ }
223
+
224
+ const cache = new Map<string, CliTrust>()
225
+ const pending = new Map<string, Promise<CliTrust>>()
226
+ function cacheKey(path: string): string | null {
227
+ try { const st = statSync(path); return st.isFile() ? `${path}\0${st.size}\0${st.mtimeMs}\0${st.ino}` : null } catch { return null }
228
+ }
229
+
230
+ /** Check (once per path, size, mtime and inode) and remember. */
231
+ export function trustCli(path: string, deps: CliTrustDeps = defaultCliTrustDeps): Promise<CliTrust> {
232
+ const key = cacheKey(path)
233
+ if (!key) return Promise.resolve({ trusted: false, path, reason: 'missing', detail: 'not found' })
234
+ const known = cache.get(key)
235
+ if (known) return Promise.resolve(known)
236
+ const running = pending.get(key)
237
+ if (running) return running
238
+ const job = checkDeveloperIdCli(path, deps).then(r => { cache.set(key, r); return r }).finally(() => pending.delete(key))
239
+ pending.set(key, job)
240
+ return job
241
+ }
242
+
243
+ /** Synchronous, for GET: the known result for this file, null while unchecked (a check starts). */
244
+ export function knownCliTrust(path: string): CliTrust | null {
245
+ const key = cacheKey(path)
246
+ if (!key) return { trusted: false, path, reason: 'missing', detail: 'not found' }
247
+ const known = cache.get(key)
248
+ if (known) return known
249
+ void trustCli(path)
250
+ return null
251
+ }
252
+
253
+ /** The first trusted candidate, or null. */
254
+ export async function findTrustedCli(candidates: string[] = controlCliCandidates(), deps?: CliTrustDeps): Promise<Extract<CliTrust, { trusted: true }> | null> {
255
+ for (const c of candidates) {
256
+ const r = await trustCli(c, deps)
257
+ if (r.trusted) return r
258
+ }
259
+ return null
260
+ }
261
+
262
+ export function __resetCliTrustForTests(): void { cache.clear(); pending.clear(); offered.length = 0; applicationsRoot = '/Applications' }
@@ -20,7 +20,7 @@ import { execFileSync, spawn as nodeSpawn, type ChildProcess, type SpawnOptions
20
20
  import { createHash, randomBytes } from 'node:crypto'
21
21
  import {
22
22
  chmodSync, copyFileSync, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync,
23
- readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync,
23
+ readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, statfsSync, writeFileSync,
24
24
  } from 'node:fs'
25
25
  import { homedir, setPriority, tmpdir } from 'node:os'
26
26
  import { dirname, join, resolve } from 'node:path'
@@ -29,6 +29,7 @@ import { Readable, Transform } from 'node:stream'
29
29
  import { durableAtomicWriteFileSync } from './atomic-fs.js'
30
30
  import { dataPath } from './data-dir.js'
31
31
  import { PARAKEET_CLI, PARAKEET_MODELS, type PinnedModel } from './parakeet-manifest.js'
32
+ import { checkDeveloperIdCli, controlCliCandidates, COS_TEAM_ID, findTrustedCli, isCliPathShape, type CliTrustDeps, type CliTrustReason } from './parakeet-cli-trust.js'
32
33
  import type { HypWord } from './stt-resolver.js'
33
34
 
34
35
  export type ParakeetModelId = 'parakeet-ultra'
@@ -57,18 +58,23 @@ function pinned(id: ParakeetModelId): PinnedModel {
57
58
  // ── Install state ─────────────────────────────────────────────────────────────
58
59
 
59
60
  export type InstallPhase = 'idle' | 'downloading' | 'verifying' | 'installed' | 'failed'
61
+ /** 6.74.0: why an install failed, for a client that picks the action from a code. */
62
+ export type InstallErrorCode = 'cancelled' | 'insufficient_disk' | 'sha_mismatch' | 'download_failed' | 'stalled' | 'cli_untrusted' | 'cli_missing' | 'failed'
60
63
  export interface InstallProgress {
61
64
  state: InstallPhase
62
65
  bytesDone: number
63
66
  bytesTotal: number
64
67
  error: string | null
68
+ errorCode?: InstallErrorCode | null
65
69
  /** Where the bytes came from: files copied from a verified local copy, and files downloaded. */
66
70
  source?: { filesLocal: number; filesDownloaded: number; localSources: string[] }
67
71
  }
68
72
 
69
73
  export interface InstallReceipt {
70
74
  schema: 1
71
- cli: { version: string; sha256: string }
75
+ /** trust (6.74.0): 'sha256' (absent before 6.74.0) is the pinned upstream bytes;
76
+ * 'developer-id' is COS Control's signed build, its own hash and size recorded at install. */
77
+ cli: { version: string; sha256: string; size?: number; trust?: 'sha256' | 'developer-id'; teamId?: string }
72
78
  models: Array<{ id: string; revision: string; bytes: number }>
73
79
  bytes: number
74
80
  installedAt: string
@@ -94,7 +100,7 @@ export function parakeetInstalled(now = Date.now()): { installed: boolean; bytes
94
100
  try {
95
101
  const receipt = JSON.parse(readFileSync(receiptPath(), 'utf8')) as InstallReceipt
96
102
  const sizeOk = (path: string, size: number) => { try { return statSync(path).size === size } catch { return false } }
97
- const cliOk = receipt?.schema === 1 && receipt.cli?.sha256 === PARAKEET_CLI.sha256 && sizeOk(parakeetCliPath(), PARAKEET_CLI.size)
103
+ const cliOk = receipt?.schema === 1 && receiptCliOk(receipt.cli, sizeOk)
98
104
  const modelsOk = PARAKEET_MODELS.every(m =>
99
105
  receipt.models?.some(r => r.id === m.id && r.revision === m.revision)
100
106
  && m.files.every(f => sizeOk(join(parakeetModelDir(m.id as ParakeetModelId), f.path), f.size)))
@@ -104,6 +110,26 @@ export function parakeetInstalled(now = Date.now()): { installed: boolean; bytes
104
110
  return value
105
111
  }
106
112
 
113
+ /** The receipt's CLI is one this server trusts, and the installed file has its size. */
114
+ function receiptCliOk(cli: InstallReceipt['cli'] | undefined, sizeOk: (path: string, size: number) => boolean): boolean {
115
+ if (!cli || cli.version !== PARAKEET_CLI.version) return false
116
+ if (cli.trust === 'developer-id') {
117
+ return cli.teamId === COS_TEAM_ID && typeof cli.sha256 === 'string' && /^[0-9a-f]{64}$/.test(cli.sha256)
118
+ && typeof cli.size === 'number' && sizeOk(parakeetCliPath(), cli.size)
119
+ }
120
+ return cli.sha256 === PARAKEET_CLI.sha256 && sizeOk(parakeetCliPath(), PARAKEET_CLI.size)
121
+ }
122
+
123
+ function readReceipt(): InstallReceipt | null {
124
+ try { return JSON.parse(readFileSync(receiptPath(), 'utf8')) as InstallReceipt } catch { return null }
125
+ }
126
+
127
+ /** 6.74.0: how the installed CLI was trusted, for GET. */
128
+ export function installedCliTrust(): 'sha256' | 'developer-id' | null {
129
+ const cli = readReceipt()?.cli
130
+ return cli ? cli.trust ?? 'sha256' : null
131
+ }
132
+
107
133
  export function resetParakeetInstallCache(): void { installedCache = null; cliHashCache.clear() }
108
134
 
109
135
  async function sha256File(path: string): Promise<string> {
@@ -185,7 +211,32 @@ function modelDirCandidates(model: PinnedModel, sources: string[]): string[] {
185
211
  }
186
212
 
187
213
  export class ParakeetInstallError extends Error {
188
- constructor(message: string) { super(message); this.name = 'ParakeetInstallError' }
214
+ constructor(message: string, readonly code: InstallErrorCode = 'failed') { super(message); this.name = 'ParakeetInstallError' }
215
+ }
216
+
217
+ /** 6.74.0: an install needs this much free space where it installs (654 MB of model,
218
+ * the CLI, a staging copy of each file before the swap, and headroom). */
219
+ export const INSTALL_MIN_FREE_BYTES = 1_500_000_000
220
+
221
+ /** Free bytes on the volume holding `dir` (its nearest existing ancestor), or null when unknown. */
222
+ export function freeBytesAt(dir: string): number | null {
223
+ let probe = resolve(dir)
224
+ for (let i = 0; i < 64; i++) {
225
+ if (existsSync(probe)) {
226
+ try { const st = statfsSync(probe); return Number(st.bavail) * Number(st.bsize) } catch { return null }
227
+ }
228
+ const up = dirname(probe)
229
+ if (up === probe) return null
230
+ probe = up
231
+ }
232
+ return null
233
+ }
234
+
235
+ /** The disk precheck the install route and the installer share. */
236
+ export function parakeetDiskCheck(freeBytes: (dir: string) => number | null = freeBytesAt, required = INSTALL_MIN_FREE_BYTES): { ok: boolean; freeBytes: number | null; requiredBytes: number } {
237
+ const free = freeBytes(parakeetRoot())
238
+ // Unknown free space does not block: the per-file writes still fail plainly on a full disk.
239
+ return { ok: free === null || free >= required, freeBytes: free, requiredBytes: required }
189
240
  }
190
241
 
191
242
  type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<{ ok: boolean; status: number; body: ReadableStream<Uint8Array> | null }>
@@ -201,6 +252,12 @@ export interface InstallOptions {
201
252
  cliCandidates?: string[]
202
253
  fetchImpl?: FetchLike
203
254
  idleTimeoutMs?: number
255
+ /** 6.74.0, tests: codesign and version probes for a Developer ID CLI. */
256
+ trustDeps?: CliTrustDeps
257
+ /** 6.74.0, tests: where COS Control's signed CLI may be (defaults to /Applications and ~/Applications). */
258
+ controlCliCandidates?: string[]
259
+ /** 6.74.0, tests: free space probe. */
260
+ freeBytes?: (dir: string) => number | null
204
261
  }
205
262
 
206
263
  /** Start the install, or join the one already running. */
@@ -216,27 +273,53 @@ export function parakeetInstallRunning(): boolean { return installing !== null }
216
273
  /** Stop a running install; nothing partial is kept. */
217
274
  export function cancelParakeetInstall(): boolean {
218
275
  if (!installAbort) return false
219
- installAbort.abort(new ParakeetInstallError('The install was cancelled.'))
276
+ installAbort.abort(new ParakeetInstallError('The install was cancelled.', 'cancelled'))
220
277
  return true
221
278
  }
222
279
 
223
280
  async function runInstall(options: InstallOptions, signal: AbortSignal): Promise<void> {
224
281
  const root = parakeetRoot()
225
282
  const localSources = [...(options.localSources ?? []), ...defaultLocalSources()]
226
- progress = { state: 'verifying', bytesDone: 0, bytesTotal: TOTAL_BYTES, error: null, source: { filesLocal: 0, filesDownloaded: 0, localSources: [] } }
227
- mkdirSync(root, { recursive: true, mode: 0o700 })
228
- const staging = join(root, `.staging-${randomBytes(6).toString('hex')}`)
229
- mkdirSync(join(staging, 'bin'), { recursive: true, mode: 0o700 })
283
+ progress = { state: 'verifying', bytesDone: 0, bytesTotal: TOTAL_BYTES, error: null, errorCode: null, source: { filesLocal: 0, filesDownloaded: 0, localSources: [] } }
230
284
  const usedSources = new Set<string>()
285
+ let staging: string | null = null
231
286
  try {
232
- // 1. The CLI. FluidAudio publishes no binary, so a pinned local copy is the only source.
287
+ // 0. Disk precheck (6.74.0), before anything is written.
288
+ const disk = parakeetDiskCheck(options.freeBytes)
289
+ if (!disk.ok) {
290
+ throw new ParakeetInstallError(`Parakeet needs ${formatGb(disk.requiredBytes)} free on this Mac; ${formatGb(disk.freeBytes ?? 0)} is free. Free some space, then install again. Whisper keeps working.`, 'insufficient_disk')
291
+ }
292
+ mkdirSync(root, { recursive: true, mode: 0o700 })
293
+ staging = join(root, `.staging-${randomBytes(6).toString('hex')}`)
294
+ mkdirSync(join(staging, 'bin'), { recursive: true, mode: 0o700 })
295
+
296
+ // 1. The CLI. FluidAudio publishes no binary: a SHA-256 pinned local copy, or
297
+ // (6.74.0) the build COS Control ships signed with the COS Developer ID.
233
298
  const cliTarget = join(staging, 'bin', 'fluidaudiocli')
234
299
  const cli = await findPinnedCli([...(options.cliCandidates ?? []), ...(options.localSources ?? []).map(src => fileSize(src) !== null ? src : join(src, 'fluidaudiocli')), ...defaultCliCandidates()])
235
- if (!cli) {
236
- throw new ParakeetInstallError(`FluidAudio CLI ${PARAKEET_CLI.version} is not on this Mac. It is the same tool speaker separation uses; put it at ~/.cos-glasses/bin/fluidaudiocli, then install again.`)
300
+ let receiptCli: InstallReceipt['cli']
301
+ if (cli) {
302
+ copyFileSync(cli, cliTarget)
303
+ if (await sha256File(cliTarget) !== PARAKEET_CLI.sha256) throw new ParakeetInstallError('The FluidAudio CLI changed while it was being copied. Try again.')
304
+ receiptCli = { version: PARAKEET_CLI.version, sha256: PARAKEET_CLI.sha256, size: PARAKEET_CLI.size, trust: 'sha256' }
305
+ } else {
306
+ const signedCandidates = [...new Set([...(options.cliCandidates ?? []).filter(isCliPathShape), ...(options.controlCliCandidates ?? controlCliCandidates())])]
307
+ const signed = await findTrustedCli(signedCandidates, options.trustDeps)
308
+ if (!signed) {
309
+ const seen = signedCandidates.find(path => fileSize(path) !== null)
310
+ if (seen) {
311
+ const why = await checkDeveloperIdCli(seen, options.trustDeps)
312
+ const reason: CliTrustReason = why.trusted ? 'signature_invalid' : why.reason
313
+ throw new ParakeetInstallError(`The speech tool in COS Control did not pass its check (${reason}). Reinstall COS Control, then install again.`, 'cli_untrusted')
314
+ }
315
+ throw new ParakeetInstallError(`The speech tool Parakeet needs is not on this Mac. Update COS Control, which includes it, then install again.`, 'cli_missing')
316
+ }
317
+ copyFileSync(signed.path, cliTarget)
318
+ // Prove the copy, not only the source: the installed bytes must be signed and unchanged.
319
+ const copy = await checkDeveloperIdCli(cliTarget, options.trustDeps)
320
+ if (!copy.trusted || copy.sha256 !== signed.sha256) throw new ParakeetInstallError('The speech tool changed while it was being copied. Try again.', 'cli_untrusted')
321
+ receiptCli = { version: PARAKEET_CLI.version, sha256: copy.sha256, size: copy.size, trust: 'developer-id', teamId: COS_TEAM_ID }
237
322
  }
238
- copyFileSync(cli, cliTarget)
239
- if (await sha256File(cliTarget) !== PARAKEET_CLI.sha256) throw new ParakeetInstallError('The FluidAudio CLI changed while it was being copied. Try again.')
240
323
  chmodSync(cliTarget, 0o755)
241
324
  progress.bytesDone += PARAKEET_CLI.size
242
325
 
@@ -246,7 +329,7 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
246
329
  for (const model of PARAKEET_MODELS) {
247
330
  const candidates = modelDirCandidates(model, localSources)
248
331
  for (const file of model.files) {
249
- if (signal.aborted) throw new ParakeetInstallError('The install was cancelled.')
332
+ if (signal.aborted) throw new ParakeetInstallError('The install was cancelled.', 'cancelled')
250
333
  const target = join(staging, 'models', model.id, file.path)
251
334
  mkdirSync(dirname(target), { recursive: true, mode: 0o700 })
252
335
  let placed = false
@@ -268,12 +351,12 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
268
351
  const url = `https://huggingface.co/${model.repo}/resolve/${model.revision}/${file.path.split('/').map(encodeURIComponent).join('/')}`
269
352
  // One controller per file: user cancel, or no bytes for idleMs.
270
353
  const ctl = new AbortController()
271
- const onCancel = () => ctl.abort(new ParakeetInstallError('The install was cancelled.'))
354
+ const onCancel = () => ctl.abort(new ParakeetInstallError('The install was cancelled.', 'cancelled'))
272
355
  signal.addEventListener('abort', onCancel, { once: true })
273
356
  let idle: ReturnType<typeof setTimeout> | null = null
274
357
  const arm = () => {
275
358
  if (idle) clearTimeout(idle)
276
- idle = setTimeout(() => ctl.abort(new ParakeetInstallError(`The download stalled (no data for ${Math.round(idleMs / 1000)} s). Nothing was installed; try again.`)), idleMs)
359
+ idle = setTimeout(() => ctl.abort(new ParakeetInstallError(`The download stalled (no data for ${Math.round(idleMs / 1000)} s). Nothing was installed; try again.`, 'stalled')), idleMs)
277
360
  idle.unref?.()
278
361
  }
279
362
  arm()
@@ -281,18 +364,20 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
281
364
  let written = 0
282
365
  try {
283
366
  const res = await fetchImpl(url, { signal: ctl.signal })
284
- if (!res.ok || !res.body) throw new ParakeetInstallError(`Download failed (${res.status}) for ${model.id} ${file.path}.`)
367
+ if (!res.ok || !res.body) throw new ParakeetInstallError(`Download failed (${res.status}) for ${model.id} ${file.path}.`, 'download_failed')
285
368
  const counter = new Transform({ transform(chunk, _e, cb) {
286
369
  arm()
287
370
  written += chunk.length
288
- if (written > file.size) { cb(new ParakeetInstallError(`${model.id} ${file.path} is larger than the pinned size.`)); return }
371
+ if (written > file.size) { cb(new ParakeetInstallError(`${model.id} ${file.path} is larger than the pinned size.`, 'sha_mismatch')); return }
289
372
  progress.bytesDone += chunk.length
290
373
  cb(null, chunk)
291
374
  } })
292
375
  await pipeline(Readable.fromWeb(res.body as any), counter, createWriteStream(partial, { mode: 0o600 }), { signal: ctl.signal })
293
376
  } catch (error) {
294
377
  const why = ctl.signal.reason
295
- throw why instanceof ParakeetInstallError ? why : error
378
+ if (why instanceof ParakeetInstallError) throw why
379
+ if (error instanceof ParakeetInstallError) throw error
380
+ throw new ParakeetInstallError(`Download failed for ${model.id} ${file.path}: ${error instanceof Error ? error.message : String(error)}`, 'download_failed')
296
381
  } finally {
297
382
  if (idle) clearTimeout(idle)
298
383
  signal.removeEventListener('abort', onCancel)
@@ -303,7 +388,7 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
303
388
  }
304
389
  progress.state = 'verifying'
305
390
  if (fileSize(target) !== file.size || await sha256File(target) !== file.sha256) {
306
- throw new ParakeetInstallError(`${model.id} ${file.path} does not match its pinned SHA-256. Nothing was installed.`)
391
+ throw new ParakeetInstallError(`${model.id} ${file.path} does not match its pinned SHA-256. Nothing was installed.`, 'sha_mismatch')
307
392
  }
308
393
  progress.bytesDone += file.size
309
394
  }
@@ -312,7 +397,7 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
312
397
 
313
398
  const receipt: InstallReceipt = {
314
399
  schema: 1,
315
- cli: { version: PARAKEET_CLI.version, sha256: PARAKEET_CLI.sha256 },
400
+ cli: receiptCli,
316
401
  models: PARAKEET_MODELS.map(m => ({ id: m.id, revision: m.revision, bytes: m.files.reduce((n, f) => n + f.size, 0) })),
317
402
  bytes: TOTAL_BYTES,
318
403
  installedAt: new Date().toISOString(),
@@ -324,20 +409,23 @@ async function runInstall(options: InstallOptions, signal: AbortSignal): Promise
324
409
  const current = currentDir()
325
410
  const old = existsSync(current) ? join(root, `.old-${Date.now()}-${randomBytes(3).toString('hex')}`) : null
326
411
  if (old) renameSync(current, old)
327
- renameSync(staging, current)
412
+ renameSync(staging!, current)
328
413
  if (old) rmSync(old, { recursive: true, force: true })
329
414
  mkdirSync(parakeetHome(), { recursive: true, mode: 0o700 })
330
415
  resetParakeetInstallCache()
331
416
  for (const id of warm.keys()) warm.delete(id)
332
- progress = { ...progress, state: 'installed', bytesDone: TOTAL_BYTES, bytesTotal: TOTAL_BYTES, error: null }
417
+ progress = { ...progress, state: 'installed', bytesDone: TOTAL_BYTES, bytesTotal: TOTAL_BYTES, error: null, errorCode: null }
333
418
  } catch (error) {
334
- rmSync(staging, { recursive: true, force: true })
419
+ if (staging) rmSync(staging, { recursive: true, force: true })
335
420
  const message = error instanceof ParakeetInstallError ? error.message : `Install failed: ${error instanceof Error ? error.message : String(error)}`
336
- progress = { ...progress, state: 'failed', bytesTotal: TOTAL_BYTES, error: message, source: progress.source ? { ...progress.source, localSources: [...usedSources] } : undefined }
337
- throw new ParakeetInstallError(message)
421
+ const code: InstallErrorCode = error instanceof ParakeetInstallError ? error.code : 'failed'
422
+ progress = { ...progress, state: 'failed', bytesTotal: TOTAL_BYTES, error: message, errorCode: code, source: progress.source ? { ...progress.source, localSources: [...usedSources] } : undefined }
423
+ throw new ParakeetInstallError(message, code)
338
424
  }
339
425
  }
340
426
 
427
+ function formatGb(bytes: number): string { return `${(bytes / 1e9).toFixed(1)} GB` }
428
+
341
429
  /**
342
430
  * Boot: put back an install a crash left mid-swap (no `current`, an `.old-*`
343
431
  * present), then remove staging and old directories.
@@ -392,6 +480,8 @@ export interface ParakeetRunOptions {
392
480
  spawnImpl?: SpawnLike
393
481
  /** Tests only: a different sandbox-exec path. There is no environment override. */
394
482
  sandboxExec?: string
483
+ /** 6.74.0 benchmark: called with the child's pid once it is spawned (peak memory sampling). */
484
+ onSpawn?: (pid: number) => void
395
485
  }
396
486
 
397
487
  const liveChildren = new Set<ChildProcess>()
@@ -537,12 +627,48 @@ export function transcribeParakeet(wav: Buffer, options: ParakeetRunOptions): Pr
537
627
 
538
628
  async function runParakeet(wav: Buffer, options: ParakeetRunOptions): Promise<ParakeetResult> {
539
629
  // The installed CLI's full hash, once per process (and again if it changes on disk).
540
- if (!options.spawnImpl && !(await isPinnedCli(parakeetCliPath()))) {
541
- throw new ParakeetError('not_installed', 'the installed FluidAudio CLI no longer matches its pinned SHA-256')
630
+ if (!options.spawnImpl && !(await installedCliIntact())) {
631
+ throw new ParakeetError('not_installed', 'the installed FluidAudio CLI no longer matches the hash recorded when it was installed')
542
632
  }
543
633
  return runParakeetChecked(wav, options)
544
634
  }
545
635
 
636
+ /**
637
+ * 6.74.0, for the diarizer's CLI lookup (synchronous, read on health): the GUI-installed
638
+ * CLI is trusted only when the install is complete and its bytes are known to match the
639
+ * receipt (the pinned hash, or the Developer ID build's recorded hash). null while that
640
+ * hash has not been checked in this process; a check starts in the background.
641
+ */
642
+ export function installedCliTrustedNow(): boolean | null {
643
+ if (!parakeetInstalled().installed) return false
644
+ const path = parakeetCliPath()
645
+ const st = fileStat(path)
646
+ if (!st) return false
647
+ const cli = readReceipt()?.cli
648
+ const key = cli?.trust === 'developer-id'
649
+ ? `${path}\0${st.size}\0${st.mtimeMs}\0${cli.sha256}`
650
+ : `${path}\0${st.size}\0${st.mtimeMs}`
651
+ const known = cliHashCache.get(key)
652
+ if (known !== undefined) return known
653
+ void installedCliIntact(path)
654
+ return null
655
+ }
656
+
657
+ /** The installed CLI is byte for byte what the install verified: the pinned hash, or
658
+ * (6.74.0) the hash of the Developer ID build recorded in the receipt. */
659
+ export async function installedCliIntact(path: string = parakeetCliPath()): Promise<boolean> {
660
+ const cli = readReceipt()?.cli
661
+ if (cli?.trust !== 'developer-id') return isPinnedCli(path)
662
+ const st = fileStat(path)
663
+ if (!st || st.size !== cli.size || cli.teamId !== COS_TEAM_ID) return false
664
+ const key = `${path}\0${st.size}\0${st.mtimeMs}\0${cli.sha256}`
665
+ const known = cliHashCache.get(key)
666
+ if (known !== undefined) return known
667
+ const ok = await sha256File(path).then(h => h === cli.sha256, () => false)
668
+ cliHashCache.set(key, ok)
669
+ return ok
670
+ }
671
+
546
672
  function runParakeetChecked(wav: Buffer, options: ParakeetRunOptions): Promise<ParakeetResult> {
547
673
  const started = Date.now()
548
674
  if (!parakeetInstalled().installed) return Promise.reject(new ParakeetError('not_installed', 'Parakeet is not installed'))
@@ -591,6 +717,7 @@ function runParakeetChecked(wav: Buffer, options: ParakeetRunOptions): Promise<P
591
717
  options.signal?.addEventListener('abort', onAbort, { once: true })
592
718
  liveChildren.add(child)
593
719
  child.once('close', () => liveChildren.delete(child))
720
+ if (child.pid && options.onSpawn) { try { options.onSpawn(child.pid) } catch { /* sampling only */ } }
594
721
  if (child.pid && options.lane !== 'preview' && options.lane !== 'interactive') {
595
722
  try { setPriority(child.pid, 10) } catch { /* runs at normal priority */ }
596
723
  }