@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.
- package/CHANGELOG.md +112 -0
- package/README.md +7 -1
- package/docs/README.md +62 -1
- package/docs/ru/README.md +62 -1
- package/package.json +8 -1
- package/scripts/verify-install-pair.ts +145 -0
- package/scripts/verify-install.ts +202 -0
- package/src/cli.ts +2 -0
- package/src/installPair.ts +67 -0
- package/src/installSignature.ts +58 -0
- package/src/selfInstall.ts +56 -42
- package/src/voiceConnect.ts +6 -5
- package/src/voiceSession.ts +132 -17
- package/src/wake.ts +57 -24
- 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/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
|
+
}
|
package/src/selfInstall.ts
CHANGED
|
@@ -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 {
|
|
23
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
123
|
-
// half-written file
|
|
124
|
-
const tmp = `${binPath}.tmp.${process.pid}.${
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
}
|
package/src/voiceConnect.ts
CHANGED
|
@@ -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',
|