@agfpd/voicetalk-runtime 0.3.1 → 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 ADDED
@@ -0,0 +1,112 @@
1
+ # Changelog
2
+
3
+ All notable changes to **@agfpd/voicetalk-runtime** are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.3] - 2026-10-18
11
+
12
+ ### Fixed
13
+
14
+ - Self-install publishes the binary and manifest as one pair. 0.3.1 and the
15
+ unpublished 0.3.2 replaced the binary before writing the manifest, so a manifest
16
+ failure (reproduced with a real ENOTDIR) left the new binary next to the old
17
+ manifest. The manifest is now staged first; a manifest rename failure restores the
18
+ previous binary inode/signature/mode or removes a first-install binary, and a
19
+ failed rollback reports the retained recovery path.
20
+ - Under `IAPEER_TEST_SANDBOX=1` self-install refuses to run unless both
21
+ `IAPEER_BIN_DIR` and `IAPEER_ROOT` are explicit.
22
+
23
+ ### Added
24
+
25
+ - `scripts/verify-install.ts` (+ `verify-install-pair.ts`) shipped in the package:
26
+ a standalone native macOS proof run from the unpacked archive, including a live
27
+ headless router run of the installed binary.
28
+ - `repository.url` and a dispatch-only `publish.yml` (npm trusted publishing, exact
29
+ accepted bytes: pack → sha256 check → native proof → publish → registry integrity).
30
+
31
+ ## [0.3.2] - 2026-10-06
32
+
33
+ ### Fixed
34
+
35
+ - macOS self-install strictly verifies the staged executable after compile/copy and
36
+ chmod, before publishing the binary or manifest. Only an invalid signature is
37
+ repaired with ad-hoc signing, followed by another strict verification. Valid
38
+ signatures are preserved; verifier/repair failures leave the previous binary and
39
+ manifest untouched and remove staging files (including partial compiler output).
40
+
41
+ ### Added
42
+
43
+ - Injectable command/platform seams, ordering and failure tests for both install
44
+ paths, and real macOS compile/execute/copy-self/repair/refusal acceptance in a
45
+ disposable sandbox. The acceptance test can target an unpacked npm artifact and
46
+ runs the installed binary's text router E2E without live audio or fleet delivery.
47
+
48
+ ## [0.3.1] - 2026-07-24
49
+
50
+ ### Fixed
51
+
52
+ - Wake recognition is decoded without the configured names as a Whisper
53
+ `initial_prompt`. The prompt was generative context, not acoustic evidence: on
54
+ degraded but name-free speech it could manufacture an exact configured name and
55
+ wake an agent.
56
+ - A closed dialogue now activates only from an exact leading address. Inexact
57
+ candidates, repeated-token decoder loops, and transcripts whose body merely echoes
58
+ configured names fail closed before `turn.open` and IAP delivery. Open-dialogue
59
+ continuation remains unchanged.
60
+
61
+ ### Added
62
+
63
+ - A private bounded ring (`tmp/wake-captures`, 16 candidate segments by default)
64
+ retains the WAV plus the routing verdict, transcript, audio measurements, and PCM
65
+ hash for accepted and rejected wake candidates. This makes a disputed activation
66
+ replayable through `VOICETALK_MIC_FILE` without continuous room recording.
67
+
68
+ ## [0.1.0] - 2026-06-22
69
+
70
+ ### Added — Фаза 2 (runtime skeleton; text caркас, audio stubbed)
71
+
72
+ - `run` — the bidirectional router loop. Outbound: an `<iap>…</iap>` reader on STDIN
73
+ (CDATA-aware) hands each envelope to an `AudioSink`; inbound: a per-owner unix socket
74
+ receives utterances and delivers them to the fleet. Audio touchpoints are stubbed —
75
+ the sink logs the spoken text (and appends to `spoken.log`), the socket stands in for
76
+ mic + wake-word + STT. The seams are the `voice-connect` plug-in points for Фаза 3.
77
+ - `say --peer <personality> <text…>` — inject an utterance over the running router's
78
+ inbound socket (the Фаза-2 mic+STT stand-in). Frames the text for the spoken modality
79
+ and delivers it via the daemon (`iapeer send`) from the owner's identity
80
+ (`from-runtime=voicetalk`).
81
+ - `src/manifest.ts` — `RuntimeManifest` self-deploy (operator-add: no `peers[]`),
82
+ IAPEER_ROOT-aware, atomic + idempotent.
83
+ - `src/selfInstall.ts` + bare/`self-install` — npx self-deploy: compiled launcher on
84
+ PATH + manifest + on-host docs. `src/hostDocs.ts` mirrors the foundation scaffolder.
85
+ - `src/selfConfig.ts` + `self-config` — the per-peer hook merging
86
+ `interfaces.voicetalk` (`{ wake_word, device? }`) from env, preserving the
87
+ foundation-provisioned identity (`intelligence=natural`).
88
+ - `interface --wake-word <w> [--device <d>]` — manual binding setter; `doctor` reports
89
+ the binding + manifest health.
90
+ - Tests: envelope parsing, the inbound `iapeer send` wrapper (verdicts/timeout),
91
+ manifest round-trip, the self-config hook, and a **live text E2E** that drives the
92
+ actual `run` process both directions with the daemon faked.
93
+ - No sender-guard (decision: the speaker is a single local endpoint of the owner; any
94
+ fleet peer may answer by voice).
95
+
96
+ ## [0.0.1] - 2026-06-21
97
+
98
+ ### Added
99
+
100
+ - Project scaffold: bun package skeleton mirroring the iapeer runtime contract
101
+ (`package.json`, `tsconfig.json`, `bin/voicetalk-runtime`, `.gitignore`, CI).
102
+ - `src/constants.ts` — package-facing contract values (`RUNTIME` = `voicetalk`,
103
+ `BIN_NAME`, `PACKAGE_NAME`, `VERSION`, `NAME_RE`).
104
+ - `src/cli.ts` — CLI skeleton: `run` (Фаза-0 stub), `doctor`, `--version`,
105
+ `--help`. The audio pipeline (wake-word / STT / TTS) is not implemented yet.
106
+ - Smoke tests (`tests/cli.test.ts`) and the test + typecheck CI gate.
107
+
108
+ [Unreleased]: https://github.com/agfpd/voicetalk-runtime/compare/v0.3.2...HEAD
109
+ [0.3.2]: https://github.com/agfpd/voicetalk-runtime/compare/v0.3.1...v0.3.2
110
+ [0.3.1]: https://github.com/agfpd/voicetalk-runtime/compare/v0.3.0...v0.3.1
111
+ [0.1.0]: https://github.com/agfpd/voicetalk-runtime/compare/v0.0.1...v0.1.0
112
+ [0.0.1]: https://github.com/agfpd/voicetalk-runtime/releases/tag/v0.0.1
package/docs/README.md CHANGED
@@ -97,6 +97,55 @@ still performed and timed. `scripts/replay-dialogue.sh` wires both into a sandbo
97
97
  `VOICETALK_VOICE_ENDPOINT`).
