@agfpd/voicetalk-runtime 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,129 @@
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.4.0] - 2026-10-26
11
+
12
+ ### Added
13
+
14
+ - Native voice hand-off (pilot, `interfaces.voicetalk.native_voice` or
15
+ `VOICETALK_NATIVE_VOICE`): an accepted spoken wake for a listed target releases the
16
+ microphone (capture exit confirmed) and asks iapeer to open the peer's native Codex
17
+ `/voice` (`iapeer voice open|status|close <peer> --json`). The microphone returns to
18
+ wake mode only on a confirmed `closed`; failed/timed-out opens and unreadable receipts
19
+ keep it released. One wake → one open; outbound speech and earcons are held while the
20
+ peer owns the room; a restart adopts a still-open voice. Start-up preflight: if
21
+ `iapeer voice status` gives no readable answer, the target degrades to the STT path
22
+ (`native.unavailable`) instead of a listener that could never get its mic back.
23
+ - `interface --native-voice <peers>`; `Microphone.pause()/resume()` (replay skips the
24
+ handed-off span); `scripts/replay-dialogue.sh` gains a fake `iapeer voice` and a
25
+ `native-voice.pcm` fixture.
26
+
27
+ ## [0.3.3] - 2026-10-18
28
+
29
+ ### Fixed
30
+
31
+ - Self-install publishes the binary and manifest as one pair. 0.3.1 and the
32
+ unpublished 0.3.2 replaced the binary before writing the manifest, so a manifest
33
+ failure (reproduced with a real ENOTDIR) left the new binary next to the old
34
+ manifest. The manifest is now staged first; a manifest rename failure restores the
35
+ previous binary inode/signature/mode or removes a first-install binary, and a
36
+ failed rollback reports the retained recovery path.
37
+ - Under `IAPEER_TEST_SANDBOX=1` self-install refuses to run unless both
38
+ `IAPEER_BIN_DIR` and `IAPEER_ROOT` are explicit.
39
+
40
+ ### Added
41
+
42
+ - `scripts/verify-install.ts` (+ `verify-install-pair.ts`) shipped in the package:
43
+ a standalone native macOS proof run from the unpacked archive, including a live
44
+ headless router run of the installed binary.
45
+ - `repository.url` and a dispatch-only `publish.yml` (npm trusted publishing, exact
46
+ accepted bytes: pack → sha256 check → native proof → publish → registry integrity).
47
+
48
+ ## [0.3.2] - 2026-10-06
49
+
50
+ ### Fixed
51
+
52
+ - macOS self-install strictly verifies the staged executable after compile/copy and
53
+ chmod, before publishing the binary or manifest. Only an invalid signature is
54
+ repaired with ad-hoc signing, followed by another strict verification. Valid
55
+ signatures are preserved; verifier/repair failures leave the previous binary and
56
+ manifest untouched and remove staging files (including partial compiler output).
57
+
58
+ ### Added
59
+
60
+ - Injectable command/platform seams, ordering and failure tests for both install
61
+ paths, and real macOS compile/execute/copy-self/repair/refusal acceptance in a
62
+ disposable sandbox. The acceptance test can target an unpacked npm artifact and
63
+ runs the installed binary's text router E2E without live audio or fleet delivery.
64
+
65
+ ## [0.3.1] - 2026-07-24
66
+
67
+ ### Fixed
68
+
69
+ - Wake recognition is decoded without the configured names as a Whisper
70
+ `initial_prompt`. The prompt was generative context, not acoustic evidence: on
71
+ degraded but name-free speech it could manufacture an exact configured name and
72
+ wake an agent.
73
+ - A closed dialogue now activates only from an exact leading address. Inexact
74
+ candidates, repeated-token decoder loops, and transcripts whose body merely echoes
75
+ configured names fail closed before `turn.open` and IAP delivery. Open-dialogue
76
+ continuation remains unchanged.
77
+
78
+ ### Added
79
+
80
+ - A private bounded ring (`tmp/wake-captures`, 16 candidate segments by default)
81
+ retains the WAV plus the routing verdict, transcript, audio measurements, and PCM
82
+ hash for accepted and rejected wake candidates. This makes a disputed activation
83
+ replayable through `VOICETALK_MIC_FILE` without continuous room recording.
84
+
85
+ ## [0.1.0] - 2026-06-22
86
+
87
+ ### Added — Фаза 2 (runtime skeleton; text caркас, audio stubbed)
88
+
89
+ - `run` — the bidirectional router loop. Outbound: an `<iap>…</iap>` reader on STDIN
90
+ (CDATA-aware) hands each envelope to an `AudioSink`; inbound: a per-owner unix socket
91
+ receives utterances and delivers them to the fleet. Audio touchpoints are stubbed —
92
+ the sink logs the spoken text (and appends to `spoken.log`), the socket stands in for
93
+ mic + wake-word + STT. The seams are the `voice-connect` plug-in points for Фаза 3.
94
+ - `say --peer <personality> <text…>` — inject an utterance over the running router's
95
+ inbound socket (the Фаза-2 mic+STT stand-in). Frames the text for the spoken modality
96
+ and delivers it via the daemon (`iapeer send`) from the owner's identity
97
+ (`from-runtime=voicetalk`).
98
+ - `src/manifest.ts` — `RuntimeManifest` self-deploy (operator-add: no `peers[]`),
99
+ IAPEER_ROOT-aware, atomic + idempotent.
100
+ - `src/selfInstall.ts` + bare/`self-install` — npx self-deploy: compiled launcher on
101
+ PATH + manifest + on-host docs. `src/hostDocs.ts` mirrors the foundation scaffolder.
102
+ - `src/selfConfig.ts` + `self-config` — the per-peer hook merging
103
+ `interfaces.voicetalk` (`{ wake_word, device? }`) from env, preserving the
104
+ foundation-provisioned identity (`intelligence=natural`).
105
+ - `interface --wake-word <w> [--device <d>]` — manual binding setter; `doctor` reports
106
+ the binding + manifest health.
107
+ - Tests: envelope parsing, the inbound `iapeer send` wrapper (verdicts/timeout),
108
+ manifest round-trip, the self-config hook, and a **live text E2E** that drives the
109
+ actual `run` process both directions with the daemon faked.
110
+ - No sender-guard (decision: the speaker is a single local endpoint of the owner; any
111
+ fleet peer may answer by voice).
112
+
113
+ ## [0.0.1] - 2026-06-21
114
+
115
+ ### Added
116
+
117
+ - Project scaffold: bun package skeleton mirroring the iapeer runtime contract
118
+ (`package.json`, `tsconfig.json`, `bin/voicetalk-runtime`, `.gitignore`, CI).
119
+ - `src/constants.ts` — package-facing contract values (`RUNTIME` = `voicetalk`,
120
+ `BIN_NAME`, `PACKAGE_NAME`, `VERSION`, `NAME_RE`).
121
+ - `src/cli.ts` — CLI skeleton: `run` (Фаза-0 stub), `doctor`, `--version`,
122
+ `--help`. The audio pipeline (wake-word / STT / TTS) is not implemented yet.
123
+ - Smoke tests (`tests/cli.test.ts`) and the test + typecheck CI gate.
124
+
125
+ [Unreleased]: https://github.com/agfpd/voicetalk-runtime/compare/v0.3.2...HEAD
126
+ [0.3.2]: https://github.com/agfpd/voicetalk-runtime/compare/v0.3.1...v0.3.2
127
+ [0.3.1]: https://github.com/agfpd/voicetalk-runtime/compare/v0.3.0...v0.3.1
128
+ [0.1.0]: https://github.com/agfpd/voicetalk-runtime/compare/v0.0.1...v0.1.0
129
+ [0.0.1]: https://github.com/agfpd/voicetalk-runtime/releases/tag/v0.0.1
package/docs/README.md CHANGED
@@ -46,6 +46,37 @@ decoder prompt, because prompt text can be generated back on unrelated audio. A
46
46
  dialogue opens only on an exact leading address in the unprimed transcript; repeated
