@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 +112 -0
- package/docs/README.md +49 -0
- package/docs/ru/README.md +49 -0
- package/package.json +8 -1
- package/scripts/verify-install-pair.ts +145 -0
- package/scripts/verify-install.ts +202 -0
- package/src/cli.ts +2 -0
- package/src/installPair.ts +67 -0
- package/src/installSignature.ts +58 -0
- package/src/selfInstall.ts +56 -42
- package/tsconfig.json +1 -1
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.
|
|
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
|
+
}
|
package/src/selfInstall.ts
CHANGED
|
@@ -19,13 +19,20 @@
|
|
|
19
19
|
// (the package now owns this path); a sandbox proof sets IAPEER_BIN_DIR so a test never
|
|
20
20
|
// touches the real ~/.local/bin.
|
|
21
21
|
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
22
|
+
import { chmodSync, copyFileSync, mkdirSync, renameSync, rmSync, statSync } from 'fs'
|
|
23
|
+
import { publishInstallPair } from './installPair.ts'
|
|
24
|
+
import { randomUUID } from 'crypto'
|
|
24
25
|
import { homedir } from 'os'
|
|
25
26
|
import { dirname, join } from 'path'
|
|
26
27
|
import { BIN_NAME, PACKAGE_NAME } from './constants.ts'
|
|
27
28
|
import { scaffoldHostDocs, type HostDocsResult } from './hostDocs.ts'
|
|
28
|
-
import { buildManifest, resolveIapeerRoot
|
|
29
|
+
import { buildManifest, resolveIapeerRoot } from './manifest.ts'
|
|
30
|
+
import {
|
|
31
|
+
ensureExecutableSignature,
|
|
32
|
+
runInstallCommand,
|
|
33
|
+
type ExecutableSignatureState,
|
|
34
|
+
type InstallCommandRunner,
|
|
35
|
+
} from './installSignature.ts'
|
|
29
36
|
|
|
30
37
|
/** The launcher binary name (`voicetalk-runtime`). Re-exported so callers/tests have a
|
|
31
38
|
* single import surface for the self-install contract. */
|
|
@@ -44,6 +51,11 @@ export interface SelfInstallOptions {
|
|
|
44
51
|
* (`<pkg>/src/cli.ts` → `<pkg>/docs`). Missing (e.g. running from a compiled bin with
|
|
45
52
|
* no source tree) → soft-skip, never fails the install. */
|
|
46
53
|
docsSource?: string
|
|
54
|
+
/** Test seams: no host-global mocks or environment-based signature bypass. */
|
|
55
|
+
runner?: InstallCommandRunner
|
|
56
|
+
platform?: NodeJS.Platform
|
|
57
|
+
/** Synchronous publication fault seam for tests; no environment bypass. */
|
|
58
|
+
publishRename?: typeof renameSync
|
|
47
59
|
}
|
|
48
60
|
|
|
49
61
|
export type SelfInstallBinMode = 'compiled' | 'copied-self'
|
|
@@ -54,6 +66,7 @@ export interface SelfInstallResult {
|
|
|
54
66
|
binMode: SelfInstallBinMode
|
|
55
67
|
root: string
|
|
56
68
|
binDir: string
|
|
69
|
+
signature: ExecutableSignatureState
|
|
57
70
|
/** On-host docs scaffolding verdict (best-effort; never blocks the install). */
|
|
58
71
|
docs: HostDocsResult
|
|
59
72
|
}
|
|
@@ -88,20 +101,12 @@ function isBunInterpreter(execPath: string): boolean {
|
|
|
88
101
|
|
|
89
102
|
function cleanup(path: string): void {
|
|
90
103
|
try {
|
|
91
|
-
|
|
104
|
+
rmSync(path, { force: true })
|
|
92
105
|
} catch {
|
|
93
106
|
// best-effort
|
|
94
107
|
}
|
|
95
108
|
}
|
|
96
109
|
|
|
97
|
-
/** Tiny deterministic string hash for a unique-ish tmp suffix (Math.random is avoided
|
|
98
|
-
* to keep the tmp name reproducible within a run). */
|
|
99
|
-
function hashStr(s: string): number {
|
|
100
|
-
let h = 0
|
|
101
|
-
for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0
|
|
102
|
-
return h
|
|
103
|
-
}
|
|
104
|
-
|
|
105
110
|
/**
|
|
106
111
|
* Self-install: place the launcher on PATH + write the manifest. IDEMPOTENT — a
|
|
107
112
|
* repeat run produces the same on-disk bin and a byte-identical manifest (atomic
|
|
@@ -116,42 +121,51 @@ export function selfInstall(opts: SelfInstallOptions = {}): SelfInstallResult {
|
|
|
116
121
|
const binDir = resolveBinDir(env)
|
|
117
122
|
const binPath = join(binDir, BIN_NAME)
|
|
118
123
|
|
|
124
|
+
// Fail closed BEFORE any write/process: a sandbox must isolate BOTH surfaces, so
|
|
125
|
+
// both must be EXPLICIT overrides. Comparing against a "host home" cannot work —
|
|
126
|
+
// Bun's os.userInfo().homedir follows $HOME, which a sandbox points at itself.
|
|
127
|
+
if ((env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1')
|
|
128
|
+
&& (!env.IAPEER_BIN_DIR?.trim() || !env.IAPEER_ROOT?.trim())) {
|
|
129
|
+
throw new Error('self-install: refusing real host bin/root under IAPEER_TEST_SANDBOX=1 — set IAPEER_BIN_DIR and IAPEER_ROOT')
|
|
130
|
+
}
|
|
119
131
|
mkdirSync(binDir, { recursive: true, mode: 0o755 })
|
|
120
132
|
|
|
121
133
|
// Build to a sibling tmp in the SAME dir (rename is atomic only within one FS), then
|
|
122
|
-
// chmod +x
|
|
123
|
-
// half-written file
|
|
124
|
-
const tmp = `${binPath}.tmp.${process.pid}.${
|
|
134
|
+
// chmod +x, verify/repair its macOS signature, THEN rename over the target. A
|
|
135
|
+
// reader never sees a half-written file; a legacy symlink is replaced atomically.
|
|
136
|
+
const tmp = `${binPath}.tmp.${process.pid}.${randomUUID()}`
|
|
137
|
+
const run = opts.runner ?? runInstallCommand
|
|
125
138
|
let binMode: SelfInstallBinMode
|
|
139
|
+
let signature: ExecutableSignatureState
|
|
140
|
+
let manifestPath: string
|
|
126
141
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
)
|
|
142
|
+
try {
|
|
143
|
+
if (isCompilableSource(sourceEntry) && isBunInterpreter(execPath)) {
|
|
144
|
+
// PRIMARY: compile a snapshot decoupled from the npm cache/source tree.
|
|
145
|
+
const r = run(execPath, ['build', '--compile', '--outfile', tmp, sourceEntry], env)
|
|
146
|
+
if (r.error || (r.status ?? 1) !== 0) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`self-install: bun build --compile failed: ${(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim()}`,
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
binMode = 'compiled'
|
|
152
|
+
} else {
|
|
153
|
+
// FALLBACK: running from a compiled bin; re-assert by copying the executable.
|
|
154
|
+
copyFileSync(execPath, tmp)
|
|
155
|
+
binMode = 'copied-self'
|
|
140
156
|
}
|
|
141
|
-
binMode = 'compiled'
|
|
142
|
-
} else {
|
|
143
|
-
// FALLBACK: running from an already-compiled bin (no .ts source / execPath is not
|
|
144
|
-
// bun). Re-assert the install by copying the running executable into binDir.
|
|
145
|
-
copyFileSync(execPath, tmp)
|
|
146
|
-
binMode = 'copied-self'
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
chmodSync(tmp, 0o755)
|
|
150
|
-
renameSync(tmp, binPath)
|
|
151
157
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
158
|
+
chmodSync(tmp, 0o755)
|
|
159
|
+
signature = ensureExecutableSignature(tmp, { env, runner: run, platform: opts.platform })
|
|
160
|
+
// Pair publication: the manifest (pinning the ABSOLUTE installed bin into the
|
|
161
|
+
// self-config descriptor) is staged BEFORE the binary is published; a manifest
|
|
162
|
+
// rename failure restores the previous binary inode (or removes a first install).
|
|
163
|
+
manifestPath = publishInstallPair(tmp, binPath, buildManifest(binPath), env, opts.publishRename)
|
|
164
|
+
} finally {
|
|
165
|
+
// Includes partial compiler/copy output and chmod/sign/rename failures. Do not
|
|
166
|
+
// leave .dead binaries; a failed signature gate never reaches the manifest.
|
|
167
|
+
cleanup(tmp)
|
|
168
|
+
}
|
|
155
169
|
|
|
156
170
|
// On-host docs — stage this version's public docs to <root>/docs/voicetalk-runtime/.
|
|
157
171
|
// Best-effort: scaffoldHostDocs already soft-skips missing source / copy errors; the
|
|
@@ -168,5 +182,5 @@ export function selfInstall(opts: SelfInstallOptions = {}): SelfInstallResult {
|
|
|
168
182
|
}
|
|
169
183
|
}
|
|
170
184
|
|
|
171
|
-
return { binPath, manifestPath, binMode, root: resolveIapeerRoot(env), binDir, docs }
|
|
185
|
+
return { binPath, manifestPath, binMode, signature, root: resolveIapeerRoot(env), binDir, docs }
|
|
172
186
|
}
|
package/tsconfig.json
CHANGED