@agfpd/voicetalk-runtime 0.3.0 → 0.3.3

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/cli.ts CHANGED
@@ -1141,6 +1141,7 @@ async function selfInstallCommand(): Promise<void> {
1141
1141
  const r = selfInstall({ env: process.env, sourceEntry: import.meta.path })
1142
1142
  process.stdout.write(`${BIN_NAME} self-install (idempotent)\n`)
1143
1143
  process.stdout.write(` bin: ${r.binPath} (${r.binMode})\n`)
1144
+ process.stdout.write(` signature: ${r.signature}\n`)
1144
1145
  process.stdout.write(` manifest: ${r.manifestPath}\n`)
1145
1146
  process.stdout.write(` root: ${r.root}\n`)
1146
1147
  process.stdout.write(` docs: ${r.docs.copied ? r.docs.dest : `skipped (${r.docs.reason ?? 'unknown'})`}\n`)
@@ -1148,6 +1149,7 @@ async function selfInstallCommand(): Promise<void> {
1148
1149
  bin: r.binPath,
1149
1150
  manifest: r.manifestPath,
1150
1151
  binMode: r.binMode,
1152
+ signature: r.signature,
1151
1153
  root: r.root,
1152
1154
  docs: r.docs,
1153
1155
  })
@@ -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
+ }
@@ -19,13 +19,20 @@
19
19
  // (the package now owns this path); a sandbox proof sets IAPEER_BIN_DIR so a test never
20
20
  // touches the real ~/.local/bin.
21
21
 
22
- import { spawnSync } from 'child_process'
23
- import { chmodSync, copyFileSync, existsSync, mkdirSync, renameSync, statSync } from 'fs'
22
+ import { chmodSync, copyFileSync, mkdirSync, renameSync, rmSync, statSync } from 'fs'
23
+ import { publishInstallPair } from './installPair.ts'
24
+ import { randomUUID } from 'crypto'
24
25
  import { homedir } from 'os'
25
26
  import { dirname, join } from 'path'
26
27
  import { BIN_NAME, PACKAGE_NAME } from './constants.ts'
27
28
  import { scaffoldHostDocs, type HostDocsResult } from './hostDocs.ts'
28
- import { buildManifest, resolveIapeerRoot, writeManifestAtomic } from './manifest.ts'
29
+ import { buildManifest, resolveIapeerRoot } from './manifest.ts'
30
+ import {
31
+ ensureExecutableSignature,
32
+ runInstallCommand,
33
+ type ExecutableSignatureState,
34
+ type InstallCommandRunner,
35
+ } from './installSignature.ts'
29
36
 
30
37
  /** The launcher binary name (`voicetalk-runtime`). Re-exported so callers/tests have a
31
38
  * single import surface for the self-install contract. */
@@ -44,6 +51,11 @@ export interface SelfInstallOptions {
44
51
  * (`<pkg>/src/cli.ts` → `<pkg>/docs`). Missing (e.g. running from a compiled bin with
45
52
  * no source tree) → soft-skip, never fails the install. */
46
53
  docsSource?: string
54
+ /** Test seams: no host-global mocks or environment-based signature bypass. */
55
+ runner?: InstallCommandRunner
56
+ platform?: NodeJS.Platform
57
+ /** Synchronous publication fault seam for tests; no environment bypass. */
58
+ publishRename?: typeof renameSync
47
59
  }
48
60
 
49
61
  export type SelfInstallBinMode = 'compiled' | 'copied-self'
@@ -54,6 +66,7 @@ export interface SelfInstallResult {
54
66
  binMode: SelfInstallBinMode
55
67
  root: string
56
68
  binDir: string
69
+ signature: ExecutableSignatureState
57
70
  /** On-host docs scaffolding verdict (best-effort; never blocks the install). */
58
71
  docs: HostDocsResult
59
72
  }