47
47
  token loops and name-only echoes are rejected before an IAP envelope exists.
48
48
 
49
+ ## Native voice — `native_voice` (pilot)
50
+
51
+ ```jsonc
52
+ { "native_voice": ["darwin"] } // or env VOICETALK_NATIVE_VOICE=darwin ("" disables)
53
+ ```
54
+
55
+ For a target listed here an accepted wake word does NOT start STT → envelope → TTS. It
56
+ hands the conversation to the peer's own realtime voice (Codex CLI `/voice`) inside its
57
+ live session, which iapeer opens and closes:
58
+
59
+ 1. The wake word must pass every fail-closed check above — native voice changes only
60
+ what an accepted activation does, never what counts as one.
61
+ 2. voicetalk kills its capture process and waits until it has actually exited (no
62
+ CoreAudio client left), plays the short "heard you" earcon while nobody is
63
+ listening, then runs `iapeer voice open <peer> --json`.
64
+ 3. While the peer holds the room voicetalk stays silent: no earcons, and any agent
65
+ answer that arrives is spoken only after the hand-off ends.
66
+ 4. voicetalk polls `iapeer voice status <peer> --json` (1 s) and takes the microphone
67
+ back ONLY on `state: closed`. A failed or timed-out open, an unreadable answer, and
68
+ `opening`/`closing` all keep the microphone released — a timeout is not evidence
69
+ that the peer stopped listening. Idle-close is iapeer's; after a 30-minute ceiling
70
+ (`VOICETALK_NATIVE_MAX_MS`) voicetalk asks `iapeer voice close`, and still waits for
71
+ the confirmed `closed`.
72
+ 5. Back in wake mode a soft "door closed" tone plays; the next turn needs a name again.
73
+ A runtime restart adopts a voice that is still open instead of grabbing the mic.
74
+ If at start iapeer does not answer `voice status` at all, the target falls back to
75
+ the STT path (`native.unavailable` in the log).
76
+
77
+ The words spoken after the name in the activating phrase are not forwarded; the
78
+ conversation itself happens in the peer's native voice. Other targets keep the STT path.
79
+
49
80
  ## How a turn works