98
98
  - Microphone permission (TCC) granted once to the process that runs the runtime.
99
99
 
100
+ ## Install integrity on macOS
101
+
102
+ Bare invocation (`npx -y @agfpd/voicetalk-runtime@<version>`) and `self-install`
103
+ stage the compiled or copied executable beside its destination. After chmod, before
104
+ binary rename and manifest publication, `/usr/bin/codesign --verify --deep --strict`
105
+ must pass. If the signature is invalid, the installer runs `--force --sign -` on
106
+ that staging file, then strictly verifies again. Verification-tool failures or an
107
+ unsuccessful repair abort installation, retain the previous binary and manifest,
108
+ and remove the staging file. No core/helper package dependency is required.
109
+
110
+ A valid signature is never replaced. Repair may change the TCC identity and require
111
+ permission to be granted again; this gate checks local signature integrity, not
112
+ notarization or code provenance. See Apple's
113
+ [code-signing verification guidance](https://developer.apple.com/library/archive/technotes/tn2206/).
114
+
115
+ The binary and manifest are published as one pair. The manifest is staged before
116
+ the binary is published, and the previous binary is kept as a hard link until the
117
+ manifest rename succeeds. If any catchable I/O error happens after the binary is in
118
+ place (for example ENOTDIR or EACCES), the previous binary inode, signature and mode
119
+ are restored, or a first-install binary is removed. If that rollback also fails, the
120
+ error names the path that still holds the previous binary. This is not atomic across
121
+ power loss, SIGKILL or concurrent installers.
122
+
123
+ Artifact proof shipped in the package (macOS, Bun, system tools; no checkout,
124
+ daemon, microphone, speaker or voice service):
125
+
126
+ ```sh
127
+ mkdir /tmp/vt-proof && tar -xzf /absolute/agfpd-voicetalk-runtime-<version>.tgz -C /tmp/vt-proof
128
+ bun /tmp/vt-proof/package/scripts/verify-install.ts \
129
+ [/tmp/vt-proof/package] [/absolute/previous-package-dir] [/absolute/recovery-backup-dir]
130
+ ```
131
+
132
+ It compiles through the shipped CLI under strict codesign, runs the installed router
133
+ headless (doctor, outbound envelope to the audio sink, inbound `say` to a stub
134
+ daemon), checks copy-self, unsigned repair, real ENOTDIR and late EACCES with the
135
+ previous pair preserved, rejection of a truncated Mach-O and of a strict-invalid
136
+ minimal compile, and exact recovery of the previous pair. All writes go to a
137
+ temporary `/tmp/vtp-*` sandbox that is removed afterwards. Passing an unpacked
138
+ previous package also reproduces that installer's ENOTDIR defect as a
139
+ before-and-after contrast. It does not prove live audio or delivery to a real fleet.
140
+
141
+ Developer acceptance (macOS, no live channel mutation):
142
+
143
+ ```sh
144
+ bun test tests/installSignature.test.ts tests/selfInstall.test.ts tests/selfInstall-macos.test.ts
145
+ # To repeat real acceptance against an unpacked npm tarball:
146
+ VOICETALK_INSTALL_PACKAGE_ROOT=/absolute/path/to/package bun test tests/selfInstall-macos.test.ts
147
+ ```
148
+
100
149
  These pages are written as each phase lands. Russian translation lives in
101
150
  [`ru/`](ru/README.md).
102
151
 
package/docs/ru/README.md CHANGED
@@ -96,6 +96,55 @@ JSON-вердикт в `<IAPEER_ROOT>/runtimes/voicetalk/tmp/wake-captures`. К
96
96
  (переопределяется `VOICETALK_VOICE_ENDPOINT`).
97
97
  - Разрешение на микрофон (TCC), выданное один раз процессу рантайма.
98
98
 
99
+ ## Целостность установки на macOS
100
+
101
+ Запуск без подкоманды (`npx -y @agfpd/voicetalk-runtime@<версия>`) и `self-install`
102
+ собирают или копируют исполняемый файл во временный файл рядом с целевым бинарником.
103
+ После chmod, до rename бинарника и публикации манифеста, обязательна проверка
104
+ `/usr/bin/codesign --verify --deep --strict`. Невалидная подпись исправляется
105
+ `--force --sign -` только на этом временном файле, затем повторяется строгая проверка.
106
+ Отказ средства проверки или неуспешное исправление прерывает установку: прежние
107
+ бинарник и манифест не изменяются, временный файл удаляется. Зависимость от пакета
108
+ ядра или его helper-модуля не требуется.
109
+
110
+ Валидная подпись не переписывается. Исправление может изменить идентичность TCC и
111
+ потребовать повторной выдачи разрешения; проверка подтверждает целостность локальной
112
+ подписи, не нотариализацию или происхождение кода. См.
113
+ [руководство Apple по проверке подписи](https://developer.apple.com/library/archive/technotes/tn2206/).
114
+
115
+ Бинарник и манифест публикуются парой. Манифест готовится до публикации бинарника,
116
+ а прежний бинарник сохраняется жёсткой ссылкой, пока rename манифеста не завершится.
117
+ При перехватываемой ошибке ввода-вывода после замены бинарника (например, ENOTDIR или
118
+ EACCES) восстанавливаются прежние inode, подпись и права бинарника, а бинарник первой
119
+ установки удаляется. Если откат тоже не удался, ошибка называет путь к сохранённой
120
+ копии. Атомарности при потере питания, SIGKILL и параллельных установщиках нет.
121
+
122
+ Проверка артефакта поставляется в пакете (macOS, Bun, системные утилиты; без
123
+ репозитория, демона, микрофона, динамика и голосового сервиса):
124
+
125
+ ```sh
126
+ mkdir /tmp/vt-proof && tar -xzf /absolute/agfpd-voicetalk-runtime-<версия>.tgz -C /tmp/vt-proof
127
+ bun /tmp/vt-proof/package/scripts/verify-install.ts \
128
+ [/tmp/vt-proof/package] [/absolute/previous-package-dir] [/absolute/recovery-backup-dir]
129
+ ```
130
+
131
+ Скрипт собирает бинарник через поставляемый CLI со строгой проверкой подписи, запускает
132
+ установленный роутер без аудио (doctor, исходящий конверт в аудиоприёмник, входящий
133
+ `say` в подставной демон), проверяет copy-self, исправление неподписанной копии,
134
+ реальные ENOTDIR и поздний EACCES с сохранением прежней пары, отказ усечённому Mach-O
135
+ и строго невалидной минимальной сборке, точное восстановление прежней пары. Все записи
136
+ идут во временную песочницу `/tmp/vtp-*`, которая затем удаляется. С распакованным
137
+ предыдущим пакетом дополнительно воспроизводится дефект ENOTDIR его установщика как
138
+ контраст «до/после». Живой звук и доставка реальному флоту не проверяются.
139
+
140
+ Проверка разработчика на macOS без изменения живого канала:
141
+
142
+ ```sh
143
+ bun test tests/installSignature.test.ts tests/selfInstall.test.ts tests/selfInstall-macos.test.ts
144
+ # Повтор настоящей приёмки на распакованном npm-артефакте:
145
+ VOICETALK_INSTALL_PACKAGE_ROOT=/absolute/path/to/package bun test tests/selfInstall-macos.test.ts
146
+ ```
147
+
99
148
  Страницы пишутся по мере прохождения фаз.
100
149
 
101
150
  ## Источники дизайна
package/package.json CHANGED
@@ -1,17 +1,24 @@
1
1
  {
2
2
  "name": "@agfpd/voicetalk-runtime",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Voice runtime-router for IAP peers.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/agfpd/voicetalk-runtime.git"
8
+ },
5
9
  "license": "Apache-2.0",
6
10
  "type": "module",
7
11
  "bin": {
8
12
  "voicetalk-runtime": "bin/voicetalk-runtime"
9
13
  },
10
14
  "files": [
15
+ "CHANGELOG.md",
11
16
  "bin/",
12
17
  "src/",
13
18
  "docs/*.md",
14
19
  "docs/ru/",
20
+ "scripts/verify-install.ts",
21
+ "scripts/verify-install-pair.ts",
15
22
  "tsconfig.json"
16
23
  ],
17
24
  "scripts": {
@@ -0,0 +1,145 @@
1
+ // Real filesystem regression, imported by the shipped standalone proof.
2
+ import { strict as assert } from 'assert'
3
+ import { execFileSync } from 'child_process'
4
+ import { createHash } from 'crypto'
5
+ import { chmodSync, copyFileSync, cpSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'fs'
6
+ import { dirname, join, relative } from 'path'
7
+ import type { selfInstall as Install } from '../src/selfInstall.ts'
8
+
9
+ type Tree = Record<string, { mode: number; kind: string; sha256?: string }>
10
+ const hash = (path: string) => createHash('sha256').update(readFileSync(path)).digest('hex')
11
+ function tree(path: string): Tree {
12
+ const result: Tree = {}
13
+ const walk = (p: string) => {
14
+ const s = lstatSync(p)
15
+ assert.ok(s.isDirectory() || s.isFile(), `proof fixture must not contain symlinks: ${p}`)
16
+ result[relative(path, p) || '.'] = { mode: s.mode & 0o7777, kind: s.isDirectory() ? 'dir' : 'file',
17
+ ...(s.isFile() ? { sha256: hash(p) } : {}) }
18
+ if (s.isDirectory()) for (const name of readdirSync(p).sort()) walk(join(p, name))
19
+ }
20
+ walk(path)
21
+ return result
22
+ }
23
+ function copyExact(source: string, dest: string): void {
24
+ mkdirSync(dirname(dest), { recursive: true })
25
+ if (lstatSync(source).isDirectory()) cpSync(source, dest, { recursive: true })
26
+ else copyFileSync(source, dest)
27
+ for (const [name, node] of Object.entries(tree(source))) chmodSync(name === '.' ? dest : join(dest, name), node.mode)
28
+ assert.deepEqual(tree(dest), tree(source))
29
+ }
30
+
31
+ export async function proveInstallPair(options: {
32
+ pkg: string; tmp: string; env: NodeJS.ProcessEnv; candidateBin: string; selfInstall: typeof Install
33
+ run: (bin: string, version: string, env: NodeJS.ProcessEnv) => Promise<void>
34
+ previousPkg?: string; recoveryBackup?: string
35
+ }): Promise<void> {
36
+ const { pkg, tmp, candidateBin, selfInstall, run } = options
37
+ const version = JSON.parse(readFileSync(join(pkg, 'package.json'), 'utf8')).version as string
38
+ const env = { ...options.env, IAPEER_ROOT: join(tmp, 'pair-root'), IAPEER_BIN_DIR: join(tmp, 'pair-bin') }
39
+ const bin = join(env.IAPEER_BIN_DIR, 'voicetalk-runtime')
40
+ const manifest = join(env.IAPEER_ROOT, 'runtimes/voicetalk/runtime.json')
41
+ const docs = join(env.IAPEER_ROOT, 'docs/voicetalk-runtime')
42
+ const strict = (path: string) => execFileSync('/usr/bin/codesign', ['--verify', '--deep', '--strict', path], { env })
43
+ const runningVersion = () => execFileSync(bin, ['--version'], { env, encoding: 'utf8' }).trim()
44
+ const targets = { bin, manifest, docs }
45
+ const saved = join(tmp, 'pair-backup')
46
+ if (options.recoveryBackup) {
47
+ // Optional readonly accepted host backup. It is NEVER a destination.
48
+ const baseline = JSON.parse(readFileSync(join(options.recoveryBackup, 'baseline.json'), 'utf8'))
49
+ for (const [key, dest] of Object.entries(targets)) {
50
+ const entry = baseline.paths[key === 'bin' ? 'binary' : key]
51
+ const source = join(options.recoveryBackup, entry.backup)
52
+ const inventory = tree(source)
53
+ for (const [name, node] of Object.entries(inventory)) {
54
+ assert.equal(node.mode, entry.tree[name].mode)
55
+ assert.equal(node.kind, entry.tree[name].kind)
56
+ if (node.kind === 'file') assert.equal(node.sha256, entry.tree[name].sha256)
57
+ }
58
+ assert.equal(Object.keys(inventory).length, Object.keys(entry.tree).length)
59
+ copyExact(source, dest)
60
+ }
61
+ } else {
62
+ // Self-contained old native fixture: same working application, deliberately
63
+ // distinct valid ad-hoc signature. No host binary/backup is required.
64
+ mkdirSync(dirname(bin), { recursive: true })
65
+ copyFileSync(candidateBin, bin)
66
+ execFileSync('/usr/bin/codesign', ['--force', '--sign', '-', '--identifier', 'voicetalk-runtime.proof.previous', bin], { env })
67
+ mkdirSync(dirname(manifest), { recursive: true })
68
+ writeFileSync(manifest, `${JSON.stringify({ runtime: 'voicetalk', version, selfConfig: { command: bin, args: ['self-config'] } }, null, 4)}\n\n`)
69
+ mkdirSync(docs, { recursive: true })
70
+ for (const name of readdirSync(join(pkg, 'docs')).filter(n => n.endsWith('.md'))) copyExact(join(pkg, 'docs', name), join(docs, name))
71
+ }
72
+ strict(bin)
73
+ const oldVersion = runningVersion()
74
+ assert.equal(JSON.parse(readFileSync(manifest, 'utf8')).version, oldVersion)
75
+ assert.notEqual(hash(bin), hash(candidateBin), 'old native must differ, otherwise replacement is invisible')
76
+ for (const [key, path] of Object.entries(targets)) copyExact(path, join(saved, key))
77
+ const before = () => Object.fromEntries(Object.entries(targets).map(([key, path]) => [key, tree(path)]))
78
+ const original = before()
79
+ await run(bin, oldVersion, env)
80
+ const manifestDirEntries = readdirSync(dirname(manifest)).sort() // the router creates its inbox/spoken.log here
81
+ const blocked = join(tmp, 'not-a-root')
82
+ writeFileSync(blocked, 'ordinary file occupying IAPEER_ROOT')
83
+ const copyOptions = { env, sourceEntry: join(tmp, 'missing-pair-entry.ts'), execPath: candidateBin, docsSource: join(pkg, 'docs') }
84
+
85
+ if (options.previousPkg) {
86
+ const { selfInstall: previousInstall } = await import(join(options.previousPkg, 'src/selfInstall.ts')) as { selfInstall: typeof Install }
87
+ assert.throws(() => previousInstall({ ...copyOptions, env: { ...env, IAPEER_ROOT: blocked } }), /ENOTDIR/)
88
+ assert.equal(hash(bin), hash(candidateBin), 'previous installer must reproduce the binary replacement defect')
89
+ assert.deepEqual(tree(manifest), original.manifest)
90
+ console.log('BEFORE reproduced: published previous installer ENOTDIR leaves NEW binary + OLD manifest')
91
+ copyExact(join(saved, 'bin'), bin + '.restore.tmp')
92
+ strict(bin + '.restore.tmp'); renameSync(bin + '.restore.tmp', bin)
93
+ assert.deepEqual(before(), original)
94
+ }
95
+
96
+ assert.throws(() => selfInstall({ ...copyOptions, env: { ...env, IAPEER_ROOT: blocked } }), /ENOTDIR/)
97
+ assert.deepEqual(before(), original)
98
+ assert.equal(readFileSync(blocked, 'utf8'), 'ordinary file occupying IAPEER_ROOT')
99
+ assert.deepEqual(readdirSync(env.IAPEER_BIN_DIR), ['voicetalk-runtime'])
100
+ console.log(`AFTER real ENOTDIR: old pair unchanged, bin=${hash(bin)} manifest=${hash(manifest)}`)
101
+
102
+ const inode = lstatSync(bin).ino
103
+ let attempts = 0
104
+ let failure: unknown
105
+ try {
106
+ selfInstall({ ...copyOptions, publishRename: (from, to) => {
107
+ attempts++
108
+ if (to !== manifest) return renameSync(from, to)
109
+ assert.equal(hash(bin), hash(candidateBin), 'late failure must happen AFTER binary publication')
110
+ assert.deepEqual(tree(manifest), original.manifest)
111
+ const mode = lstatSync(dirname(manifest)).mode & 0o7777
112
+ chmodSync(dirname(manifest), 0o555)
113
+ try { renameSync(from, to) } // real OS error, not a mocked rename result
114
+ finally { chmodSync(dirname(manifest), mode) }
115
+ } })
116
+ } catch (e) { failure = e }
117
+ assert.equal((failure as NodeJS.ErrnoException)?.code, 'EACCES', `expected real unprivileged EACCES, got ${failure}`)
118
+ assert.equal(attempts, 3, 'binary rename, failed manifest rename, old inode rollback')
119
+ assert.equal(lstatSync(bin).ino, inode)
120
+ assert.deepEqual(before(), original)
121
+ strict(bin)
122
+ assert.deepEqual(readdirSync(env.IAPEER_BIN_DIR), ['voicetalk-runtime'])
123
+ assert.deepEqual(readdirSync(dirname(manifest)).sort(), manifestDirEntries)
124
+ console.log(`AFTER real late EACCES: binary inode/signature/mode restored, manifest unchanged; version=${oldVersion}`)
125
+ await run(bin, oldVersion, env)
126
+
127
+ // Successful native install, then verify-only recovery of all three surfaces.
128
+ selfInstall(copyOptions)
129
+ strict(bin); assert.equal(runningVersion(), version)
130
+ assert.equal(JSON.parse(readFileSync(manifest, 'utf8')).version, version)
131
+ await run(bin, version, env)
132
+ const installed = before()
133
+ for (const [key, path] of Object.entries(targets)) copyExact(join(saved, key), path + '.restore.tmp')
134
+ strict(bin + '.restore.tmp')
135
+ assert.deepEqual(before(), installed, 'stage ALL recovery surfaces before replacing ANY')
136
+ renameSync(bin + '.restore.tmp', bin)
137
+ renameSync(manifest + '.restore.tmp', manifest)
138
+ renameSync(docs, docs + '.candidate.saved')
139
+ try { renameSync(docs + '.restore.tmp', docs) }
140
+ catch (e) { renameSync(docs + '.candidate.saved', docs); throw e }
141
+ assert.deepEqual(before(), original)
142
+ strict(bin); assert.equal(runningVersion(), oldVersion)
143
+ await run(bin, oldVersion, env)
144
+ console.log(`PASS native recovery ${oldVersion} -> ${version} -> ${oldVersion}: exact binary/manifest/docs bytes+modes, original signature`)
145
+ }
@@ -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
  }
package/tsconfig.json CHANGED
@@ -11,5 +11,5 @@
11
11
  "allowImportingTsExtensions": true,
12
12
  "forceConsistentCasingInFileNames": true
13
13
  },
14
- "include": ["src/**/*.ts", "tests/**/*.ts"]
14
+ "include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"]
15
15
  }