@@ -88,20 +101,12 @@ function isBunInterpreter(execPath: string): boolean {
88
101
 
89
102
  function cleanup(path: string): void {
90
103
  try {
91
- if (existsSync(path)) renameSync(path, `${path}.dead`)
104
+ rmSync(path, { force: true })
92
105
  } catch {
93
106
  // best-effort
94
107
  }
95
108
  }
96
109
 
97
- /** Tiny deterministic string hash for a unique-ish tmp suffix (Math.random is avoided
98
- * to keep the tmp name reproducible within a run). */
99
- function hashStr(s: string): number {
100
- let h = 0
101
- for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
102
- return h
103
- }
104
-
105
110
  /**
106
111
  * Self-install: place the launcher on PATH + write the manifest. IDEMPOTENT — a
107
112
  * repeat run produces the same on-disk bin and a byte-identical manifest (atomic
@@ -116,42 +121,51 @@ export function selfInstall(opts: SelfInstallOptions = {}): SelfInstallResult {
116
121
  const binDir = resolveBinDir(env)
117
122
  const binPath = join(binDir, BIN_NAME)
118
123
 
124
+ // Fail closed BEFORE any write/process: a sandbox must isolate BOTH surfaces, so
125
+ // both must be EXPLICIT overrides. Comparing against a "host home" cannot work —
126
+ // Bun's os.userInfo().homedir follows $HOME, which a sandbox points at itself.
127
+ if ((env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1')
128
+ && (!env.IAPEER_BIN_DIR?.trim() || !env.IAPEER_ROOT?.trim())) {
129
+ throw new Error('self-install: refusing real host bin/root under IAPEER_TEST_SANDBOX=1 — set IAPEER_BIN_DIR and IAPEER_ROOT')
130
+ }
119
131
  mkdirSync(binDir, { recursive: true, mode: 0o755 })
120
132
 
121
133
  // Build to a sibling tmp in the SAME dir (rename is atomic only within one FS), then
122
- // chmod +x and rename over the target. A reader of `voicetalk-runtime` never sees a
123
- // half-written file, and a legacy symlink at binPath is replaced atomically.
124
- const tmp = `${binPath}.tmp.${process.pid}.${Math.abs(hashStr(binPath + execPath))}`
134
+ // chmod +x, verify/repair its macOS signature, THEN rename over the target. A
135
+ // reader never sees a half-written file; a legacy symlink is replaced atomically.
136
+ const tmp = `${binPath}.tmp.${process.pid}.${randomUUID()}`
137
+ const run = opts.runner ?? runInstallCommand
125
138
  let binMode: SelfInstallBinMode
139
+ let signature: ExecutableSignatureState
140
+ let manifestPath: string
126
141
 
127
- if (isCompilableSource(sourceEntry) && isBunInterpreter(execPath)) {
128
- // PRIMARY path: running under bun from the package source (npx → bin → bun cli.ts).
129
- // Compile a self-contained snapshot so the installed bin does not depend on the npm
130
- // cache (GC'd) or the source tree.
131
- const r = spawnSync(execPath, ['build', '--compile', '--outfile', tmp, sourceEntry], {
132
- encoding: 'utf8',
133
- env: env as Record<string, string>,
134
- })
135
- if (r.error || (r.status ?? 1) !== 0) {
136
- cleanup(tmp)
137
- throw new Error(
138
- `self-install: bun build --compile failed: ${(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim()}`,
139
- )
142
+ try {
143
+ if (isCompilableSource(sourceEntry) && isBunInterpreter(execPath)) {
144
+ // PRIMARY: compile a snapshot decoupled from the npm cache/source tree.
145
+ const r = run(execPath, ['build', '--compile', '--outfile', tmp, sourceEntry], env)
146
+ if (r.error || (r.status ?? 1) !== 0) {
147
+ throw new Error(
148
+ `self-install: bun build --compile failed: ${(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim()}`,
149
+ )
150
+ }
151
+ binMode = 'compiled'
152
+ } else {
153
+ // FALLBACK: running from a compiled bin; re-assert by copying the executable.
154
+ copyFileSync(execPath, tmp)
155
+ binMode = 'copied-self'
140
156
  }
141
- binMode = 'compiled'
142
- } else {
143
- // FALLBACK: running from an already-compiled bin (no .ts source / execPath is not
144
- // bun). Re-assert the install by copying the running executable into binDir.
145
- copyFileSync(execPath, tmp)
146
- binMode = 'copied-self'
147
- }
148
-
149
- chmodSync(tmp, 0o755)
150
- renameSync(tmp, binPath)
151
157
 
152
- // Manifest pins the ABSOLUTE installed bin into the self-config descriptor.
153
- const manifest = buildManifest(binPath)
154
- const manifestPath = writeManifestAtomic(manifest, env)
158
+ chmodSync(tmp, 0o755)
159
+ signature = ensureExecutableSignature(tmp, { env, runner: run, platform: opts.platform })
160
+ // Pair publication: the manifest (pinning the ABSOLUTE installed bin into the
161
+ // self-config descriptor) is staged BEFORE the binary is published; a manifest
162
+ // rename failure restores the previous binary inode (or removes a first install).
163
+ manifestPath = publishInstallPair(tmp, binPath, buildManifest(binPath), env, opts.publishRename)
164
+ } finally {
165
+ // Includes partial compiler/copy output and chmod/sign/rename failures. Do not
166
+ // leave .dead binaries; a failed signature gate never reaches the manifest.
167
+ cleanup(tmp)
168
+ }
155
169
 
156
170
  // On-host docs — stage this version's public docs to <root>/docs/voicetalk-runtime/.
157
171
  // Best-effort: scaffoldHostDocs already soft-skips missing source / copy errors; the
@@ -168,5 +182,5 @@ export function selfInstall(opts: SelfInstallOptions = {}): SelfInstallResult {
168
182
  }
169
183
  }
170
184
 
171
- return { binPath, manifestPath, binMode, root: resolveIapeerRoot(env), binDir, docs }
185
+ return { binPath, manifestPath, binMode, signature, root: resolveIapeerRoot(env), binDir, docs }
172
186
  }
@@ -114,13 +114,15 @@ export type TranscribeOptions = {
114
114
  lang?: string
115
115
  /** Force an STT engine (`mlx-whisper` keeps it fully local); default = service cascade. */
116
116
  engine?: string
117
- /** Decoder-priming prompt — biases spelling of fleet names so the wake matcher sees
118
- * «Борис», not «Барис». */
119
- prompt?: string
120
117
  timeoutMs?: number
121
118
  }
122
119
 
123
- /** Transcribe a local audio file. Returns the raw transcript (may be empty). */
120
+ /** Transcribe a local audio file. Returns the raw transcript (may be empty).
121
+ *
122
+ * Deliberately no `prompt` option: this client feeds a presence detector, so configured
123
+ * wake words must never be injected into the decoder and then mistaken for acoustic
124
+ * evidence. voice-connect supports prompts for other consumers; voicetalk's door does not.
125
+ */
124
126
  export async function transcribe(audioPath: string, opts: TranscribeOptions = {}): Promise<string> {
125
127
  const ep = resolveVoiceEndpoint()
126
128
  const form = new FormData()
@@ -128,7 +130,6 @@ export async function transcribe(audioPath: string, opts: TranscribeOptions = {}
128
130
  form.append('file', new Blob([new Uint8Array(bytes)], { type: 'audio/wav' }), audioPath.split('/').pop() ?? 'audio.wav')
129
131
  if (opts.lang) form.append('language', opts.lang)
130
132
  if (opts.engine) form.append('engine', opts.engine)
131
- if (opts.prompt) form.append('prompt', opts.prompt)
132
133
  form.append('response_format', 'json')
133
134
  const res = await fetch(ep.stt, {
134
135
  method: 'POST',