@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 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/README.md CHANGED
@@ -61,11 +61,17 @@ runs the same router text-only, with no microphone at all. Configuration details
61
61
  - **Modality framing for a naive agent.** An agent has no senses — only envelope text. voicetalk marks the inbound envelope so a peer with no knowledge of the system answers in the right modality ("spoken, live — reply by voice, briefly").
62
62
  - **Voice stays out of the core.** Speech-to-text and text-to-speech live in the shared `voice-connect` service (multi-provider, one place to configure), not inside this runtime and not inside the iapeer foundation.
63
63
  - **Conversational turn-taking.** A wake-word only opens a conversation; after a reply, an open-mic window lets the next utterance continue without re-triggering. Endpointing and earcons are owned here, inside the audio session — the agent only sees the next message.
64
+ - **Wake evidence fails closed.** Configured names are never injected into Whisper's
65
+ decoder prompt: only an exact leading name heard in the unprimed transcript can open
66
+ a dialogue, and repeated-name decoder loops are discarded before IAP delivery.
64
67
 
65
68
  ## What it does not do (yet)
66
69
 
67
70
  - No barge-in: the owner cannot interrupt a spoken answer by talking over it (that needs acoustic echo cancellation plus a neural VAD). While the runtime speaks, it does not listen.
68
- - Wake detection runs on the transcript, not on an acoustic wake-word model — so a wake word costs one line of config and no training, at the price of transcribing every voiced segment. An acoustic pre-gate is the Фаза-4 latency upgrade.
71
+ - Wake detection runs on an unprimed transcript, not on an acoustic wake-word model —
72
+ so a wake word costs one line of config and no training, at the price of transcribing
73
+ every voiced segment. A bounded private ring retains wake-candidate segments for
74
+ replayable diagnostics; an acoustic pre-gate remains the stronger future upgrade.
69
75
  - Single host, single owner, local microphone and speaker. Multi-device and multi-owner are out of scope for the MVP.
70
76
 
71
77
  ## Roadmap
package/docs/README.md CHANGED
@@ -41,6 +41,11 @@ self-config hook. `voicetalk-runtime devices` lists the host's inputs;
41
41
  `voicetalk-runtime doctor` reports the whole contour (binding, wake map,
42
42
  voice-connect, microphone).
43
43
 
44
+ Wake evidence is fail-closed. The configured vocabulary is never passed as a Whisper
45
+ decoder prompt, because prompt text can be generated back on unrelated audio. A closed
46
+ dialogue opens only on an exact leading address in the unprimed transcript; repeated
47
+ token loops and name-only echoes are rejected before an IAP envelope exists.
48
+
44
49
  ## How a turn works
45
50
 
46
51
  1. The microphone runs always-on; energy endpointing cuts an utterance when the
@@ -69,7 +74,14 @@ Everything is tunable by env: `VOICETALK_AUDIO=0` (text-only, no microphone),
69
74
  `VOICETALK_FOLLOWUP_MS`, `VOICETALK_HANGOVER_MS`, `VOICETALK_VAD_MIN_RMS`,
70
75
  `VOICETALK_VAD_RATIO`, `VOICETALK_STT_LANG`, `VOICETALK_STT_ENGINE`,
71
76
  `VOICETALK_TTS_MAX_CHARS`, `VOICETALK_EARCONS=0`, `VOICETALK_COMPOSE_MS`,
72
- `VOICETALK_COMPOSE_UNFINISHED_MS`, `VOICETALK_TURN_MAX_MS`.
77
+ `VOICETALK_COMPOSE_UNFINISHED_MS`, `VOICETALK_TURN_MAX_MS`,
78
+ `VOICETALK_WAKE_CAPTURE=0` (disable the default diagnostic ring), and
79
+ `VOICETALK_WAKE_CAPTURE_MAX` (default 16).
80
+
81
+ Every transcript that resembles a configured wake is retained as a mode-0600 WAV plus
82
+ JSON verdict under `<IAPEER_ROOT>/runtimes/voicetalk/tmp/wake-captures`. The ring is
83
+ bounded and contains candidate utterances only, not continuous room audio. Replay a
84
+ disputed segment through the same paced microphone path described below.
73
85
 
