@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 +129 -0
- package/docs/README.md +80 -0
- package/docs/ru/README.md +80 -0
- package/package.json +8 -1
- package/scripts/verify-install-pair.ts +145 -0
- package/scripts/verify-install.ts +202 -0
- package/src/audio.ts +36 -2
- package/src/cli.ts +22 -3
- package/src/installPair.ts +67 -0
- package/src/installSignature.ts +58 -0
- package/src/nativeVoice.ts +119 -0
- package/src/selfInstall.ts +56 -42
- package/src/voiceSession.ts +199 -4
- package/src/wake.ts +4 -0
- package/tsconfig.json +1 -1
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
|
+
"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
|
+
}
|