@agfpd/voicetalk-runtime 0.3.1 → 0.4.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.
- package/CHANGELOG.md +129 -0
- package/docs/README.md +80 -0
- package/docs/ru/README.md +80 -0
- package/package.json +8 -1
- package/scripts/verify-install-pair.ts +145 -0
- package/scripts/verify-install.ts +202 -0
- package/src/audio.ts +36 -2
- package/src/cli.ts +22 -3
- package/src/installPair.ts +67 -0
- package/src/installSignature.ts +58 -0
- package/src/nativeVoice.ts +119 -0
- package/src/selfInstall.ts +56 -42
- package/src/voiceSession.ts +199 -4
- package/src/wake.ts +4 -0
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Standalone real macOS install proof shipped IN the package. No dependencies,
|
|
2
|
+
// daemon, keychain identities, lifecycle commands, microphone, speaker, voice
|
|
3
|
+
// service or repository checkout required.
|
|
4
|
+
// Usage after unpacking:
|
|
5
|
+
// bun package/scripts/verify-install.ts [package-dir] [previous-package-dir] [recovery-backup-dir]
|
|
6
|
+
import { strict as assert } from 'assert'
|
|
7
|
+
import { execFileSync, spawn, spawnSync, type ChildProcess } from 'child_process'
|
|
8
|
+
import { createHash, randomUUID } from 'crypto'
|
|
9
|
+
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'
|
|
10
|
+
import { join, resolve } from 'path'
|
|
11
|
+
import { proveInstallPair } from './verify-install-pair.ts'
|
|
12
|
+
|
|
13
|
+
assert.equal(process.platform, 'darwin', 'real codesign proof requires macOS')
|
|
14
|
+
const pkg = resolve(process.argv[2] ?? join(import.meta.dir, '..'))
|
|
15
|
+
const { selfInstall } = await import(join(pkg, 'src/selfInstall.ts')) as typeof import('../src/selfInstall.ts')
|
|
16
|
+
const version = JSON.parse(readFileSync(join(pkg, 'package.json'), 'utf8')).version as string
|
|
17
|
+
// SHORT root on purpose: the router's inbound unix socket lives under IAPEER_ROOT and
|
|
18
|
+
// macOS sun_path is ~104 bytes; $TMPDIR (/var/folders/…) would overflow it.
|
|
19
|
+
const tmp = mkdtempSync('/tmp/vtp-')
|
|
20
|
+
const fakeIap = join(tmp, 'fake-iapeer')
|
|
21
|
+
// Do not inherit host credentials, daemon discovery, identity, audio or voice knobs.
|
|
22
|
+
const env: NodeJS.ProcessEnv = {
|
|
23
|
+
PATH: process.env.PATH ?? '/usr/bin:/bin',
|
|
24
|
+
HOME: join(tmp, 'home'), TMPDIR: tmp,
|
|
25
|
+
IAPEER_TEST_SANDBOX: '1', IAPEER_ROOT: join(tmp, 'root'), IAPEER_BIN_DIR: join(tmp, 'bin'),
|
|
26
|
+
IAP_BIN: fakeIap, VOICETALK_AUDIO: '0', VOICETALK_PLAYBACK: '0',
|
|
27
|
+
VOICETALK_VOICE_ENDPOINT: 'http://127.0.0.1:1',
|
|
28
|
+
}
|
|
29
|
+
mkdirSync(env.HOME!, { recursive: true })
|
|
30
|
+
const bin = join(env.IAPEER_BIN_DIR!, 'voicetalk-runtime')
|
|
31
|
+
const manifest = join(env.IAPEER_ROOT!, 'runtimes/voicetalk/runtime.json')
|
|
32
|
+
const hash = (path: string) => createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
33
|
+
const verify = () => execFileSync('/usr/bin/codesign', ['--verify', '--deep', '--strict', bin], { env })
|
|
34
|
+
const runningVersion = () => execFileSync(bin, ['--version'], { env, encoding: 'utf8' }).trim()
|
|
35
|
+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
|
36
|
+
let router: ChildProcess | undefined
|
|
37
|
+
let routerExit: Promise<void> | undefined
|
|
38
|
+
|
|
39
|
+
// Stand-in for the iapeer daemon: records argv + message body, never contacts a fleet.
|
|
40
|
+
const argvFile = join(tmp, 'daemon-argv.txt')
|
|
41
|
+
const bodyFile = join(tmp, 'daemon-body.txt')
|
|
42
|
+
writeFileSync(fakeIap,
|
|
43
|
+
`#!/bin/sh\nprintf '%s\\n' "$*" > ${JSON.stringify(argvFile)}\n` +
|
|
44
|
+
`while [ "$#" -gt 0 ]; do\n if [ "$1" = "--message-file" ]; then shift; cat "$1" > ${JSON.stringify(bodyFile)}; fi\n shift || true\ndone\n` +
|
|
45
|
+
`printf '%s\\n' 'delivered to proof-target (claude)'\nexit 0\n`)
|
|
46
|
+
chmodSync(fakeIap, 0o755)
|
|
47
|
+
|
|
48
|
+
async function stopRouter(): Promise<void> {
|
|
49
|
+
if (!router || router.exitCode !== null || router.signalCode !== null) return
|
|
50
|
+
router.kill('SIGTERM') // ONLY the proof's own child, never a host runtime
|
|
51
|
+
await Promise.race([routerExit, sleep(3000)])
|
|
52
|
+
if (router.exitCode === null && router.signalCode === null) {
|
|
53
|
+
router.kill('SIGKILL')
|
|
54
|
+
await routerExit
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function waitFor(predicate: () => boolean, ms = 8000): Promise<boolean> {
|
|
59
|
+
const deadline = Date.now() + ms
|
|
60
|
+
while (Date.now() < deadline) {
|
|
61
|
+
if (predicate()) return true
|
|
62
|
+
await sleep(50)
|
|
63
|
+
}
|
|
64
|
+
return predicate()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The installed binary RUNS as a router: headless `run` (audio off) in a bound
|
|
68
|
+
* natural peer, an outbound envelope reaches the audio sink, an inbound `say`
|
|
69
|
+
* reaches the (fake) daemon framed for the spoken modality. */
|
|
70
|
+
async function proveRunningRouter(executable = bin, expectedVersion = version, runEnv = env): Promise<void> {
|
|
71
|
+
assert.equal(execFileSync(executable, ['--version'], { env: runEnv, encoding: 'utf8' }).trim(), expectedVersion)
|
|
72
|
+
const peer = join(tmp, 'proof-owner')
|
|
73
|
+
mkdirSync(join(peer, '.iapeer'), { recursive: true })
|
|
74
|
+
writeFileSync(join(peer, '.iapeer/peer-profile.json'), JSON.stringify({
|
|
75
|
+
personality: 'proof-owner', default_runtime: 'voicetalk', runtimes: ['voicetalk'], intelligence: 'natural',
|
|
76
|
+
interfaces: { voicetalk: { wake_words: { 'проба': 'proof-target' }, default_target: 'proof-target' } },
|
|
77
|
+
}))
|
|
78
|
+
const doctor = spawnSync(executable, ['doctor', '--json'], { cwd: peer, env: runEnv, encoding: 'utf8' })
|
|
79
|
+
assert.equal(doctor.status, 0, `doctor: ${doctor.stdout}${doctor.stderr}`)
|
|
80
|
+
assert.equal(JSON.parse(doctor.stdout).ok, true)
|
|
81
|
+
rmSync(argvFile, { force: true }); rmSync(bodyFile, { force: true })
|
|
82
|
+
const root = runEnv.IAPEER_ROOT!
|
|
83
|
+
const sock = join(root, 'runtimes/voicetalk/inbox/proof-owner.sock')
|
|
84
|
+
assert.ok(Buffer.byteLength(sock) < 104, `socket path too long for sun_path: ${sock}`)
|
|
85
|
+
router = spawn(executable, ['run'], { cwd: peer, env: runEnv, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
86
|
+
routerExit = new Promise<void>(resolve => router!.once('close', () => resolve()))
|
|
87
|
+
let stderr = ''
|
|
88
|
+
let spawnError: Error | undefined
|
|
89
|
+
router.once('error', error => { spawnError = error })
|
|
90
|
+
router.stdout!.resume()
|
|
91
|
+
router.stderr!.on('data', chunk => { stderr += String(chunk) })
|
|
92
|
+
try {
|
|
93
|
+
const up = await waitFor(() => { assert.ifError(spawnError); return existsSync(sock) })
|
|
94
|
+
assert.ok(up, `router never opened its inbound socket: ${stderr}`)
|
|
95
|
+
const outbound = `исходящее ${randomUUID()}`
|
|
96
|
+
router.stdin!.write('<iap from-personality="proof-target" from-runtime="claude" from-intelligence="artificial">' +
|
|
97
|
+
`<message><![CDATA[${outbound}]]></message></iap>`)
|
|
98
|
+
const spokenLog = join(root, 'runtimes/voicetalk/spoken.log')
|
|
99
|
+
assert.ok(await waitFor(() => existsSync(spokenLog) && readFileSync(spokenLog, 'utf8').includes(outbound)),
|
|
100
|
+
`outbound envelope did not reach the audio sink: ${stderr}`)
|
|
101
|
+
const inbound = `входящее ${randomUUID()}`
|
|
102
|
+
const say = spawnSync(executable, ['say', '--peer', 'proof-target', inbound], { cwd: peer, env: runEnv, encoding: 'utf8' })
|
|
103
|
+
assert.equal(say.status, 0, `say: ${say.stdout}${say.stderr}\nrouter: ${stderr}`)
|
|
104
|
+
assert.equal(JSON.parse(say.stdout.trim()).ok, true)
|
|
105
|
+
assert.match(readFileSync(argvFile, 'utf8').trim(), /^send proof-target --message-file /)
|
|
106
|
+
const body = readFileSync(bodyFile, 'utf8')
|
|
107
|
+
assert.ok(body.includes('via voicetalk') && body.includes(inbound), body)
|
|
108
|
+
assert.equal(router.exitCode, null, stderr)
|
|
109
|
+
console.log(`PASS installed router run: version ${expectedVersion}, doctor ok, outbound→sink, inbound say→daemon (fake), audio off`)
|
|
110
|
+
} finally {
|
|
111
|
+
await stopRouter()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Exercise the actual shipped CLI + default compile/signature runner.
|
|
117
|
+
const compiled = execFileSync(process.execPath, [join(pkg, 'src/cli.ts'), 'self-install'], { env, encoding: 'utf8', timeout: 120_000 })
|
|
118
|
+
assert.match(compiled, /\(compiled\)/)
|
|
119
|
+
verify()
|
|
120
|
+
assert.equal(runningVersion(), version)
|
|
121
|
+
assert.equal(JSON.parse(readFileSync(manifest, 'utf8')).version, version)
|
|
122
|
+
console.log(`PASS package CLI compile: strict signature + executable version ${version}`)
|
|
123
|
+
await proveRunningRouter()
|
|
124
|
+
|
|
125
|
+
const validHash = hash(bin)
|
|
126
|
+
const copied = execFileSync(bin, ['self-install'], { env, encoding: 'utf8', timeout: 120_000 })
|
|
127
|
+
assert.match(copied, /\(copied-self\)/)
|
|
128
|
+
verify()
|
|
129
|
+
assert.equal(hash(bin), validHash, 'valid copy-self must retain the exact signature/bytes')
|
|
130
|
+
console.log('PASS package CLI copy-self: strict signature, bytes/signature unchanged')
|
|
131
|
+
|
|
132
|
+
// Feed an unsigned native input through the shipped copy-self implementation.
|
|
133
|
+
const unsigned = join(tmp, 'unsigned-voicetalk-runtime')
|
|
134
|
+
copyFileSync(bin, unsigned)
|
|
135
|
+
execFileSync('/usr/bin/codesign', ['--remove-signature', unsigned], { env })
|
|
136
|
+
const repaired = selfInstall({ env, sourceEntry: join(tmp, 'missing.ts'), execPath: unsigned })
|
|
137
|
+
assert.equal(repaired.binMode, 'copied-self')
|
|
138
|
+
assert.equal(repaired.signature, 'repaired')
|
|
139
|
+
verify()
|
|
140
|
+
assert.equal(runningVersion(), version)
|
|
141
|
+
console.log('PASS unsigned native copy: ad-hoc repaired, strict verify + executable')
|
|
142
|
+
|
|
143
|
+
await proveInstallPair({ pkg, tmp, env, candidateBin: bin, selfInstall, run: proveRunningRouter,
|
|
144
|
+
previousPkg: process.argv[3] ? resolve(process.argv[3]) : undefined,
|
|
145
|
+
recoveryBackup: process.argv[4] ? resolve(process.argv[4]) : undefined,
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Impossible-to-sign input: real codesign failure must preserve BOTH old surfaces.
|
|
149
|
+
const bad = join(tmp, 'truncated-mach-o')
|
|
150
|
+
writeFileSync(bad, readFileSync(bin).subarray(0, 1024))
|
|
151
|
+
const invalid = spawnSync('/usr/bin/codesign', ['--verify', '--deep', '--strict', bad], { env, encoding: 'utf8' })
|
|
152
|
+
assert.ifError(invalid.error)
|
|
153
|
+
assert.notEqual(invalid.status, 0, 'negative fixture must really be strict-invalid')
|
|
154
|
+
console.log(`REAL truncated Mach-O strict verify: status=${invalid.status}, SHA256=${hash(bad)}`)
|
|
155
|
+
const before = [hash(bin), hash(manifest)]
|
|
156
|
+
assert.throws(() => selfInstall({ env, sourceEntry: join(tmp, 'missing.ts'), execPath: bad,
|
|
157
|
+
runner: (cmd, args, passedEnv) => {
|
|
158
|
+
const result = spawnSync(cmd, args, { encoding: 'utf8', env: passedEnv, timeout: 120_000 })
|
|
159
|
+
console.log(`REAL truncated ${args[0]}: status=${result.status}, stderr=${result.stderr?.trim() ?? ''}`)
|
|
160
|
+
return result
|
|
161
|
+
},
|
|
162
|
+
}), /codesign .* failed/)
|
|
163
|
+
assert.deepEqual([hash(bin), hash(manifest)], before)
|
|
164
|
+
assert.deepEqual(readdirSync(env.IAPEER_BIN_DIR!), ['voicetalk-runtime'], 'no stage/.dead artifact left')
|
|
165
|
+
verify()
|
|
166
|
+
assert.equal(runningVersion(), version)
|
|
167
|
+
console.log(`PASS un-signable copy rejected: old bin+manifest SHA256 unchanged ${before.join(' ')}, stage removed`)
|
|
168
|
+
await proveRunningRouter() // previous router still RUNS after the rejection
|
|
169
|
+
|
|
170
|
+
// The incident's minimal fresh-compile input: accept ONLY if strict-valid and
|
|
171
|
+
// executable, otherwise prove the real failing compiler/signing path is closed.
|
|
172
|
+
const minimal = join(tmp, 'minimal.ts')
|
|
173
|
+
const marker = 'minimal fresh compile signature proof'
|
|
174
|
+
writeFileSync(minimal, `console.log(${JSON.stringify(marker)})\n`)
|
|
175
|
+
const previous = [hash(bin), hash(manifest)]
|
|
176
|
+
let failure: unknown
|
|
177
|
+
try {
|
|
178
|
+
selfInstall({ env, sourceEntry: minimal, execPath: process.execPath, runner: (cmd, args, passedEnv) => {
|
|
179
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8', env: passedEnv, timeout: 120_000 })
|
|
180
|
+
console.log(`REAL minimal ${args[0]}: status=${r.status}, error=${r.error?.message ?? '-'}, stderr=${r.stderr?.trim() ?? ''}`)
|
|
181
|
+
return r
|
|
182
|
+
} })
|
|
183
|
+
} catch (e) {
|
|
184
|
+
failure = e
|
|
185
|
+
}
|
|
186
|
+
if (failure) {
|
|
187
|
+
assert.match(String(failure), /codesign .* failed/)
|
|
188
|
+
assert.deepEqual([hash(bin), hash(manifest)], previous)
|
|
189
|
+
verify()
|
|
190
|
+
assert.equal(runningVersion(), version)
|
|
191
|
+
console.log('PASS strict-invalid minimal compile rejected: old bin+manifest SHA256 unchanged')
|
|
192
|
+
} else {
|
|
193
|
+
verify()
|
|
194
|
+
assert.equal(execFileSync(bin, [], { env, encoding: 'utf8' }).trim(), marker)
|
|
195
|
+
console.log('PASS minimal compile accepted ONLY after strict verify + execution')
|
|
196
|
+
}
|
|
197
|
+
assert.deepEqual(readdirSync(env.IAPEER_BIN_DIR!), ['voicetalk-runtime'])
|
|
198
|
+
console.log(`PASS artifact ${version}: no core/helper/voice-service dependency; all writes sandboxed`)
|
|
199
|
+
} finally {
|
|
200
|
+
await stopRouter()
|
|
201
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
202
|
+
}
|
package/src/audio.ts
CHANGED
|
@@ -320,6 +320,9 @@ export class Microphone {
|
|
|
320
320
|
private stopped = false
|
|
321
321
|
private attempt = 0
|
|
322
322
|
private timer: ReturnType<typeof setTimeout> | null = null
|
|
323
|
+
/** Replay position, kept across pause/resume (test seam only). */
|
|
324
|
+
private replayOffset = 0
|
|
325
|
+
private pausedAt = 0
|
|
323
326
|
|
|
324
327
|
constructor(private readonly opts: MicOptions) {}
|
|
325
328
|
|
|
@@ -342,6 +345,37 @@ export class Microphone {
|
|
|
342
345
|
return this.child !== null
|
|
343
346
|
}
|
|
344
347
|
|
|
348
|
+
/** Release the input device and resolve only once the capture child has actually
|
|
349
|
+
* EXITED — a killed-but-not-yet-reaped ffmpeg still holds the CoreAudio client. This is
|
|
350
|
+
* the hand-off to a second listener (native voice): the other side may open the same
|
|
351
|
+
* physical microphone only after this resolves, or both would hear the room. */
|
|
352
|
+
async pause(capMs = 3_000): Promise<{ released: boolean }> {
|
|
353
|
+
const child = this.child
|
|
354
|
+
this.stop()
|
|
355
|
+
if (this.opts.filePath) this.pausedAt = Date.now()
|
|
356
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return { released: true }
|
|
357
|
+
return await new Promise(resolve => {
|
|
358
|
+
const timer = setTimeout(() => resolve({ released: false }), capMs)
|
|
359
|
+
child.once('close', () => {
|
|
360
|
+
clearTimeout(timer)
|
|
361
|
+
resolve({ released: true })
|
|
362
|
+
})
|
|
363
|
+
})
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Take the device back after a pause. A replayed recording does not rewind: the audio
|
|
367
|
+
* that "played" while the device was handed off belongs to the other listener, so the
|
|
368
|
+
* replay skips ahead by the paused wall-clock time — exactly what a real microphone
|
|
369
|
+
* would have missed. */
|
|
370
|
+
resume(): void {
|
|
371
|
+
if (this.opts.filePath && this.pausedAt) {
|
|
372
|
+
const frames = Math.floor((Date.now() - this.pausedAt) / FRAME_MS)
|
|
373
|
+
this.replayOffset += frames * FRAME_BYTES
|
|
374
|
+
this.pausedAt = 0
|
|
375
|
+
}
|
|
376
|
+
this.start()
|
|
377
|
+
}
|
|
378
|
+
|
|
345
379
|
private spawnChild(): void {
|
|
346
380
|
if (this.stopped) return
|
|
347
381
|
this.attempt += 1
|
|
@@ -397,9 +431,9 @@ export class Microphone {
|
|
|
397
431
|
return
|
|
398
432
|
}
|
|
399
433
|
this.opts.onStart?.(this.attempt)
|
|
400
|
-
let offset = 0
|
|
401
434
|
const tick = () => {
|
|
402
435
|
if (this.stopped) return
|
|
436
|
+
const offset = this.replayOffset
|
|
403
437
|
if (offset >= pcm.length) {
|
|
404
438
|
// Keep the stream alive with silence so compose/window timers still fire.
|
|
405
439
|
this.opts.onData(Buffer.alloc(FRAME_BYTES))
|
|
@@ -407,7 +441,7 @@ export class Microphone {
|
|
|
407
441
|
return
|
|
408
442
|
}
|
|
409
443
|
this.opts.onData(Buffer.from(pcm.subarray(offset, offset + FRAME_BYTES)))
|
|
410
|
-
offset
|
|
444
|
+
this.replayOffset = offset + FRAME_BYTES
|
|
411
445
|
this.timer = setTimeout(tick, FRAME_MS)
|
|
412
446
|
}
|
|
413
447
|
tick()
|
package/src/cli.ts
CHANGED
|
@@ -45,6 +45,7 @@ import { selfInstall } from './selfInstall.ts'
|
|
|
45
45
|
import { parseWakeWords, runSelfConfig } from './selfConfig.ts'
|
|
46
46
|
import { writeJsonAtomic } from './fsAtomic.ts'
|
|
47
47
|
import { VoiceSession } from './voiceSession.ts'
|
|
48
|
+
import { cliNativeVoice, nativeVoiceTargets } from './nativeVoice.ts'
|
|
48
49
|
import { voiceHealth } from './voiceConnect.ts'
|
|
49
50
|
import { listInputDevices, resolveInputDevice } from './audio.ts'
|
|
50
51
|
import { buildAddressBook, unresolvedWakeWords, wakeEntries, type VoicetalkBinding } from './wake.ts'
|
|
@@ -163,7 +164,7 @@ function usage(): string {
|
|
|
163
164
|
${BIN_NAME} # self-install (npx contract): bin on PATH + manifest
|
|
164
165
|
${BIN_NAME} self-install # explicit self-install (idempotent)
|
|
165
166
|
${BIN_NAME} self-config # per-peer self-config hook (foundation-invoked)
|
|
166
|
-
${BIN_NAME} interface --wake-words «дарвин=darwin,борис=boris» [--default-target <peer>] [--device <d>]
|
|
167
|
+
${BIN_NAME} interface --wake-words «дарвин=darwin,борис=boris» [--default-target <peer>] [--device <d>] [--native-voice darwin]
|
|
167
168
|
# set this peer's voicetalk binding (wake map)
|
|
168
169
|
${BIN_NAME} run # router loop: live mic ↔ speaker (VOICETALK_AUDIO=0 → text only)
|
|
169
170
|
${BIN_NAME} say --peer <personality> <text…> # inject an utterance without the mic (debug seam)
|
|
@@ -400,6 +401,7 @@ function setVoicetalkInterface(cwd: string, patch: VoicetalkInterface): Record<s
|
|
|
400
401
|
if (patch.wake_words !== undefined) voicetalk.wake_words = patch.wake_words
|
|
401
402
|
if (patch.default_target !== undefined) voicetalk.default_target = patch.default_target
|
|
402
403
|
if (patch.device !== undefined) voicetalk.device = patch.device
|
|
404
|
+
if (patch.native_voice !== undefined) voicetalk.native_voice = patch.native_voice
|
|
403
405
|
;(interfaces as Record<string, unknown>).voicetalk = voicetalk
|
|
404
406
|
const merged = { ...raw, interfaces }
|
|
405
407
|
writeJsonAtomic(path, merged)
|
|
@@ -972,6 +974,11 @@ async function runCommand(extraArgs: string[]): Promise<void> {
|
|
|
972
974
|
deliver: (target, text) => deliverUtterance(ctx, target, text),
|
|
973
975
|
log: logEvt,
|
|
974
976
|
tmpDir: join(globalVoicetalkRoot(), 'tmp'),
|
|
977
|
+
// Native voice is opt-in per target; with no target configured the door is not
|
|
978
|
+
// even built, so the STT path is untouched for everyone.
|
|
979
|
+
...(nativeVoiceTargets(binding).size
|
|
980
|
+
? { nativeVoice: cliNativeVoice({ bin: ctx.iapBin, env: iapEnv(owner), cwd: ctx.cwd }) }
|
|
981
|
+
: {}),
|
|
975
982
|
})
|
|
976
983
|
try {
|
|
977
984
|
const started = await candidate.start()
|
|
@@ -1013,6 +1020,7 @@ async function runCommand(extraArgs: string[]): Promise<void> {
|
|
|
1013
1020
|
owner: owner.personality,
|
|
1014
1021
|
wake_words: wakeEntries(binding).map(([w]) => w),
|
|
1015
1022
|
device: binding.device ?? null,
|
|
1023
|
+
native_voice: [...nativeVoiceTargets(binding)],
|
|
1016
1024
|
iapBin: ctx.iapBin,
|
|
1017
1025
|
inbound_socket: inboundSocketPath(owner.personality),
|
|
1018
1026
|
audio: session ? { live: true, ...audio } : { live: false, ...audio },
|
|
@@ -1115,9 +1123,17 @@ async function interfaceCommand(args: string[]): Promise<void> {
|
|
|
1115
1123
|
const wakeWords = parseWakeWords(stringFlag(flags, 'wake-words'))
|
|
1116
1124
|
const defaultTarget = stringFlag(flags, 'default-target')
|
|
1117
1125
|
const device = stringFlag(flags, 'device')
|
|
1118
|
-
|
|
1126
|
+
const nativeFlag = stringFlag(flags, 'native-voice')
|
|
1127
|
+
const nativeVoice = nativeFlag === undefined ? undefined : nativeFlag.split(',').map(v => v.trim()).filter(Boolean)
|
|
1128
|
+
if (
|
|
1129
|
+
wakeWord === undefined &&
|
|
1130
|
+
wakeWords === undefined &&
|
|
1131
|
+
defaultTarget === undefined &&
|
|
1132
|
+
device === undefined &&
|
|
1133
|
+
nativeVoice === undefined
|
|
1134
|
+
) {
|
|
1119
1135
|
throw new VoicetalkRuntimeError(
|
|
1120
|
-
'interface requires --wake-words <«слово=пир,…»>, --default-target <peer>, --wake-word <w> and/or --
|
|
1136
|
+
'interface requires --wake-words <«слово=пир,…»>, --default-target <peer>, --wake-word <w>, --device <d> and/or --native-voice <peers>',
|
|
1121
1137
|
)
|
|
1122
1138
|
}
|
|
1123
1139
|
ensureCurrentProfile() // assert the profile exists before patching
|
|
@@ -1126,6 +1142,7 @@ async function interfaceCommand(args: string[]): Promise<void> {
|
|
|
1126
1142
|
...(wakeWords !== undefined ? { wake_words: wakeWords } : {}),
|
|
1127
1143
|
...(defaultTarget !== undefined ? { default_target: defaultTarget } : {}),
|
|
1128
1144
|
...(device !== undefined ? { device } : {}),
|
|
1145
|
+
...(nativeVoice !== undefined ? { native_voice: nativeVoice } : {}),
|
|
1129
1146
|
})
|
|
1130
1147
|
process.stdout.write(`${peerProfilePath(process.cwd())}\n`)
|
|
1131
1148
|
printJson(merged)
|
|
@@ -1141,6 +1158,7 @@ async function selfInstallCommand(): Promise<void> {
|
|
|
1141
1158
|
const r = selfInstall({ env: process.env, sourceEntry: import.meta.path })
|
|
1142
1159
|
process.stdout.write(`${BIN_NAME} self-install (idempotent)\n`)
|
|
1143
1160
|
process.stdout.write(` bin: ${r.binPath} (${r.binMode})\n`)
|
|
1161
|
+
process.stdout.write(` signature: ${r.signature}\n`)
|
|
1144
1162
|
process.stdout.write(` manifest: ${r.manifestPath}\n`)
|
|
1145
1163
|
process.stdout.write(` root: ${r.root}\n`)
|
|
1146
1164
|
process.stdout.write(` docs: ${r.docs.copied ? r.docs.dest : `skipped (${r.docs.reason ?? 'unknown'})`}\n`)
|
|
@@ -1148,6 +1166,7 @@ async function selfInstallCommand(): Promise<void> {
|
|
|
1148
1166
|
bin: r.binPath,
|
|
1149
1167
|
manifest: r.manifestPath,
|
|
1150
1168
|
binMode: r.binMode,
|
|
1169
|
+
signature: r.signature,
|
|
1151
1170
|
root: r.root,
|
|
1152
1171
|
docs: r.docs,
|
|
1153
1172
|
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto'
|
|
2
|
+
import { linkSync, lstatSync, mkdirSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'fs'
|
|
3
|
+
import { dirname } from 'path'
|
|
4
|
+
import { runtimeManifestPath, type RuntimeManifest } from './manifest.ts'
|
|
5
|
+
|
|
6
|
+
/** Publish an already signature-verified executable and its manifest.
|
|
7
|
+
* All preparation is fallible BEFORE publication. If the manifest rename fails,
|
|
8
|
+
* restore the old binary inode/signature (or symlink), or remove a first install.
|
|
9
|
+
* Two paths are NOT crash-atomic: no power-loss/SIGKILL or concurrent-writer claim.
|
|
10
|
+
* No lifecycle operations belong here; running processes keep their old inode. */
|
|
11
|
+
export function publishInstallPair(
|
|
12
|
+
stagedBin: string,
|
|
13
|
+
binPath: string,
|
|
14
|
+
manifest: RuntimeManifest,
|
|
15
|
+
env: NodeJS.ProcessEnv,
|
|
16
|
+
// Narrow test seam; production always calls the real synchronous rename.
|
|
17
|
+
rename: typeof renameSync = renameSync,
|
|
18
|
+
): string {
|
|
19
|
+
const manifestPath = runtimeManifestPath(env)
|
|
20
|
+
const suffix = `${process.pid}.${randomUUID()}`
|
|
21
|
+
const stagedManifest = `${manifestPath}.${suffix}.tmp`
|
|
22
|
+
const backupBin = `${binPath}.${suffix}.rollback`
|
|
23
|
+
let hadBin = false
|
|
24
|
+
let publishedBin = false
|
|
25
|
+
let retainBackup = false
|
|
26
|
+
// Cleanup cannot turn a completed new pair into a reported publication failure.
|
|
27
|
+
// If cleanup itself is denied, an unused uniquely named file can remain.
|
|
28
|
+
const cleanup = (path: string) => { try { rmSync(path, { force: true }) } catch {} }
|
|
29
|
+
try {
|
|
30
|
+
mkdirSync(dirname(manifestPath), { recursive: true, mode: 0o755 })
|
|
31
|
+
writeFileSync(stagedManifest, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o644, flag: 'wx' })
|
|
32
|
+
let symlink = false
|
|
33
|
+
try {
|
|
34
|
+
const stat = lstatSync(binPath)
|
|
35
|
+
if (!stat.isFile() && !stat.isSymbolicLink()) throw new Error(`self-install: binary is not a file or symlink: ${binPath}`)
|
|
36
|
+
symlink = stat.isSymbolicLink()
|
|
37
|
+
hadBin = true
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
40
|
+
}
|
|
41
|
+
if (hadBin) {
|
|
42
|
+
// macOS link(2) follows symlinks. Preserve link text explicitly, including
|
|
43
|
+
// dangling/relative links. Hard-link regular files: no copy or re-signing.
|
|
44
|
+
if (symlink) symlinkSync(readlinkSync(binPath), backupBin)
|
|
45
|
+
else linkSync(binPath, backupBin)
|
|
46
|
+
}
|
|
47
|
+
rename(stagedBin, binPath)
|
|
48
|
+
publishedBin = true
|
|
49
|
+
rename(stagedManifest, manifestPath)
|
|
50
|
+
return manifestPath // no fallible contract operation after the second rename
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (publishedBin) {
|
|
53
|
+
try {
|
|
54
|
+
if (hadBin) rename(backupBin, binPath)
|
|
55
|
+
else rmSync(binPath)
|
|
56
|
+
} catch (rollbackError) {
|
|
57
|
+
retainBackup = true
|
|
58
|
+
throw new AggregateError([error, rollbackError],
|
|
59
|
+
`self-install: pair publication AND rollback failed; ${hadBin ? `previous binary retained at ${backupBin}` : `could not remove first-install binary ${binPath}`}`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
throw error
|
|
63
|
+
} finally {
|
|
64
|
+
cleanup(stagedManifest)
|
|
65
|
+
if (!retainBackup) cleanup(backupBin)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// File-scoped macOS install gate. Verify the staged executable BEFORE publishing
|
|
2
|
+
// it; preserve valid signatures (and their TCC identity). No keychain operations.
|
|
3
|
+
import { spawnSync } from 'child_process'
|
|
4
|
+
|
|
5
|
+
export interface InstallCommandResult {
|
|
6
|
+
status: number | null
|
|
7
|
+
stdout?: string
|
|
8
|
+
stderr?: string
|
|
9
|
+
error?: Error
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Injectable for sandbox tests; commands use argv, never a shell or PATH codesign. */
|
|
13
|
+
export type InstallCommandRunner = (
|
|
14
|
+
command: string,
|
|
15
|
+
args: string[],
|
|
16
|
+
env: NodeJS.ProcessEnv,
|
|
17
|
+
) => InstallCommandResult
|
|
18
|
+
|
|
19
|
+
export const runInstallCommand: InstallCommandRunner = (command, args, env) =>
|
|
20
|
+
spawnSync(command, args, { encoding: 'utf8', env, timeout: 120_000 })
|
|
21
|
+
|
|
22
|
+
export interface ExecutableSignatureOptions {
|
|
23
|
+
env?: NodeJS.ProcessEnv
|
|
24
|
+
platform?: NodeJS.Platform
|
|
25
|
+
runner?: InstallCommandRunner
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ExecutableSignatureState = 'verified' | 'repaired' | 'skipped-platform'
|
|
29
|
+
|
|
30
|
+
function failed(stage: string, r: InstallCommandResult): Error {
|
|
31
|
+
const detail = (r.error?.message || r.stderr || r.stdout || `exit ${r.status}`).trim()
|
|
32
|
+
return new Error(`self-install: codesign ${stage} failed: ${detail}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Runtime-package contract: strict verify → repair only an invalid signature →
|
|
37
|
+
* strict verify again. Any command failure throws; callers must retain the old
|
|
38
|
+
* binary/manifest and remove the staging file. Sandbox is NOT a skip flag: tests
|
|
39
|
+
* inject a runner, while real sandbox installs verify their own staged binary.
|
|
40
|
+
*/
|
|
41
|
+
export function ensureExecutableSignature(
|
|
42
|
+
stagedPath: string,
|
|
43
|
+
opts: ExecutableSignatureOptions = {},
|
|
44
|
+
): ExecutableSignatureState {
|
|
45
|
+
if ((opts.platform ?? process.platform) !== 'darwin') return 'skipped-platform'
|
|
46
|
+
const run = opts.runner ?? runInstallCommand
|
|
47
|
+
const env = opts.env ?? process.env
|
|
48
|
+
const verifyArgs = ['--verify', '--deep', '--strict', stagedPath]
|
|
49
|
+
const initial = run('/usr/bin/codesign', verifyArgs, env)
|
|
50
|
+
if (initial.error || initial.status === null) throw failed('initial verify', initial)
|
|
51
|
+
if (initial.status === 0) return 'verified'
|
|
52
|
+
|
|
53
|
+
const sign = run('/usr/bin/codesign', ['--force', '--sign', '-', stagedPath], env)
|
|
54
|
+
if (sign.error || sign.status !== 0) throw failed('ad-hoc repair', sign)
|
|
55
|
+
const verified = run('/usr/bin/codesign', verifyArgs, env)
|
|
56
|
+
if (verified.error || verified.status !== 0) throw failed('post-repair verify', verified)
|
|
57
|
+
return 'repaired'
|
|
58
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// nativeVoice.ts — the door to a peer's NATIVE voice mode (Codex CLI `/voice`).
|
|
2
|
+
//
|
|
3
|
+
// For a configured target the spoken wake word does not start STT → envelope → TTS; it
|
|
4
|
+
// hands the conversation to the peer's own realtime voice inside its live session. The
|
|
5
|
+
// session belongs to iapeer (the pty supervisor), so voicetalk only REQUESTS and OBSERVES
|
|
6
|
+
// over a small CLI contract agreed with the iapeer Implementer (topic codex-voice-wake):
|
|
7
|
+
//
|
|
8
|
+
// iapeer voice open <peer> --json → {"ok":true,"state":"open","session":"…","already":bool}
|
|
9
|
+
// iapeer voice status <peer> --json → {"state":"open|opening|closing|closed","session":…,"reason"?:…}
|
|
10
|
+
// iapeer voice close <peer> --json → {"ok":true,"state":"closed"}
|
|
11
|
+
//
|
|
12
|
+
// open returns only once the realtime session is confirmed started, close once it is
|
|
13
|
+
// confirmed closed; status is what the listener polls before it takes the microphone back.
|
|
14
|
+
// Idle-close lives in iapeer — voicetalk never decides a conversation is over, it only
|
|
15
|
+
// waits for the confirmation that it is.
|
|
16
|
+
|
|
17
|
+
import { spawn } from 'child_process'
|
|
18
|
+
|
|
19
|
+
export type NativeVoiceState = 'open' | 'opening' | 'closing' | 'closed' | 'unknown'
|
|
20
|
+
|
|
21
|
+
export type NativeVoiceReceipt = {
|
|
22
|
+
ok: boolean
|
|
23
|
+
state: NativeVoiceState
|
|
24
|
+
session?: string | null
|
|
25
|
+
already?: boolean
|
|
26
|
+
reason?: string
|
|
27
|
+
error?: string
|
|
28
|
+
/** Raw stdout/stderr tail for the log when the answer did not parse. */
|
|
29
|
+
raw?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type NativeVoiceControl = {
|
|
33
|
+
open(peer: string): Promise<NativeVoiceReceipt>
|
|
34
|
+
status(peer: string): Promise<NativeVoiceReceipt>
|
|
35
|
+
close(peer: string): Promise<NativeVoiceReceipt>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const STATES = new Set(['open', 'opening', 'closing', 'closed'])
|
|
39
|
+
|
|
40
|
+
/** Parse the LAST JSON object line of a contract answer. Anything else is not a receipt:
|
|
41
|
+
* a confirmation that cannot be read is treated as no confirmation. */
|
|
42
|
+
export function parseVoiceReceipt(stdout: string, stderr: string, exitCode: number | null): NativeVoiceReceipt {
|
|
43
|
+
const lines = `${stdout}`.split(/\r?\n/).map(l => l.trim()).filter(l => l.startsWith('{') && l.endsWith('}'))
|
|
44
|
+
for (const line of lines.reverse()) {
|
|
45
|
+
try {
|
|
46
|
+
const obj = JSON.parse(line) as Record<string, unknown>
|
|
47
|
+
const state = typeof obj.state === 'string' && STATES.has(obj.state) ? (obj.state as NativeVoiceState) : 'unknown'
|
|
48
|
+
const errorText = [obj.error, obj.err, obj.detail].find(v => typeof v === 'string') as string | undefined
|
|
49
|
+
return {
|
|
50
|
+
ok: obj.ok === false ? false : exitCode === 0,
|
|
51
|
+
state,
|
|
52
|
+
session: typeof obj.session === 'string' ? obj.session : null,
|
|
53
|
+
already: obj.already === true,
|
|
54
|
+
reason: typeof obj.reason === 'string' ? obj.reason : undefined,
|
|
55
|
+
error: exitCode === 0 && obj.ok !== false ? undefined : (errorText ?? `exit ${exitCode}`),
|
|
56
|
+
}
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
const raw = `${stdout}\n${stderr}`.trim().slice(-300)
|
|
60
|
+
return { ok: false, state: 'unknown', error: raw || `exit ${exitCode}`, raw }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The CLI implementation of the contract. Never throws; a timeout is a failed receipt. */
|
|
64
|
+
export function cliNativeVoice(opts: {
|
|
65
|
+
bin: string
|
|
66
|
+
env?: NodeJS.ProcessEnv
|
|
67
|
+
cwd?: string
|
|
68
|
+
openTimeoutMs?: number
|
|
69
|
+
statusTimeoutMs?: number
|
|
70
|
+
closeTimeoutMs?: number
|
|
71
|
+
}): NativeVoiceControl {
|
|
72
|
+
const run = (verb: string, peer: string, timeoutMs: number) =>
|
|
73
|
+
new Promise<NativeVoiceReceipt>(resolve => {
|
|
74
|
+
let stdout = ''
|
|
75
|
+
let stderr = ''
|
|
76
|
+
let settled = false
|
|
77
|
+
const finish = (r: NativeVoiceReceipt) => {
|
|
78
|
+
if (settled) return
|
|
79
|
+
settled = true
|
|
80
|
+
clearTimeout(timer)
|
|
81
|
+
resolve(r)
|
|
82
|
+
}
|
|
83
|
+
let child: ReturnType<typeof spawn>
|
|
84
|
+
try {
|
|
85
|
+
child = spawn(opts.bin, ['voice', verb, peer, '--json'], {
|
|
86
|
+
cwd: opts.cwd,
|
|
87
|
+
env: opts.env ?? process.env,
|
|
88
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
89
|
+
})
|
|
90
|
+
} catch (err) {
|
|
91
|
+
resolve({ ok: false, state: 'unknown', error: err instanceof Error ? err.message : String(err) })
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
try {
|
|
96
|
+
child.kill('SIGKILL')
|
|
97
|
+
} catch {}
|
|
98
|
+
finish({ ok: false, state: 'unknown', error: `voice ${verb} timed out after ${Math.ceil(timeoutMs / 1000)}s` })
|
|
99
|
+
}, timeoutMs)
|
|
100
|
+
child.stdout?.on('data', d => (stdout += String(d)))
|
|
101
|
+
child.stderr?.on('data', d => (stderr += String(d)))
|
|
102
|
+
child.on('error', err => finish({ ok: false, state: 'unknown', error: err.message }))
|
|
103
|
+
child.on('close', code => finish(parseVoiceReceipt(stdout, stderr, code)))
|
|
104
|
+
})
|
|
105
|
+
return {
|
|
106
|
+
open: peer => run('open', peer, opts.openTimeoutMs ?? 20_000),
|
|
107
|
+
status: peer => run('status', peer, opts.statusTimeoutMs ?? 5_000),
|
|
108
|
+
close: peer => run('close', peer, opts.closeTimeoutMs ?? 15_000),
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Which targets are answered in native voice: the passport's `native_voice` list, or the
|
|
113
|
+
* `VOICETALK_NATIVE_VOICE` override (comma list; empty string disables). */
|
|
114
|
+
export function nativeVoiceTargets(binding: { native_voice?: unknown }, env = process.env): Set<string> {
|
|
115
|
+
const override = env.VOICETALK_NATIVE_VOICE
|
|
116
|
+
const source: unknown = override !== undefined ? override.split(',') : binding.native_voice
|
|
117
|
+
const list = Array.isArray(source) ? source : typeof source === 'string' ? source.split(',') : []
|
|
118
|
+
return new Set(list.map(v => String(v).trim()).filter(Boolean))
|
|
119
|
+
}
|