74
86
  Two seams exist for testing turn-taking without touching a live channel:
75
87
  `VOICETALK_MIC_FILE=<pcm>` replays a recording through the microphone path (paced in
@@ -85,6 +97,55 @@ still performed and timed. `scripts/replay-dialogue.sh` wires both into a sandbo
85
97
  `VOICETALK_VOICE_ENDPOINT`).
86
98
  - Microphone permission (TCC) granted once to the process that runs the runtime.
87
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
+
88
149
  These pages are written as each phase lands. Russian translation lives in
89
150
  [`ru/`](ru/README.md).
90
151
 
package/docs/ru/README.md CHANGED
@@ -41,6 +41,11 @@ self-config-хука (`VOICETALK_WAKE_WORDS`, `VOICETALK_DEFAULT_TARGET`,
41
41
  `voicetalk-runtime doctor` — весь контур целиком (привязка, карта, voice-connect,
42
42
  микрофон).
43
43
 
44
+ Wake-сигнал работает fail-closed. Настроенные имена не передаются Whisper как decoder
45
+ prompt: такой контекст способен сам породить имя на постороннем аудио. Закрытый диалог
46
+ открывает только точное имя в начале непримированного транскрипта; циклы повторяющихся
47
+ токенов и тело из одних настроенных имён отбрасываются до появления IAP-конверта.
48
+
44
49
  ## Как устроен ход разговора
45
50
 
46
51
  1. Микрофон слушает постоянно; энергетический эндпойнтер закрывает реплику по паузе
@@ -68,7 +73,14 @@ Earcon'ы размечают состояния: восходящий — мик
68
73
  `VOICETALK_FOLLOWUP_MS`, `VOICETALK_HANGOVER_MS`, `VOICETALK_VAD_MIN_RMS`,
69
74
  `VOICETALK_VAD_RATIO`, `VOICETALK_STT_LANG`, `VOICETALK_STT_ENGINE`,
70
75
  `VOICETALK_TTS_MAX_CHARS`, `VOICETALK_EARCONS=0`, `VOICETALK_COMPOSE_MS`,
71
- `VOICETALK_COMPOSE_UNFINISHED_MS`, `VOICETALK_TURN_MAX_MS`.
76
+ `VOICETALK_COMPOSE_UNFINISHED_MS`, `VOICETALK_TURN_MAX_MS`,
77
+ `VOICETALK_WAKE_CAPTURE=0` (отключить диагностическое кольцо) и
78
+ `VOICETALK_WAKE_CAPTURE_MAX` (по умолчанию 16).
79
+
80
+ Каждый транскрипт, похожий на настроенный wake, сохраняется как WAV с правами 0600 и
81
+ JSON-вердикт в `<IAPEER_ROOT>/runtimes/voicetalk/tmp/wake-captures`. Кольцо ограничено
82
+ и хранит только сегменты-кандидаты, не непрерывную запись комнаты. Спорный сегмент
83
+ можно прогнать через тот же микрофонный путь способом ниже.
72
84
 
73
85
  Для проверки turn-taking, не трогая живой канал, есть два шва: `VOICETALK_MIC_FILE=<pcm>`
74
86
  проигрывает запись через тот же путь, что и микрофон (в реальном темпе), а
@@ -84,6 +96,55 @@ Earcon'ы размечают состояния: восходящий — мик
84
96
  (переопределяется `VOICETALK_VOICE_ENDPOINT`).
85
97
  - Разрешение на микрофон (TCC), выданное один раз процессу рантайма.
86
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
+
87
148
  Страницы пишутся по мере прохождения фаз.
88
149
 
89
150
  ## Источники дизайна
package/package.json CHANGED
@@ -1,17 +1,24 @@
1
1
  {
2
2
  "name": "@agfpd/voicetalk-runtime",
3
- "version": "0.3.0",
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
+ }