@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.
@@ -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 += FRAME_BYTES
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
- if (wakeWord === undefined && wakeWords === undefined && defaultTarget === undefined && device === undefined) {
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 --device <d>',
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
+ }