50
81
 
51
82
  1. The microphone runs always-on; energy endpointing cuts an utterance when the
@@ -97,6 +128,55 @@ still performed and timed. `scripts/replay-dialogue.sh` wires both into a sandbo
97
128
  `VOICETALK_VOICE_ENDPOINT`).
98
129
  - Microphone permission (TCC) granted once to the process that runs the runtime.
99
130
 
131
+ ## Install integrity on macOS
132
+
133
+ Bare invocation (`npx -y @agfpd/voicetalk-runtime@<version>`) and `self-install`
134
+ stage the compiled or copied executable beside its destination. After chmod, before
135
+ binary rename and manifest publication, `/usr/bin/codesign --verify --deep --strict`
136
+ must pass. If the signature is invalid, the installer runs `--force --sign -` on
137
+ that staging file, then strictly verifies again. Verification-tool failures or an
138
+ unsuccessful repair abort installation, retain the previous binary and manifest,
139
+ and remove the staging file. No core/helper package dependency is required.
140
+
141
+ A valid signature is never replaced. Repair may change the TCC identity and require
142
+ permission to be granted again; this gate checks local signature integrity, not
143
+ notarization or code provenance. See Apple's
144
+ [code-signing verification guidance](https://developer.apple.com/library/archive/technotes/tn2206/).
145
+
146
+ The binary and manifest are published as one pair. The manifest is staged before
147
+ the binary is published, and the previous binary is kept as a hard link until the
148
+ manifest rename succeeds. If any catchable I/O error happens after the binary is in
149
+ place (for example ENOTDIR or EACCES), the previous binary inode, signature and mode
150
+ are restored, or a first-install binary is removed. If that rollback also fails, the
151
+ error names the path that still holds the previous binary. This is not atomic across
152
+ power loss, SIGKILL or concurrent installers.
153
+
154
+ Artifact proof shipped in the package (macOS, Bun, system tools; no checkout,
155
+ daemon, microphone, speaker or voice service):
156
+
157
+ ```sh
158
+ mkdir /tmp/vt-proof && tar -xzf /absolute/agfpd-voicetalk-runtime-<version>.tgz -C /tmp/vt-proof
159
+ bun /tmp/vt-proof/package/scripts/verify-install.ts \
160
+ [/tmp/vt-proof/package] [/absolute/previous-package-dir] [/absolute/recovery-backup-dir]
161
+ ```
162
+
163
+ It compiles through the shipped CLI under strict codesign, runs the installed router
164
+ headless (doctor, outbound envelope to the audio sink, inbound `say` to a stub
165
+ daemon), checks copy-self, unsigned repair, real ENOTDIR and late EACCES with the
166
+ previous pair preserved, rejection of a truncated Mach-O and of a strict-invalid
167
+ minimal compile, and exact recovery of the previous pair. All writes go to a
168
+ temporary `/tmp/vtp-*` sandbox that is removed afterwards. Passing an unpacked
169
+ previous package also reproduces that installer's ENOTDIR defect as a
170
+ before-and-after contrast. It does not prove live audio or delivery to a real fleet.
171
+
172
+ Developer acceptance (macOS, no live channel mutation):
173
+
174
+ ```sh
175
+ bun test tests/installSignature.test.ts tests/selfInstall.test.ts tests/selfInstall-macos.test.ts
176
+ # To repeat real acceptance against an unpacked npm tarball:
177
+ VOICETALK_INSTALL_PACKAGE_ROOT=/absolute/path/to/package bun test tests/selfInstall-macos.test.ts
178
+ ```
179
+
100
180
  These pages are written as each phase lands. Russian translation lives in
101
181
  [`ru/`](ru/README.md).
102
182
 
package/docs/ru/README.md CHANGED
@@ -46,6 +46,37 @@ prompt: такой контекст способен сам породить и
46
46
  открывает только точное имя в начале непримированного транскрипта; циклы повторяющихся
47
47
  токенов и тело из одних настроенных имён отбрасываются до появления IAP-конверта.
48
48
 
49
+ ## Нативный голос — `native_voice` (пилот)
50
+
51
+ ```jsonc
52
+ { "native_voice": ["darwin"] } // или env VOICETALK_NATIVE_VOICE=darwin ("" выключает)
53
+ ```
54
+
55
+ Для перечисленного адресата принятое wake-слово НЕ запускает STT → конверт → TTS, а
56
+ передаёт разговор в собственный realtime-голос пира (Codex CLI `/voice`) в его живой
57
+ сессии; открывает и закрывает голос iapeer.
58
+
59
+ 1. Wake-слово проходит все fail-closed проверки выше: режим меняет только то, что делает
60
+ принятая активация, но не то, что ею считается.
61
+ 2. voicetalk гасит процесс захвата и ждёт его фактического выхода (CoreAudio-клиента
62
+ нет), играет короткий earcon «услышал», пока никто не слушает, и вызывает
63
+ `iapeer voice open <peer> --json`.
64
+ 3. Пока комната у пира, voicetalk молчит: ни earcon'ов, а пришедший ответ агента
65
+ озвучивается только после возврата микрофона.
66
+ 4. voicetalk опрашивает `iapeer voice status <peer> --json` (1 с) и забирает микрофон
67
+ ТОЛЬКО по `state: closed`. Неудачный или просроченный open, нечитаемый ответ,
68
+ `opening`/`closing` — микрофон остаётся отпущенным: таймаут не доказывает, что пир
69
+ перестал слушать. Закрытие по простою — у iapeer; по потолку 30 минут
70
+ (`VOICETALK_NATIVE_MAX_MS`) voicetalk просит `iapeer voice close` и всё равно ждёт
71
+ подтверждённого `closed`.
72
+ 5. По возврату в wake-режим звучит мягкий тон «дверь закрыта»; следующий ход снова
73
+ требует имени. Рестарт рантайма принимает ещё открытый голос, а не хватает микрофон.
74
+ Если на старте iapeer вообще не отвечает на `voice status`, адресат уходит на прежний
75
+ STT-путь (`native.unavailable` в логе).
76
+
77
+ Слова после имени в активирующей фразе не пересылаются — разговор идёт уже нативным
78
+ голосом пира. Остальные адресаты — прежним STT-путём.
79
+
49
80
  ## Как устроен ход разговора
50
81
 
51
82
  1. Микрофон слушает постоянно; энергетический эндпойнтер закрывает реплику по паузе
@@ -96,6 +127,55 @@ JSON-вердикт в `<IAPEER_ROOT>/runtimes/voicetalk/tmp/wake-captures`. К
96
127
  (переопределяется `VOICETALK_VOICE_ENDPOINT`).
97
128
  - Разрешение на микрофон (TCC), выданное один раз процессу рантайма.
98
129
 
130
+ ## Целостность установки на macOS
131
+
132
+ Запуск без подкоманды (`npx -y @agfpd/voicetalk-runtime@<версия>`) и `self-install`
133
+ собирают или копируют исполняемый файл во временный файл рядом с целевым бинарником.
134
+ После chmod, до rename бинарника и публикации манифеста, обязательна проверка
135
+ `/usr/bin/codesign --verify --deep --strict`. Невалидная подпись исправляется
136
+ `--force --sign -` только на этом временном файле, затем повторяется строгая проверка.
137
+ Отказ средства проверки или неуспешное исправление прерывает установку: прежние
138
+ бинарник и манифест не изменяются, временный файл удаляется. Зависимость от пакета
139
+ ядра или его helper-модуля не требуется.
140
+
141
+ Валидная подпись не переписывается. Исправление может изменить идентичность TCC и
142
+ потребовать повторной выдачи разрешения; проверка подтверждает целостность локальной
143
+ подписи, не нотариализацию или происхождение кода. См.
144
+ [руководство Apple по проверке подписи](https://developer.apple.com/library/archive/technotes/tn2206/).
145
+
146
+ Бинарник и манифест публикуются парой. Манифест готовится до публикации бинарника,
147
+ а прежний бинарник сохраняется жёсткой ссылкой, пока rename манифеста не завершится.
148
+ При перехватываемой ошибке ввода-вывода после замены бинарника (например, ENOTDIR или
149
+ EACCES) восстанавливаются прежние inode, подпись и права бинарника, а бинарник первой
150
+ установки удаляется. Если откат тоже не удался, ошибка называет путь к сохранённой
151
+ копии. Атомарности при потере питания, SIGKILL и параллельных установщиках нет.
152
+
153
+ Проверка артефакта поставляется в пакете (macOS, Bun, системные утилиты; без
154
+ репозитория, демона, микрофона, динамика и голосового сервиса):
155
+
156
+ ```sh
157
+ mkdir /tmp/vt-proof && tar -xzf /absolute/agfpd-voicetalk-runtime-<версия>.tgz -C /tmp/vt-proof
158
+ bun /tmp/vt-proof/package/scripts/verify-install.ts \
159
+ [/tmp/vt-proof/package] [/absolute/previous-package-dir] [/absolute/recovery-backup-dir]
160
+ ```
161
+
162
+ Скрипт собирает бинарник через поставляемый CLI со строгой проверкой подписи, запускает
163
+ установленный роутер без аудио (doctor, исходящий конверт в аудиоприёмник, входящий
164
+ `say` в подставной демон), проверяет copy-self, исправление неподписанной копии,
165
+ реальные ENOTDIR и поздний EACCES с сохранением прежней пары, отказ усечённому Mach-O
166
+ и строго невалидной минимальной сборке, точное восстановление прежней пары. Все записи
167
+ идут во временную песочницу `/tmp/vtp-*`, которая затем удаляется. С распакованным
168
+ предыдущим пакетом дополнительно воспроизводится дефект ENOTDIR его установщика как
169
+ контраст «до/после». Живой звук и доставка реальному флоту не проверяются.
170
+
171
+ Проверка разработчика на macOS без изменения живого канала:
172
+
173
+ ```sh
174
+ bun test tests/installSignature.test.ts tests/selfInstall.test.ts tests/selfInstall-macos.test.ts
175
+ # Повтор настоящей приёмки на распакованном npm-артефакте:
176
+ VOICETALK_INSTALL_PACKAGE_ROOT=/absolute/path/to/package bun test tests/selfInstall-macos.test.ts
177
+ ```
178
+
99
179
  Страницы пишутся по мере прохождения фаз.
100
180
 
101
181
  ## Источники дизайна
package/package.json CHANGED
@@ -1,17 +1,24 @@
1
1
  {
2
2
  "name": "@agfpd/voicetalk-runtime",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
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
+ }