@agfpd/iapeer 0.4.110 → 0.4.111

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 CHANGED
@@ -12,6 +12,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
12
12
 
13
13
  ### Fixed
14
14
 
15
+ - **Hosted hard teardown reclaims PTY-owned subprocesses, not only the runtime root.**
16
+ `self-fresh`, stop and reap snapshot ancestry before killing the runtime, including stdio MCP
17
+ launchers in separate process groups. Start-token and controlling-terminal checks exclude reused
18
+ PIDs, unrelated processes and detached/shared daemon branches. An isolated real-Codex proof and
19
+ hermetic regressions cover old-tree exit, fresh MCP calls and sentinel survival. Already-orphaned
20
+ processes and shared-backend thread shutdown remain outside this bounded cleanup.
21
+
22
+ - **macOS executable signatures are checked before activation.** Runtime packages
23
+ must strictly verify compiled and copied staged binaries, repair invalid trusted
24
+ build artifacts with ad-hoc signing, and re-verify before binary/manifest publication.
25
+ A lightweight `@agfpd/iapeer/install-signature` helper implements the gate. Foundation
26
+ install and rollback now sign/verify the staged inode before replacing the binary;
27
+ runtime install/update also verify installed launchers before provisioning/restart,
28
+ even when the package/version is already current. Foundation update also verifies
29
+ its installed binary before activation, including same-version stale-daemon healing.
30
+ Valid signatures are preserved;
31
+ failed repair never licenses activation of invalid code.
32
+
15
33
  - **IAP attachments now transfer ownership instead of forwarding fragile source paths.**
16
34
  Every `send_to_peer`, `iapeer send`, and Fleet API send copies regular-file sources
17
35
  into the target peer's durable, content-addressed foundation inbox before an
@@ -98,4 +98,15 @@ So `stop`, `start`, `new` refuse to touch foreign launchd peers — for those th
98
98
  - **`iapeer interrupt`** — interrupt the current turn (Escape) without losing context.
99
99
  - **`iapeer self-fresh` / `self-done`** — an agent's self-calls: mark itself for a fresh restart, or quietly finish (for ephemeral workers).
100
100
 
101
+ ### Process ownership at teardown
102
+
103
+ Closing a hosted session is a **hard teardown**, not the MCP client's graceful stdin/EOF shutdown.
104
+ Before killing the runtime, the supervisor snapshots its descendants and kills those still attached
105
+ to its controlling PTY, including stdio MCP launchers in separate process groups. Each PID's start
106
+ token and terminal are re-checked before signalling; unrelated processes and detached daemon branches
107
+ are excluded. This also applies to `self-fresh`: finish work and persist the handoff before calling it.
108
+ Processes already orphaned before the snapshot, a concurrent fork/exit race, an unreadable process
109
+ table, and shared-backend threads outside the PTY are not covered by this cleanup. A shared runtime
110
+ daemon needs its own per-thread shutdown protocol; ancestry alone does not authorize killing it.
111
+
101
112
  Behavior by runtime class — [08 — Peer management](08-peer-management.md); ephemeral workers — [10 — Messaging](10-messaging.md).
@@ -60,7 +60,7 @@ On a fresh macOS host the first install → onboard → peer launch surfaces a h
60
60
  **Three things you do (these cannot be automated):**
61
61
 
62
62
  1. **Log in to the runtimes — before onboard.** Sign in to Claude Code and Codex first. Their login screen is a browser OAuth flow that no headless peer can drive, so iapeer deliberately does **not** auto-answer it; a runtime that is installed but not signed in makes a peer fail its first wake loudly. `iapeer onboard` warns when it finds an unauthenticated runtime.
63
- 2. **Login keychain password — once, during install.** The install code-signs the binary with a stable local identity so Full Disk Access survives updates; macOS asks for your login keychain password the one time that identity is created. It is expected (the installer announces it). If declined, the binary still works — TCC prompts would just recur on updates.
63
+ 2. **Login keychain password — once, during install.** The install code-signs the staged binary with a stable local identity so Full Disk Access survives updates; macOS asks for your login keychain password the one time that identity is created. It is expected (the installer announces it). If declined, install may use a verified ad-hoc signature (TCC prompts may recur). Signature verification itself is mandatory: an unrepairable build aborts before replacing the installed binary.
64
64
  3. **Full Disk Access — a manual grant, no prompt.** macOS TCC cannot be set by any flag, env var, or script. Without it, a peer reading or writing a TCC-protected path (an iCloud Obsidian vault, Desktop, Documents, Downloads) silently fails with EPERM — no prompt, no hang. Grant it in **System Settings → Privacy & Security → Full Disk Access** (`~/.iapeer` itself is under `$HOME` and needs nothing); onboard prints the reminder.
65
65
 
66
66
  Gatekeeper does not gate the locally-built CLI run from a terminal (no quarantine attribute). Verified live on a truly-virgin Claude config (claude 2.1.183): the theme picker, folder-trust, the bypass-permissions accept (cursor stepped to "Yes"), and the project MCP-server approval all auto-clear in sequence, the peer reaches its input prompt, and a second launch on the same config is dialog-free; the Codex folder-trust / update auto-clearing likewise (an owner's clean-machine install + an isolated first-boot test).
@@ -78,7 +78,7 @@ A bare `iapeer update` updates the **whole host stack** in one command — the c
78
78
 
79
79
  **1. Core (foundation)** — and it ABORTS the cascade on a hard failure (never update runtimes onto a broken core):
80
80
 
81
- 1. Learns the target version from npm. If the installed one already matches and there's no `--force` — it does nothing.
81
+ 1. Learns the target version from npm. If the installed one already matches and there's no `--force`, it verifies the installed executable signature without repairing it; a valid, version-consistent daemon needs no restart. An invalid binary is refused, including before recovery of a stale live daemon.
82
82
  2. Downloads and builds the new binary via `npm pack`, not `npx` (the binary doesn't rebuild itself).
83
83
  3. Restarts the daemon onto the new binary.
84
84
  4. Restarts the infrastructure launchd jobs the core owns (`com.iapeer.*` — the `timer`, `watcher`, Telegram routers), moving them onto the just-replaced binary. It's the JOBS that restart, not the runtimes' package code — that's the next step. If the daemon didn't come up in step 3, this is skipped.
@@ -90,7 +90,7 @@ A bare `iapeer update` updates the **whole host stack** in one command — the c
90
90
 
91
91
  Runtimes and memory are **best-effort**: a component failure is reported and the rest still run; the command exits non-zero if anything failed. Agentic peers (Claude/Codex) pick up new core doctrine lazily on their next wake.
92
92
 
93
- `iapeer update --foundation-only` does just step 1 (the narrow case). A pinned `iapeer update <version>` is **core-only** by design — a version pin is core-specific (downgrade, or pin a proven version, deeper than one rollback step).
93
+ `iapeer update --foundation-only` does just the foundation leg, including infrastructure-job re-registration when the binary/daemon changes; it narrows package updates, not process restarts. A pinned `iapeer update <version>` has the same **core-only** scope — a version pin is core-specific (downgrade, or pin a proven version, deeper than one rollback step).
94
94
 
95
95
  > **Upgrading from before the cascade (< 0.4.2).** The cascade is driven by the *installed* binary, so the very first `iapeer update` from a pre-cascade version runs the OLD (foundation-only) logic: it upgrades the core to the cascade-capable binary but does not yet cascade runtimes + memory. Run `iapeer update` once more — now the new binary cascades the rest. This is a one-time bootstrap; from 0.4.2 onward it is a single command. (A fresh install lands on a cascade-capable binary directly, so it never needs the double run.)
96
96
 
@@ -83,6 +83,49 @@ Commands like `prepare`, `interface`, `bot`, `doctor` are the package's own inte
83
83
  - **`install-runtime <runtime>`** determines the npm package (from the built-in registry or the `--package` flag), installs it via `npx`, then provisions the declared peer set.
84
84
  - **`update-runtime <runtime>`** compares the manifest version with npm; when behind, it reinstalls, re-provisions, and restarts the runtime's peers with the standard `stop`/`start`.
85
85
 
86
+ ### Executable signature before activation (macOS)
87
+
88
+ A successful Bun compile or executable copy does not guarantee a valid Mach-O
89
+ signature. Each package owns this gate on **every** self-install path (compile and
90
+ copy-self), including direct `npx` installs outside the foundation:
91
+
92
+ 1. Build/copy to a sibling temporary file and set its executable permissions.
93
+ 2. Run `/usr/bin/codesign --verify --deep --strict <tmp>`. Preserve a valid signature
94
+ unchanged, including certificate-backed signatures used for stable TCC grants.
95
+ 3. For an invalid/unsigned **installer-owned build artifact**, repair with
96
+ `/usr/bin/codesign --force --sign - <tmp>` and repeat strict verification.
97
+ 4. Only after verification succeeds, atomically rename the file over the installed
98
+ binary and publish the new manifest. On failure, remove the temporary file, exit
99
+ non-zero, and leave the previous binary and manifest unchanged.
100
+
101
+ The optional lightweight helper `ensureExecutableSignature` is exported from
102
+ `@agfpd/iapeer/install-signature`; a package may implement the same contract locally.
103
+ It throws on verification/repair failure, skips shebang script launchers and non-macOS
104
+ hosts, and supports injected command runners for hermetic tests. Its repair mode is
105
+ for trusted installer artifacts, **not** a general authenticity check or a way to
106
+ trust arbitrary modified installed code. Stable certificate signing for TCC is a
107
+ separate concern; signature validity is fail-closed even if stable signing is soft.
108
+
109
+ The foundation additionally verifies installed runtime launchers before provisioning
110
+ or restart, including already-installed/already-latest paths. This secondary gate
111
+ is verify-only: it reports invalid signatures rather than re-signing live binaries.
112
+ It cannot replace the package's pre-rename gate, since launchd may reopen a binary
113
+ as soon as the package publishes it.
114
+
115
+ Foundation install and rollback apply the same staged validity gate before binary
116
+ replacement. Stable local-certificate signing happens on the staged inode; a
117
+ certificate-signing failure can fall back to a verified ad-hoc signature, but an
118
+ unrepairable executable is never activated. Repair is not guaranteed for malformed
119
+ Bun output: a strict-validation failure aborts safely and requires a working build.
120
+ The update orchestrator also verify-only checks the installed foundation binary before
121
+ daemon activation, including the same-version stale-daemon recovery path.
122
+
123
+ The shipped `scripts/proof-install-signature.ts` exercises the actual package CLI
124
+ installer and native codesign in disposable paths (requires the existing local signing
125
+ identity). It includes refusal/state preservation, verify-only runtime gates and
126
+ rollback. Update lifecycle calls are injected: this proof never restarts the host daemon
127
+ and is not a deployed-health acceptance.
128
+
86
129
  ## The capability-plugin standard
87
130
 
88
131
  A capability plugin adds an ability to a peer without being a runtime. The distinction is clear: a runtime is a peer's mode of presence, a plugin is an ability on top of a runtime.
@@ -98,4 +98,15 @@
98
98
  - **`iapeer interrupt`** — прервать текущий ход (Escape), не теряя контекст.
99
99
  - **`iapeer self-fresh` / `self-done`** — самовызовы агента: пометить себя на свежий перезапуск или тихо завершиться (для эфемерных воркеров).
100
100
 
101
+ ### Владение процессами при закрытии сессии
102
+
103
+ Закрытие управляемой сессии — **жёсткое завершение**, а не протокол мягкого закрытия stdin/EOF
104
+ клиентом MCP. До остановки рантайма супервизор снимает дерево его потомков и завершает тех,
105
+ кто ещё принадлежит его управляющему PTY, включая stdio MCP в отдельных группах процессов.
106
+ Перед сигналом повторно проверяются время рождения PID и терминал; чужие процессы и ветви
107
+ отсоединённых демонов исключены. Это относится и к `self-fresh`: работу и хендофф завершают до самовызова.
108
+ Уже осиротевшие до снимка процессы, гонка параллельного порождения/выхода, недоступная таблица
109
+ процессов и потоки общего бэкенда вне PTY этой очисткой не покрываются. Общему демону рантайма
110
+ нужен собственный протокол закрытия отдельного потока; одно лишь родство не разрешает убивать его.
111
+
101
112
  Поведение по классам рантаймов — [08 — Управление пирами](08-управление-пирами.md); эфемерные воркеры — [10 — Обмен сообщениями](10-обмен-сообщениями.md).
@@ -60,7 +60,7 @@ iapeer status
60
60
  **Три пункта на тебе (автоматизировать нельзя):**
61
61
 
62
62
  1. **Залогинься в рантаймы — ДО onboard.** Войди в Claude Code и Codex заранее. Их экран логина — браузерный OAuth, который headless-пир не пройдёт, поэтому iapeer НАМЕРЕННО его не авто-отвечает; установленный, но не залогиненный рантайм роняет первый wake пира громко. `iapeer onboard` предупреждает, если найдёт неаутентифицированный рантайм.
63
- 2. **Пароль логин-связки — один раз, при install.** Install подписывает бинарь стабильной локальной идентичностью, чтобы Full Disk Access пережил обновления; macOS один раз спросит пароль логин-связки при создании этой идентичности. Это ожидаемо (инсталлер анонсирует). При отказе бинарь всё равно работает — просто TCC-промпты будут повторяться на апдейтах.
63
+ 2. **Пароль логин-связки — один раз, при install.** Install подписывает временный бинарь стабильной локальной идентичностью, чтобы Full Disk Access пережил обновления; macOS один раз спросит пароль логин-связки при создании этой идентичности. Это ожидаемо (инсталлер анонсирует). При отказе возможна проверенная ad-hoc-подпись (TCC-промпты могут повторяться). Проверка валидности обязательна: неисправимая сборка прерывает установку до замены установленного бинаря.
64
64
  3. **Full Disk Access — ручной грант, без промпта.** macOS TCC нельзя выставить ни флагом, ни env, ни скриптом. Без него пир, читающий/пишущий TCC-защищённый путь (iCloud Obsidian vault, Desktop, Documents, Downloads), тихо падает с EPERM — без промпта, без зависания. Выдай в **System Settings → Privacy & Security → Full Disk Access** (`~/.iapeer` сам под `$HOME` и ничего не требует); onboard напомнит.
65
65
 
66
66
  Gatekeeper не гейтит локально собранный CLI, запускаемый из терминала (нет quarantine-атрибута). Проверено живьём на по-настоящему виргин-конфиге Claude (claude 2.1.183): theme-picker, folder-trust, принятие bypass-permissions (курсор шагнут на «Yes») и аппрув проектных MCP-серверов авто-снимаются по очереди, пир доходит до строки ввода, а повторный запуск на том же конфиге — уже без диалогов; codex folder-trust / update — аналогично (clean-install владельца + изолированный first-boot тест).
@@ -78,7 +78,7 @@ iapeer update --force # переустановить, даже есл
78
78
 
79
79
  **1. Ядро (фундамент)** — и оно ПРЕРЫВАЕТ каскад при жёсткой ошибке (не обновлять рантаймы на сломанное ядро):
80
80
 
81
- 1. Узнаёт нужную версию из npm. Если установленная уже совпадает и нет `--force` — ничего не делает.
81
+ 1. Узнаёт нужную версию из npm. Если установленная уже совпадает и нет `--force`, проверяет подпись установленного бинарника без переподписания; валидный демон с актуальной версией не требует рестарта. Невалидный бинарник отклоняется, в том числе до восстановления демона со старой версией.
82
82
  2. Скачивает и собирает новый бинарь через `npm pack`, а не `npx` (бинарь не пересобирает себя сам).
83
83
  3. Перезапускает демон на новый бинарь.
84
84
  4. Перезапускает launchd-джобы инфраструктуры, которыми владеет ядро (`com.iapeer.*` — роутеры `timer`, `watcher`, Telegram), переводя их на заменённый бинарь. Перезапускаются именно ДЖОБЫ, а не пакетный код рантаймов — это следующий шаг. Если демон на шаге 3 не поднялся, шаг пропускается.
@@ -90,7 +90,7 @@ iapeer update --force # переустановить, даже есл
90
90
 
91
91
  Рантаймы и память — **best-effort**: ошибка компонента сообщается, остальные всё равно отрабатывают; код возврата ненулевой, если что-то упало. Агентские пиры (Claude/Codex) подхватывают новую доктрину ядра лениво на следующем пробуждении.
92
92
 
93
- `iapeer update --foundation-only` делает только шаг 1 (узкий случай). Запиненный `iapeer update <версия>` — **только ядро** by design: пин версии специфичен для ядра (откат глубже одного шага или фиксация проверенной версии).
93
+ `iapeer update --foundation-only` выполняет только foundation-плечо, включая перерегистрацию инфраструктурных jobs при изменении бинарника/демона: сужается обновление пакетов, а не область рестартов процессов. Запиненный `iapeer update <версия>` имеет ту же область **только ядро**: пин версии специфичен для ядра (откат глубже одного шага или фиксация проверенной версии).
94
94
 
95
95
  > **Обновление с версии до каскада (< 0.4.2).** Каскад исполняет *установленный* бинарь, поэтому самый первый `iapeer update` со старой версии работает по СТАРОЙ (foundation-only) логике: он поднимает ядро до каскад-способного бинаря, но рантаймы + память пока не каскадит. Запусти `iapeer update` ещё раз — теперь новый бинарь докаскадит остальное. Это разовый bootstrap; начиная с 0.4.2 — одна команда. (Свежая установка сразу ставит каскад-способный бинарь, поэтому двойной прогон ей не нужен.)
96
96
 
@@ -83,6 +83,51 @@
83
83
  - **`install-runtime <рантайм>`** определяет npm-пакет (по встроенному реестру или флагу `--package`), ставит его через `npx`, затем разворачивает объявленный набор пиров.
84
84
  - **`update-runtime <рантайм>`** сверяет версию манифеста с npm; при отставании переустанавливает, заново провизионирует и перезапускает пиров рантайма штатными `stop`/`start`.
85
85
 
86
+ ### Подпись исполняемого файла до активации (macOS)
87
+
88
+ Успешная компиляция Bun или копирование исполняемого файла не гарантирует валидную
89
+ Mach-O-подпись. Пакет проверяет её на **каждом** пути self-install (compile и
90
+ copy-self), включая прямую установку через `npx` вне ядра:
91
+
92
+ 1. Собрать/скопировать файл во временный соседний файл, установить права исполнения.
93
+ 2. Выполнить `/usr/bin/codesign --verify --deep --strict <tmp>`. Валидную подпись
94
+ сохранить без изменений, включая сертификатную подпись для стабильных TCC-грантов.
95
+ 3. Для невалидного/неподписанного **артефакта сборки, принадлежащего установщику**
96
+ выполнить `/usr/bin/codesign --force --sign - <tmp>` и повторить строгую проверку.
97
+ 4. Только после успешной проверки атомарно заменить установленный бинарь и записать
98
+ новый манифест. При ошибке удалить временный файл, завершиться ненулевым кодом и
99
+ оставить прежние бинарь и манифест неизменными.
100
+
101
+ Необязательный лёгкий helper `ensureExecutableSignature` экспортирован из
102
+ `@agfpd/iapeer/install-signature`; пакет может реализовать тот же контракт локально.
103
+ Он бросает исключение при неуспехе проверки/исправления, пропускает скриптовые лаунчеры с shebang
104
+ и хосты вне macOS, поддерживает внедряемый runner для герметичных тестов. Исправление
105
+ предназначено для доверенных артефактов установщика, **не** для проверки подлинности
106
+ или легитимации произвольно изменённого установленного кода. Стабильная сертификатная
107
+ подпись для TCC — отдельный вопрос; валидность подписи проверяется fail-closed даже
108
+ при мягком отказе стабильного подписания.
109
+
110
+ Ядро дополнительно проверяет установленные лаунчеры рантайма перед провижном или
111
+ рестартом, включая ветки уже установленного пакета и актуальной версии. Вторичный
112
+ гейт только проверяет: он сообщает о невалидной подписи, не переподписывая живые
113
+ бинарники. Он не заменяет проверку пакетом до rename: launchd может открыть бинарь
114
+ сразу после его публикации установщиком.
115
+
116
+ Установка и откат ядра применяют тот же гейт к staged-файлу до замены бинаря.
117
+ Стабильное подписывание локальным сертификатом выполняется на временном inode;
118
+ его отказ допускает переход к проверенной ad-hoc-подписи, но неисправимый файл
119
+ никогда не активируется. Исправление произвольно дефектного вывода Bun не
120
+ гарантируется: ошибка строгой валидации безопасно прерывает установку и требует
121
+ рабочей сборки. Оркестратор update также проверяет установленный бинарник ядра без
122
+ переподписания перед активацией демона, включая восстановление старого демона при
123
+ совпадающей версии бинарника.
124
+
125
+ Поставляемый `scripts/proof-install-signature.ts` проверяет штатный CLI-установщик
126
+ самого пакета и настоящий codesign в одноразовых каталогах (нужна существующая локальная
127
+ подписывающая идентичность). Он покрывает отказ и сохранение состояния, вторичные гейты
128
+ рантаймов и rollback. Lifecycle-вызовы update заменены изолированными зависимостями:
129
+ проверка не перезапускает демон хоста и не является приёмкой здоровья после выкатки.
130
+
86
131
  ## Стандарт capability-плагина
87
132
 
88
133
  Capability-плагин добавляет пиру способность, не будучи рантаймом. Отличие чёткое: рантайм — способ присутствия пира, плагин — способность поверх рантайма.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.110",
3
+ "version": "0.4.111",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -34,6 +34,7 @@
34
34
  "src",
35
35
  "bin",
36
36
  "docs",
37
+ "scripts/proof-install-signature.ts",
37
38
  "CHANGELOG.md",
38
39
  "tsconfig.json",
39
40
  "!src/**/*.test.ts",
@@ -45,7 +46,8 @@
45
46
  "./storage": "./src/storage/index.ts",
46
47
  "./identity": "./src/identity/index.ts",
47
48
  "./registry": "./src/registry/index.ts",
48
- "./codec": "./src/codec/index.ts"
49
+ "./codec": "./src/codec/index.ts",
50
+ "./install-signature": "./src/install/signature.ts"
49
51
  },
50
52
  "scripts": {
51
53
  "test": "IAPEER_TEST_SANDBOX=1 bun test",
@@ -0,0 +1,189 @@
1
+ // Native artifact acceptance, NOT a host deploy. Run from the unpacked npm package:
2
+ // bun scripts/proof-install-signature.ts [package-root]
3
+ // Real CLI installer/compiler/codesign; launchd update seams are explicitly isolated.
4
+ import { createHash } from 'crypto'
5
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
6
+ import { homedir, tmpdir } from 'os'
7
+ import { join, resolve } from 'path'
8
+ import { spawnSync } from 'child_process'
9
+ import { pathToFileURL } from 'url'
10
+
11
+ if (process.platform !== 'darwin') throw new Error('native signature proof requires macOS')
12
+ if (process.env.IAPEER_TEST_SANDBOX === '1') throw new Error('native proof must execute real codesign; run outside bun test')
13
+ const identity = spawnSync('/usr/bin/security', ['find-identity', '-p', 'codesigning'], { encoding: 'utf8' })
14
+ if (identity.status !== 0 || !identity.stdout.includes('"iapeer Local Codesign"')) {
15
+ throw new Error('proof requires the EXISTING local identity; it does not create one')
16
+ }
17
+
18
+ const pkg = resolve(process.argv[2] ?? join(import.meta.dir, '..'))
19
+ const fromPackage = (path: string) => import(pathToFileURL(join(pkg, path)).href)
20
+ const { installIapeer, rollbackIapeer, stampBinaryHealthy } = await fromPackage('src/install/index.ts')
21
+ const { ensureExecutableSignature } = await fromPackage('src/install/signature.ts')
22
+ const { updateIapeer } = await fromPackage('src/update/index.ts')
23
+ const { installRuntimePackage } = await fromPackage('src/runtime/deploy.ts')
24
+ const { updateRuntime } = await fromPackage('src/runtime/update.ts')
25
+ const { writeRuntimeManifest, runtimeManifestPath } = await fromPackage('src/runtime/index.ts')
26
+ const expectedVersion = JSON.parse(readFileSync(join(pkg, 'package.json'), 'utf8')).version
27
+ const root = mkdtempSync(join(tmpdir(), 'iapeer-native-signature-'))
28
+ const tools = join(root, 'tools')
29
+ const faultyTools = join(root, 'faulty-tools')
30
+ const tray = join(root, 'tray')
31
+ for (const dir of [tools, faultyTools, tray, join(root, 'home')]) mkdirSync(dir, { recursive: true })
32
+ const env = {
33
+ ...process.env,
34
+ HOME: join(root, 'home'),
35
+ IAPEER_ROOT: join(root, 'state'),
36
+ IAPEER_BIN_DIR: join(root, 'bin'),
37
+ IAPEER_LAUNCHAGENTS_DIR: join(root, 'LaunchAgents'),
38
+ IAPEER_SOCK_DIR: join(root, 'sockets'),
39
+ IAPEER_PROOF_SWIFTBAR_DIR: tray,
40
+ PATH: `${tools}:${process.env.PATH ?? ''}`,
41
+ }
42
+ // SwiftBar preferences are host-global even with HOME overridden. The stock install
43
+ // discovers a pre-existing plugin dir through this isolated read-only defaults shim.
44
+ writeFileSync(join(tools, 'defaults'), '#!/bin/sh\nif [ "$1" = read ]; then printf "%s\\n" "$IAPEER_PROOF_SWIFTBAR_DIR"; else echo "proof refuses defaults write" >&2; exit 71; fi\n', { mode: 0o755 })
45
+ const quote = (s: string) => `'${s.replaceAll("'", "'\\''")}'`
46
+ // A disposable HOME otherwise hides the existing login keychain from security.
47
+ // Permit its read/sign operations on our staged files, but refuse identity imports.
48
+ const hostHome = process.env.HOME || homedir()
49
+ writeFileSync(join(tools, 'security'), `#!/bin/sh
50
+ if [ "$1" != find-identity ]; then echo 'proof refuses keychain mutation' >&2; exit 71; fi
51
+ export HOME=${quote(hostHome)}
52
+ exec /usr/bin/security "$@"
53
+ `, { mode: 0o755 })
54
+ writeFileSync(join(tools, 'codesign'), `#!/bin/sh
55
+ export HOME=${quote(hostHome)}
56
+ exec /usr/bin/codesign "$@"
57
+ `, { mode: 0o755 })
58
+ // Fault only the compiler's OUTPUT, not signing or the installer. CLI execution is
59
+ // forwarded to the real Bun; a build exits zero with a malformed staged Mach-O.
60
+ writeFileSync(join(faultyTools, 'bun'), `#!/bin/sh
61
+ if [ "$1" = build ]; then
62
+ while [ "$#" -gt 0 ]; do
63
+ if [ "$1" = --outfile ]; then shift; dd if="$IAPEER_BIN_DIR/iapeer" of="$1" bs=4096 count=1 2>/dev/null; chmod +x "$1"; exit 0; fi
64
+ shift
65
+ done
66
+ exit 72
67
+ fi
68
+ exec ${quote(process.execPath)} "$@"
69
+ `, { mode: 0o755 })
70
+ const faultyEnv = { ...env, PATH: `${faultyTools}:${env.PATH}` }
71
+ const checks: string[] = []
72
+ const check = (ok: boolean, label: string) => {
73
+ if (!ok) throw new Error(`FAIL: ${label}`)
74
+ checks.push(label)
75
+ console.log(`PASS: ${label}`)
76
+ }
77
+ const hash = (path: string) => createHash('sha256').update(readFileSync(path)).digest('hex')
78
+ const installCli = (passedEnv = env) => {
79
+ const result = spawnSync('/bin/bash', [join(pkg, 'bin/iapeer'), 'install'], {
80
+ encoding: 'utf8', env: passedEnv, cwd: pkg, timeout: 180_000,
81
+ })
82
+ console.log(result.stdout)
83
+ if (result.stderr) console.log(result.stderr)
84
+ return result
85
+ }
86
+ const bin = join(env.IAPEER_BIN_DIR, 'iapeer')
87
+ const execute = () => {
88
+ const result = spawnSync(bin, ['version'], { encoding: 'utf8', env, timeout: 30_000 })
89
+ check(result.status === 0 && result.stdout.trim() === expectedVersion, 'compiled binary executes expected version')
90
+ }
91
+ try {
92
+ let result = installCli()
93
+ check(result.status === 0 && result.stdout.includes('signing: signed'), 'stock CLI full-payload install succeeds with existing stable identity')
94
+ check(ensureExecutableSignature(bin, { env, repair: false }).state === 'verified', 'installed inode strictly verifies')
95
+ execute()
96
+ check(stampBinaryHealthy(env), 'isolated healthy stamp')
97
+ result = installCli()
98
+ check(result.status === 0, 'stock CLI reinstall succeeds')
99
+ check(stampBinaryHealthy(env), 'isolated healthy stamp after reinstall')
100
+ // Foundation has no runtime manifest; its binary/config pair is binary + daemon
101
+ // plist. Include rollback bytes and healthy stamp in every refusal comparison.
102
+ const paths = [bin, `${bin}.prev`, `${bin}.healthy`, join(env.IAPEER_LAUNCHAGENTS_DIR, 'com.agfpd.iapeer.plist')]
103
+ const snapshot = () => paths.map(hash)
104
+ const unchanged = (before: string[], label: string) => {
105
+ check(snapshot().every((v, i) => v === before[i]), label)
106
+ check(!existsSync(`${bin}.tmp`), 'rejected staged file removed')
107
+ execute()
108
+ }
109
+ const beforeRefusal = snapshot()
110
+ result = installCli(faultyEnv)
111
+ check(result.status !== null && result.status !== 0 && `${result.stdout}${result.stderr}`.includes('executable signature'), 'stock CLI rejects compiler output through REAL codesign')
112
+ unchanged(beforeRefusal, 'CLI refusal preserves binary/.prev/.healthy/daemon-plist byte-for-byte')
113
+
114
+ let restarted = 0
115
+ let recycled = 0
116
+ const update = (passedEnv: typeof env) => updateIapeer({
117
+ env: passedEnv, currentVersion: expectedVersion, force: true,
118
+ resolveVersion: () => expectedVersion,
119
+ runInstall: () => installCli(passedEnv).status === 0,
120
+ // No launchctl commands or real daemon health claims in native artifact proof.
121
+ restartDaemon: () => { restarted++; return { state: 'restarted' } },
122
+ waitHealthy: async () => ({ healthy: true }),
123
+ recycleInfraJobs: () => { recycled++; return [] },
124
+ stampHealthy: () => stampBinaryHealthy(env),
125
+ })
126
+ const failedUpdate = await update(faultyEnv)
127
+ check(failedUpdate.status === 'failed' && restarted === 0 && recycled === 0, 'update install refusal prevents ALL lifecycle activation calls')
128
+ unchanged(beforeRefusal, 'update refusal preserves binary/config/rollback pair')
129
+ const successfulUpdate = await update(env)
130
+ check(successfulUpdate.status === 'updated' && restarted === 1 && recycled === 1, 'real successful installer passes update activation gate (lifecycle seams isolated)')
131
+ check(ensureExecutableSignature(bin, { env, repair: false }).state === 'verified', 'updated inode strictly verifies')
132
+ execute()
133
+
134
+ const sameVersionEnv = { ...env, IAPEER_BIN_DIR: join(root, 'same-version-bin') }
135
+ mkdirSync(sameVersionEnv.IAPEER_BIN_DIR)
136
+ const sameVersionBin = join(sameVersionEnv.IAPEER_BIN_DIR, 'iapeer')
137
+ copyFileSync(bin, sameVersionBin)
138
+ const sameVersion = () => updateIapeer({
139
+ env: sameVersionEnv, currentVersion: expectedVersion, resolveVersion: () => expectedVersion,
140
+ liveDaemonVersion: () => expectedVersion,
141
+ })
142
+ check((await sameVersion()).status === 'already-latest', 'same-version foundation update passes REAL verify-only gate')
143
+ writeFileSync(sameVersionBin, Buffer.from([0xcf, 0xfa, 0xed, 0xfe]))
144
+ const invalidBefore = hash(sameVersionBin)
145
+ const invalidSameVersion = await updateIapeer({
146
+ env: sameVersionEnv, currentVersion: expectedVersion, resolveVersion: () => expectedVersion,
147
+ liveDaemonVersion: () => '0.0.0',
148
+ restartDaemon: () => { throw new Error('unexpected stale-daemon activation') },
149
+ })
150
+ check(invalidSameVersion.status === 'failed' && invalidSameVersion.reason.includes('signature'), 'invalid same-version foundation cannot activate stale-daemon recovery')
151
+ check(hash(sameVersionBin) === invalidBefore, 'foundation verify-only gate leaves installed bytes unchanged')
152
+
153
+ const tiny = join(root, 'tiny.ts')
154
+ writeFileSync(tiny, 'console.log("tiny-signature-proof")\n')
155
+ const beforeTiny = snapshot()
156
+ let tinyError: unknown
157
+ try { installIapeer(tiny, env) } catch (e) { tinyError = e }
158
+ if (tinyError) {
159
+ console.log(`INFO: real tiny-source build rejected: ${String(tinyError)}`)
160
+ unchanged(beforeTiny, 'real tiny-source refusal preserves binary/config/rollback pair')
161
+ } else {
162
+ console.log('INFO: tiny-source output is signable on this compiler; restoring full CLI')
163
+ check(installCli().status === 0, 'restore full CLI after signable tiny-source build')
164
+ check(stampBinaryHealthy(env), 'restored healthy stamp')
165
+ }
166
+
167
+ // Secondary core gate: real installed launcher signature, no provisioning or
168
+ // restart. A custom manifest also exercises package resolution outside the map.
169
+ const launcher = join(env.IAPEER_BIN_DIR, 'proof-runtime')
170
+ copyFileSync(bin, launcher)
171
+ writeRuntimeManifest({ runtime: 'proof', package: '@proof/runtime', version: '1.0.0', peers: [] }, { env })
172
+ const unexpected = () => { throw new Error('unexpected runtime install/restart') }
173
+ const runtimeEnv = { ...env, PATH: `${env.IAPEER_BIN_DIR}:${env.PATH}` }
174
+ check(installRuntimePackage({ runtime: 'proof', env: runtimeEnv, runNpx: unexpected }).state === 'skipped', 'already-installed runtime passes REAL verify-only gate')
175
+ check((await updateRuntime({ runtime: 'proof', env: runtimeEnv, npmVersion: () => '1.0.0', restartPeer: unexpected, runNpx: unexpected })).state === 'already-latest', 'already-latest runtime passes REAL verify-only gate')
176
+ const manifestPath = runtimeManifestPath('proof', { env })
177
+ writeFileSync(launcher, Buffer.from([0xcf, 0xfa, 0xed, 0xfe]))
178
+ const rejectedPair = [hash(launcher), hash(manifestPath)]
179
+ check(installRuntimePackage({ runtime: 'proof', env: runtimeEnv, runNpx: unexpected }).state === 'failed', 'already-installed runtime rejects invalid launcher')
180
+ check((await updateRuntime({ runtime: 'proof', env: runtimeEnv, npmVersion: () => '1.0.0', restartPeer: unexpected, runNpx: unexpected })).state === 'install-failed', 'already-latest runtime rejects invalid launcher before restart')
181
+ check(hash(launcher) === rejectedPair[0] && hash(manifestPath) === rejectedPair[1], 'verify-only runtime gate preserves supplied binary/manifest without repair')
182
+
183
+ check(rollbackIapeer(env).status === 'rolled-back', 'isolated foundation rollback succeeds')
184
+ check(ensureExecutableSignature(bin, { env, repair: false }).state === 'verified', 'rollback inode strictly verifies')
185
+ execute()
186
+ console.log(JSON.stringify({ version: expectedVersion, bun: Bun.version, platform: process.platform, packageRoot: pkg, checks, lifecycle: 'injected: no live restart or daemon-health claim' }, null, 2))
187
+ } finally {
188
+ rmSync(root, { recursive: true, force: true })
189
+ }
package/src/cli/index.ts CHANGED
@@ -1935,8 +1935,8 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1935
1935
  case 'update-runtime': {
1936
1936
  // §(г) runtime-package update: version-gate (npm vs the manifest stamp) →
1937
1937
  // forced re-npx → idempotent re-provision (same path as install-runtime) →
1938
- // restart the runtime's peers via the regular stop/start. The core's own
1939
- // `update` stays foundation-only — this is the runtimes' counterpart.
1938
+ // restart the runtime's peers via the regular stop/start. A bare `update`
1939
+ // calls the same updater as its runtime cascade leg.
1940
1940
  const all = flags.all === true
1941
1941
  if (!all && !positionals[0]) return argErr(errOut, 'update-runtime needs a runtime or --all — usage: iapeer update-runtime <runtime> | --all')
1942
1942
  const { updateRuntime, updateAllRuntimes } = await import('../runtime/update.ts')
@@ -20,6 +20,9 @@ import { basename, join, relative, sep } from 'path'
20
20
  import { spawnSync } from 'child_process'
21
21
  import { resolveGlobalRoot } from '../storage/index.ts'
22
22
  import { signInstalledBinary, type SigningOutcome } from './signing.ts'
23
+ import { ensureExecutableSignature } from './signature.ts'
24
+ export { ensureExecutableSignature } from './signature.ts'
25
+ export type { ExecutableSignatureOptions, ExecutableSignatureOutcome, SignatureRunner } from './signature.ts'
23
26
 
24
27
  /** The stable host-wide install path of the `iapeer` binary. Standard user-bin (no
25
28
  * admin, not tied to a node/bun version), ON $PATH. The launchd plists reference
@@ -42,8 +45,8 @@ export interface InstallResult {
42
45
  prevKept?: { reason: string }
43
46
  /** Bytes of the installed binary. */
44
47
  size?: number
45
- /** Stable-identity re-sign outcome (TCC grants survive updates). Soft: a signing
46
- * hiccup never fails the install — the binary works ad-hoc-signed. */
48
+ /** Stable-identity re-sign outcome (TCC grants survive updates). Soft failure
49
+ * is allowed only when the separate executable-signature gate succeeds. */
47
50
  signing?: SigningOutcome
48
51
  }
49
52
 
@@ -132,16 +135,38 @@ function assertInstallSandboxIsolated(binPath: string, env: NodeJS.ProcessEnv):
132
135
  }
133
136
  }
134
137
 
135
- export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = process.env): InstallResult {
138
+ /** Hermetic install seams. Paths remain resolved from the caller's env. */
139
+ export interface InstallIapeerDeps {
140
+ build?: (entry: string, tmp: string) => void
141
+ signStable?: typeof signInstalledBinary
142
+ verifySignature?: typeof ensureExecutableSignature
143
+ }
144
+
145
+ export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = process.env, deps: InstallIapeerDeps = {}): InstallResult {
136
146
  const binPath = iapeerBinPath(env)
137
147
  assertInstallSandboxIsolated(binPath, env)
138
148
  mkdirSync(join(binPath, '..'), { recursive: true })
139
149
  const tmp = `${binPath}.tmp`
140
- const build = spawnSync('bun', ['build', '--compile', cliEntrypoint, '--outfile', tmp], {
141
- encoding: 'utf8',
142
- })
143
- if (build.status !== 0 || !existsSync(tmp)) {
144
- throw new Error(`iapeer build failed: ${(build.stderr ?? '').trim() || `exit ${build.status}`}`)
150
+ let signing: SigningOutcome
151
+ try {
152
+ if (deps.build) {
153
+ deps.build(cliEntrypoint, tmp)
154
+ } else {
155
+ const build = spawnSync('bun', ['build', '--compile', cliEntrypoint, '--outfile', tmp], {
156
+ encoding: 'utf8',
157
+ })
158
+ if (build.status !== 0 || !existsSync(tmp)) {
159
+ throw new Error(`iapeer build failed: ${(build.stderr ?? '').trim() || `exit ${build.status}`}`)
160
+ }
161
+ }
162
+ // Sign the STAGED inode, then verify before ANY activation/.prev/stamp change.
163
+ // Stable TCC signing remains soft; invalid executable signatures are hard.
164
+ signing = (deps.signStable ?? signInstalledBinary)(tmp, env)
165
+ const verifySignature = deps.verifySignature ?? ensureExecutableSignature
166
+ verifySignature(tmp, { env })
167
+ } catch (e) {
168
+ rmSync(tmp, { force: true })
169
+ throw e
145
170
  }
146
171
  let prevPath: string | undefined
147
172
  let prevKept: InstallResult['prevKept']
@@ -168,9 +193,7 @@ export function installIapeer(cliEntrypoint: string, env: NodeJS.ProcessEnv = pr
168
193
  } catch {
169
194
  /* absent — fine */
170
195
  }
171
- // Stable-identity re-sign (TCC grants survive updates). AFTER the rename: the
172
- // signature belongs to the final inode at the final path. Soft-fail by design.
173
- const signing = signInstalledBinary(binPath, env)
196
+ // rename preserves the already-verified inode and its stable requirement.
174
197
  let size: number | undefined
175
198
  try {
176
199
  size = statSync(binPath).size
@@ -257,7 +280,7 @@ export interface RollbackResult {
257
280
  * rename over the binary), so the binary is never absent. The CALLER restarts the
258
281
  * daemon afterwards (cycleDaemon) — rollback only swaps the bytes. Sandbox-guarded.
259
282
  */
260
- export function rollbackIapeer(env: NodeJS.ProcessEnv = process.env): RollbackResult {
283
+ export function rollbackIapeer(env: NodeJS.ProcessEnv = process.env, deps: Pick<InstallIapeerDeps, 'signStable' | 'verifySignature'> = {}): RollbackResult {
261
284
  const binPath = iapeerBinPath(env)
262
285
  assertInstallSandboxIsolated(binPath, env)
263
286
  const prev = iapeerPrevBinPath(env)
@@ -267,13 +290,15 @@ export function rollbackIapeer(env: NodeJS.ProcessEnv = process.env): RollbackRe
267
290
  const tmp = `${binPath}.rollback.tmp`
268
291
  try {
269
292
  copyFileSync(prev, tmp)
293
+ // Same staged gate as install: never publish an invalid rollback target.
294
+ const signStable = deps.signStable ?? signInstalledBinary
295
+ const verifySignature = deps.verifySignature ?? ensureExecutableSignature
296
+ signStable(tmp, env)
297
+ verifySignature(tmp, { env })
270
298
  renameSync(tmp, binPath)
271
- // Keep the stable requirement on the restored bytes too (a .prev taken before
272
- // the signing era is ad-hoc — re-signing it heals that). Soft by design.
273
- signInstalledBinary(binPath, env)
274
299
  } catch (e) {
275
300
  try {
276
- if (existsSync(tmp)) renameSync(tmp, `${tmp}.discard`) // never leave a half-written tmp on the path
301
+ rmSync(tmp, { force: true })
277
302
  } catch {
278
303
  /* best-effort */
279
304
  }
@@ -0,0 +1,94 @@
1
+ // Lightweight package-facing activation gate. No keychain/TCC identity creation:
2
+ // preserve a valid signature; repair only a trusted, installer-owned staged file.
3
+ import { closeSync, fstatSync, openSync, readSync } from 'fs'
4
+ import { spawnSync } from 'child_process'
5
+
6
+ export interface SignatureRunner {
7
+ (command: string, args: string[], env: NodeJS.ProcessEnv): {
8
+ status: number | null
9
+ stdout?: string
10
+ stderr?: string
11
+ }
12
+ }
13
+
14
+ export interface ExecutableSignatureOptions {
15
+ env?: NodeJS.ProcessEnv
16
+ /** Default true for staged build artifacts. Use false for installed binaries. */
17
+ repair?: boolean
18
+ /** Test seams: injected runners may run under the sandbox; real codesign cannot. */
19
+ run?: SignatureRunner
20
+ platform?: NodeJS.Platform
21
+ }
22
+
23
+ export interface ExecutableSignatureOutcome {
24
+ state: 'verified' | 'repaired' | 'skipped-platform' | 'skipped-non-macho' | 'skipped-sandbox'
25
+ }
26
+
27
+ const MACH_O_MAGIC = new Set([
28
+ 0xfeedface, 0xcefaedfe, 0xfeedfacf, 0xcffaedfe, // thin, 32/64-bit, either endianness
29
+ 0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca, // universal, 32/64-bit
30
+ ])
31
+
32
+ function executableKind(path: string): 'macho' | 'script' | 'unknown' {
33
+ const fd = openSync(path, 'r') // missing/unreadable artifact is a hard failure
34
+ try {
35
+ const magic = Buffer.alloc(4)
36
+ const length = readSync(fd, magic, 0, 4, 0)
37
+ if (length === 4 && MACH_O_MAGIC.has(magic.readUInt32BE(0))) {
38
+ const value = magic.readUInt32BE(0)
39
+ const headerSize = value === 0xfeedface || value === 0xcefaedfe ? 28
40
+ : value === 0xfeedfacf || value === 0xcffaedfe ? 32 : 8
41
+ // codesign can sign a magic-only stub as generic data using xattrs. That
42
+ // success is not evidence of a Mach-O executable: require its full header.
43
+ return fstatSync(fd).size >= headerSize ? 'macho' : 'unknown'
44
+ }
45
+ if (length >= 2 && magic[0] === 0x23 && magic[1] === 0x21) return 'script'
46
+ return 'unknown'
47
+ } finally {
48
+ closeSync(fd)
49
+ }
50
+ }
51
+
52
+ const defaultRunner: SignatureRunner = (command, args, env) => {
53
+ const r = spawnSync(command, args, { encoding: 'utf8', env, timeout: 30_000 })
54
+ return { status: r.error ? null : r.status, stdout: r.stdout ?? '', stderr: r.error?.message ?? r.stderr ?? '' }
55
+ }
56
+
57
+ /**
58
+ * macOS: strict verify → (if invalid and repair allowed) ad-hoc sign → strict verify.
59
+ * Throws on failure. Call AFTER compile/copy/chmod and BEFORE rename/manifest.
60
+ * Valid certificate-backed signatures stay byte-identical. Repair establishes
61
+ * signature validity, not provenance: never use it to trust arbitrary modified code.
62
+ */
63
+ export function ensureExecutableSignature(
64
+ path: string,
65
+ opts: ExecutableSignatureOptions = {},
66
+ ): ExecutableSignatureOutcome {
67
+ if ((opts.platform ?? process.platform) !== 'darwin') return { state: 'skipped-platform' }
68
+ const env = opts.env ?? process.env
69
+ // Both flags count, so a caller cannot accidentally re-enable real commands by
70
+ // passing a sparse env. DI tests use their own runner, never the production one.
71
+ if (!opts.run && (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1')) {
72
+ return { state: 'skipped-sandbox' }
73
+ }
74
+ const kind = executableKind(path)
75
+ if (kind === 'script') return { state: 'skipped-non-macho' }
76
+ if (kind === 'unknown') throw new Error(`executable signature verification failed for ${path}: not a Mach-O executable or shebang launcher`)
77
+ const run = opts.run ?? defaultRunner
78
+ const verify = (): ReturnType<SignatureRunner> => run('/usr/bin/codesign', ['--verify', '--deep', '--strict', path], env)
79
+ const fail = (step: string, result: ReturnType<SignatureRunner>): never => {
80
+ const detail = result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status}`
81
+ throw new Error(`executable signature ${step} failed for ${path}: ${detail}`)
82
+ }
83
+ const initial = verify()
84
+ if (initial.status === 0) return { state: 'verified' }
85
+ // A timeout/spawn error/signal is not evidence of an invalid signature. Do not
86
+ // rewrite code when the verification tool itself could not run to completion.
87
+ if (initial.status === null) fail('verification', initial)
88
+ if (opts.repair === false) fail('verification', initial)
89
+ const signed = run('/usr/bin/codesign', ['--force', '--sign', '-', path], env)
90
+ if (signed.status !== 0) fail('repair', signed)
91
+ const final = verify()
92
+ if (final.status !== 0) fail('verification after repair', final)
93
+ return { state: 'repaired' }
94
+ }
@@ -16,9 +16,9 @@
16
16
  // bytes. Trust of the cert chain is NOT needed: codesign signs with an untrusted
17
17
  // (CSSMERR_TP_NOT_TRUSTED) identity fine, and TCC matches the requirement.
18
18
  //
19
- // Failure policy: SOFT. The binary works ad-hoc-signed exactly as before; a
20
- // signing hiccup must never break install/update. It is reported loud (the
21
- // operator learns TCC prompts will re-appear) but the install succeeds.
19
+ // Stable-identity failure policy: SOFT. Executable signature validity is a
20
+ // separate HARD gate (signature.ts) before activation; invalid Bun linker
21
+ // signatures cannot be assumed runnable after a stable-signing failure.
22
22
 
23
23
  import { mkdtempSync, rmSync } from 'fs'
24
24
  import { tmpdir } from 'os'
@@ -63,7 +63,7 @@ export interface SigningOutcome {
63
63
  | 'signed' // re-signed with the existing identity
64
64
  | 'signed-new-identity' // identity created this run (the ONE install-time event), then signed
65
65
  | 'skipped-sandbox' // tests never touch the real keychain
66
- | 'failed-soft' // signing failed — binary stays ad-hoc (works; TCC prompts return)
66
+ | 'failed-soft' // stable signing failed; the activation gate still decides executability
67
67
  detail?: string
68
68
  }
69
69
 
@@ -113,7 +113,7 @@ function createIdentity(run: SigningRunner): { ok: boolean; detail?: string } {
113
113
 
114
114
  /**
115
115
  * Re-sign the installed binary with the stable local identity (creating the
116
- * identity on first use). Called by installIapeer after the atomic rename —
116
+ * identity on first use). Called by installIapeer BEFORE the atomic rename —
117
117
  * i.e. on EVERY install/update path, so the designated requirement (and with it
118
118
  * every TCC grant) stays constant while the bytes change.
119
119
  */
@@ -131,7 +131,7 @@ export function signInstalledBinary(
131
131
  if (!identityPresent(run)) {
132
132
  const c = createIdentity(run)
133
133
  if (!c.ok) {
134
- return { state: 'failed-soft', detail: `${c.detail} — binary stays ad-hoc-signed (works, but TCC prompts will re-appear after updates)` }
134
+ return { state: 'failed-soft', detail: `${c.detail} — stable TCC signing unavailable; executable signature must pass the activation gate (TCC prompts may re-appear after updates)` }
135
135
  }
136
136
  created = true
137
137
  }
@@ -139,7 +139,7 @@ export function signInstalledBinary(
139
139
  if (sign.status !== 0) {
140
140
  return {
141
141
  state: 'failed-soft',
142
- detail: `codesign failed: ${sign.stderr.trim().split('\n')[0] ?? `exit ${sign.status}`} — binary stays ad-hoc-signed (works, but TCC prompts will re-appear after updates)`,
142
+ detail: `codesign failed: ${sign.stderr.trim().split('\n')[0] ?? `exit ${sign.status}`} — stable TCC signing unavailable; executable signature must pass the activation gate (TCC prompts may re-appear after updates)`,
143
143
  }
144
144
  }
145
145
  return created ? { state: 'signed-new-identity' } : { state: 'signed' }
@@ -366,6 +366,13 @@ export const claudeAdapter: RuntimeAdapter = {
366
366
  if (pane.includes('Resume from summary') || pane.includes('Bypass Permissions mode')) {
367
367
  return /❯\s*2\./.test(pane) ? ['Enter'] : ['Down']
368
368
  }
369
+ // Folder-trust: older claude defaulted the cursor to "1. Yes, proceed", but 2.1.30x
370
+ // renders "❯ No, exit" / "Yes, I trust this folder" with the DEFAULT on exit — a bare
371
+ // Enter cleanly exits the peer (live: claude-boris child-exit 5s after first wake,
372
+ // 2026-10-17). Same discipline as Bypass: Enter only once the cursor is SEEN on Yes.
373
+ if (pane.includes('trust this folder') && /No,\s*exit/.test(pane)) {
374
+ return /❯\s*(?:\d\.\s*)?Yes\b/.test(pane) ? ['Enter'] : ['Down']
375
+ }
369
376
  if (
370
377
  pane.includes('trust this folder') ||
371
378
  pane.includes('Allow external CLAUDE.md file imports?') ||
@@ -162,7 +162,7 @@ export async function runAlwaysOn(personality: string, runtime: string, cwd: str
162
162
  interrupt = null
163
163
  }
164
164
  // Signal-initiated stop (bootout / shutdown / kickstart -k) tears the session down WITH this watcher —
165
- // kill the supervisor daemon (killPtyHost: SIGTERM → it SIGKILLs the child + cleans up sock/pid/serve)
165
+ // kill the supervisor daemon (SIGTERM → hard teardown of its PTY-owned tree + sock/pid/serve cleanup)
166
166
  // so the detached child does not outlive bootout holding stale in-memory state. A NATURAL death
167
167
  // (stop=false) skips this: nothing to tear down, exit 0 → KeepAlive respawns a fresh bring-up.
168
168
  if (stop) killPtyHost(identity, env)
@@ -45,7 +45,7 @@ export function hostSessionAlive(identity: string, env: NodeJS.ProcessEnv = proc
45
45
  return hostDaemonAlive(hostRunDir(env), identity)
46
46
  }
47
47
 
48
- /** Reap a hosted session: SIGTERM the supervisor daemon (its shutdown SIGKILLs the child + cleans up).
48
+ /** Reap a hosted session: SIGTERM the supervisor daemon (hard-kills its PTY-owned tree + cleans up).
49
49
  * env sandboxes the run-dir: a test with an injected root must NEVER SIGTERM a real fleet peer. */
50
50
  export function killPtyHost(identity: string, env: NodeJS.ProcessEnv = process.env): void {
51
51
  killSupervisorSession(hostRunDir(env), identity)
@@ -1257,7 +1257,7 @@ export async function wakeOrSpawn(args: WakeArgs, deps: WakeDeps = {}): Promise<
1257
1257
  // ─────────────────────────────────────────────────────────────────────────────
1258
1258
 
1259
1259
  export function killSession(_sock: string, identity: string, env: NodeJS.ProcessEnv = process.env): void {
1260
- // pty-only: SIGTERM the supervisor daemon — its shutdown SIGKILLs the child and removes the
1260
+ // pty-only: SIGTERM the supervisor daemon — its shutdown kills the PTY-owned tree and removes the
1261
1261
  // sock/pid/serve-spec/markers. Idempotent if the session is already gone. (`_sock` retained for
1262
1262
  // call-site compatibility; no tmux sockets exist.) env sandboxes the run-dir — a test with an
1263
1263
  // injected root must NEVER SIGTERM a real fleet peer's supervisor.
@@ -22,6 +22,7 @@ import { type Runtime } from '../core/constants.ts'
22
22
  import { IapError } from '../core/errors.ts'
23
23
  import { createPeer, type CreatePeerResult } from '../create/index.ts'
24
24
  import { readRuntimeManifest, writeRuntimeManifest, type RuntimeManifest, type RuntimePeerDecl } from './index.ts'
25
+ import { verifyRuntimeSignatures, type RuntimeSignatureVerifier } from './signature.ts'
25
26
 
26
27
  // ─────────────────────────────────────────────────────────────────────────────
27
28
  // Built-in runtime → npm package registry (§6): onboard AUTO-resolves
@@ -52,6 +53,7 @@ export interface DeployRuntimeOptions {
52
53
  bootstrap?: boolean
53
54
  env?: NodeJS.ProcessEnv
54
55
  warn?: (message: string) => void
56
+ verifySignatures?: RuntimeSignatureVerifier
55
57
  }
56
58
 
57
59
  export interface DeployedPeer {
@@ -90,6 +92,8 @@ export async function deployRuntime(opts: DeployRuntimeOptions): Promise<DeployR
90
92
  )
91
93
  }
92
94
  const declared = opts.peers ?? manifest.peers ?? []
95
+ const verifySignatures = opts.verifySignatures ?? verifyRuntimeSignatures
96
+ verifySignatures(opts.runtime, env, { ...manifest, peers: declared })
93
97
  const peers: DeployedPeer[] = []
94
98
  for (const decl of declared) {
95
99
  const result = await createPeer({
@@ -164,6 +168,8 @@ export interface InstallRuntimePackageOptions {
164
168
  env?: NodeJS.ProcessEnv
165
169
  /** Injected npx runner (tests / sandbox proof). */
166
170
  runNpx?: NpxRunner
171
+ /** Verify-only activation gate; injected in hermetic orchestration tests. */
172
+ verifySignatures?: RuntimeSignatureVerifier
167
173
  }
168
174
 
169
175
  /**
@@ -183,6 +189,11 @@ export function installRuntimePackage(opts: InstallRuntimePackageOptions): Insta
183
189
  const existing = readRuntimeManifest(opts.runtime, { env })
184
190
  const pkg = resolveRuntimePackage(opts.runtime, opts.package ?? existing?.package)
185
191
  if (existing && !opts.force) {
192
+ try {
193
+ (opts.verifySignatures ?? verifyRuntimeSignatures)(opts.runtime, env)
194
+ } catch (e) {
195
+ return { runtime: opts.runtime, package: pkg, state: 'failed', detail: e instanceof Error ? e.message : String(e) }
196
+ }
186
197
  return { runtime: opts.runtime, package: pkg, state: 'skipped' }
187
198
  }
188
199
  if (!pkg) {
@@ -194,6 +205,13 @@ export function installRuntimePackage(opts: InstallRuntimePackageOptions): Insta
194
205
  if (!r.ok) {
195
206
  return { runtime: opts.runtime, package: pkg, state: 'failed', detail: r.detail }
196
207
  }
208
+ // Verification is not version-gated and precedes manifest stamping/provision.
209
+ // Never repair an installed launcher here: the package owns staged repair.
210
+ try {
211
+ (opts.verifySignatures ?? verifyRuntimeSignatures)(opts.runtime, env)
212
+ } catch (e) {
213
+ return { runtime: opts.runtime, package: pkg, state: 'failed', detail: e instanceof Error ? e.message : String(e) }
214
+ }
197
215
  // В52 — stamp the delivering package into the fresh manifest (best-effort: a
198
216
  // missing manifest is the package's own contract breach, surfaced elsewhere).
199
217
  try {
@@ -212,6 +230,7 @@ export interface OnboardRuntimeOptions extends DeployRuntimeOptions {
212
230
  npx?: boolean
213
231
  /** Injected npx runner (tests / sandbox proof). */
214
232
  runNpx?: NpxRunner
233
+ verifySignatures?: RuntimeSignatureVerifier
215
234
  }
216
235
 
217
236
  export interface OnboardRuntimeResult {
@@ -235,6 +254,7 @@ export async function onboardRuntime(opts: OnboardRuntimeOptions): Promise<Onboa
235
254
  force: opts.npx,
236
255
  env,
237
256
  runNpx: opts.runNpx,
257
+ verifySignatures: opts.verifySignatures,
238
258
  })
239
259
  if (install.state === 'failed') {
240
260
  throw new IapError(`npx install of runtime "${opts.runtime}" package ${install.package} failed: ${install.detail ?? ''}`)
@@ -250,6 +270,7 @@ export async function onboardRuntime(opts: OnboardRuntimeOptions): Promise<Onboa
250
270
  bootstrap: opts.bootstrap,
251
271
  env,
252
272
  warn: opts.warn,
273
+ verifySignatures: opts.verifySignatures,
253
274
  })
254
275
  return { install, deploy }
255
276
  }
@@ -0,0 +1,35 @@
1
+ // Secondary, verify-only gate. The package must verify its staged inode BEFORE
2
+ // publishing it; the foundation refuses provisioning/restart of invalid code.
3
+ import { ensureExecutableSignature, type ExecutableSignatureOptions } from '../install/signature.ts'
4
+ import { resolveExecutable } from '../launch/launchd.ts'
5
+ import { type Runtime } from '../core/constants.ts'
6
+ import { readRuntimeManifest, type RuntimeManifest } from './index.ts'
7
+
8
+ export type RuntimeSignatureVerifier = (runtime: Runtime, env: NodeJS.ProcessEnv, manifest?: RuntimeManifest) => void
9
+
10
+ export function verifyRuntimeSignatures(
11
+ runtime: Runtime,
12
+ env: NodeJS.ProcessEnv,
13
+ suppliedManifest?: RuntimeManifest,
14
+ opts: Pick<ExecutableSignatureOptions, 'run' | 'platform'> = {},
15
+ ): void {
16
+ if ((opts.platform ?? process.platform) !== 'darwin') return
17
+ if (!opts.run && (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1')) return
18
+ const manifest = suppliedManifest ?? readRuntimeManifest(runtime, { env })
19
+ const names = new Set<string>()
20
+ const declared = manifest?.peers ?? []
21
+ // The same default launcher the core's provisioning/launch scheme uses. A
22
+ // fully explicit declared-set needs only its explicitly declared launchers.
23
+ if (!declared.length || declared.some(p => !p.runtimeBin)) names.add(`${runtime}-runtime`)
24
+ for (const peer of declared) if (peer.runtimeBin) names.add(peer.runtimeBin)
25
+ const hook = manifest?.selfConfig
26
+ if (hook) names.add(typeof hook === 'string' ? hook : hook.command)
27
+ const checked = new Set<string>()
28
+ for (const name of names) {
29
+ const path = resolveExecutable(name, env)
30
+ if (!path) throw new Error(`runtime "${runtime}" launcher not executable or not found: ${name}`)
31
+ if (checked.has(path)) continue
32
+ checked.add(path)
33
+ ensureExecutableSignature(path, { ...opts, env, repair: false })
34
+ }
35
+ }
@@ -10,8 +10,8 @@
10
10
  // version with a new blurb/self-doc leaves stale descriptions) → restart the
11
11
  // runtime's infra peers via the REGULAR stop/start verbs.
12
12
  //
13
- // The core's own `iapeer update` deliberately does NOT touch runtimes (foundation-
14
- // only — the standing contract; the symmetry is conscious).
13
+ // The CLI's bare `iapeer update` invokes this runtime updater in its cascade;
14
+ // --foundation-only and a foundation version pin omit the runtime leg.
15
15
  //
16
16
  // Version-gate honesty: the installed version comes from the manifest's `version`
17
17
  // stamp (the owners' self-install obligation, telegram 10.06). A manifest WITHOUT
@@ -24,6 +24,7 @@ import { isRuntime, type Runtime } from '../core/constants.ts'
24
24
  import { readPeersIndex } from '../registry/index.ts'
25
25
  import { resolveGlobalRoot } from '../storage/index.ts'
26
26
  import { readRuntimeManifest } from './index.ts'
27
+ import { verifyRuntimeSignatures, type RuntimeSignatureVerifier } from './signature.ts'
27
28
  import {
28
29
  deployRuntime,
29
30
  installRuntimePackage,
@@ -71,6 +72,7 @@ export interface UpdateRuntimeOptions {
71
72
  force?: boolean
72
73
  env?: NodeJS.ProcessEnv
73
74
  runNpx?: NpxRunner
75
+ verifySignatures?: RuntimeSignatureVerifier
74
76
  npmVersion?: NpmVersionFn
75
77
  /** Injectable restart (tests). Default: the regular stop→start verbs, strictly
76
78
  * sequential per peer. */
@@ -124,12 +126,17 @@ export async function updateRuntime(opts: UpdateRuntimeOptions): Promise<UpdateR
124
126
  }
125
127
  const installed = manifest.version
126
128
  if (installed && installed === latest && !opts.force) {
129
+ try {
130
+ (opts.verifySignatures ?? verifyRuntimeSignatures)(runtime, env)
131
+ } catch (e) {
132
+ return { runtime, package: pkg, state: 'install-failed', from: installed, to: latest, peers: [], restarted: [], detail: e instanceof Error ? e.message : String(e) }
133
+ }
127
134
  return { runtime, package: pkg, state: 'already-latest', from: installed, to: latest, peers: [], restarted: [] }
128
135
  }
129
136
 
130
137
  // Re-install, PINNED to the version the gate resolved (В51 — an unpinned npx can
131
138
  // serve a stale cached/propagating build while we report "updated → latest").
132
- const install = installRuntimePackage({ runtime, package: manifest.package, version: latest, force: true, env, runNpx: opts.runNpx })
139
+ const install = installRuntimePackage({ runtime, package: manifest.package, version: latest, force: true, env, runNpx: opts.runNpx, verifySignatures: opts.verifySignatures })
133
140
  if (install.state !== 'ran') {
134
141
  return { runtime, package: pkg, state: 'install-failed', from: installed, to: latest, peers: [], restarted: [], detail: install.detail ?? install.state }
135
142
  }
@@ -157,7 +164,7 @@ export async function updateRuntime(opts: UpdateRuntimeOptions): Promise<UpdateR
157
164
  // peers — the deploy is an empty pass, by design.
158
165
  let peers: DeployedPeer[]
159
166
  try {
160
- const d = await deployRuntime({ runtime, env, warn: opts.warn })
167
+ const d = await deployRuntime({ runtime, env, warn: opts.warn, verifySignatures: opts.verifySignatures })
161
168
  peers = d.peers
162
169
  } catch (e) {
163
170
  return { runtime, package: pkg, state: 'deploy-failed', from: installed, to: latest, peers: [], restarted: [], detail: e instanceof Error ? e.message : String(e) }
@@ -26,13 +26,14 @@ import {
26
26
  stripQueries,
27
27
  } from './protocol.ts'
28
28
  import { formatEventLine } from '../storage/rotatelog.ts'
29
- import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry, writePidFile } from './paths.ts'
29
+ import { attachedPath, geometryPath, pidPath, pidStartToken, servePath, sockPath, writeGeometry, writePidFile } from './paths.ts'
30
30
  import { newStabilityGate, nagSignature, nextBootAction, nextNagAction, signatureStable, type BootPredicates, type NagPredicate } from './boot.ts'
31
31
  import { modelToPlainText } from './render.ts'
32
32
  import { codexAdapter } from '../launch/adapters/codex.ts'
33
33
  import { claudeAdapter } from '../launch/adapters/claude.ts'
34
34
  import { approvalModeOf, readPeerProfile, type ApprovalMode } from '../identity/index.ts'
35
35
  import { routeCircuitBreaker } from './approvalRoute.ts'
36
+ import { killPtyDescendants } from './processTree.ts'
36
37
 
37
38
  /** Resolve a runtime launcher from the host AT CALL TIME — env override (IAPEER_<RT>_BIN, the same
38
39
  * knob the rest of the foundation uses) → PATH lookup — never a baked machine-specific path. Returns
@@ -110,6 +111,7 @@ const SIGWINCH_REDRAW_WAIT_MS = 300
110
111
 
111
112
  // Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
112
113
  interface PtyChild {
114
+ pid: number
113
115
  terminal: { write(d: Uint8Array | string): void; resize(cols: number, rows: number): void }
114
116
  kill(sig?: number | string): void
115
117
  }
@@ -385,6 +387,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
385
387
  },
386
388
  },
387
389
  } as unknown as Parameters<typeof Bun.spawn>[1]) as unknown as PtyChild
390
+ const childToken = pidStartToken(child.pid)
388
391
  xterm.onData(resp => child.terminal.write(resp)) // @xterm auto-answers DA1/DSR → back to child
389
392
 
390
393
  /** Catch up a (re)attaching client to the current screen + history ONCE by sending a SNAPSHOT of the
@@ -602,6 +605,14 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
602
605
  } catch {
603
606
  /* */
604
607
  }
608
+ try {
609
+ // self-fresh/stop/reap owns the PTY's descendants, not just its TUI root. Snapshot BEFORE
610
+ // killing the runtime; exclude detached/shared daemons even if they are descendants.
611
+ const cleaned = killPtyDescendants(child.pid, childToken)
612
+ if (cleaned.killed || cleaned.skipped) appendExitEvent({ ev: 'session-tree', identity: session, ...cleaned })
613
+ } catch (e) {
614
+ appendExitEvent({ ev: 'session-tree', identity: session, error: (e as Error).message })
615
+ }
605
616
  try {
606
617
  child.kill('SIGKILL')
607
618
  } catch {
@@ -0,0 +1,57 @@
1
+ // Deterministic stdio process tree: peer -> launcher -> MCP server.
2
+ // No accounts, external services, or installed runtime binaries are used.
3
+ import { spawn } from 'node:child_process'
4
+ import { appendFileSync, writeFileSync } from 'node:fs'
5
+ import { createInterface } from 'node:readline'
6
+
7
+ const [mode, dir, cli] = process.argv.slice(2)
8
+ const self = import.meta.path
9
+ const record = (event: string, extra = {}) => appendFileSync(`${dir}/events.jsonl`, JSON.stringify({ event, pid: process.pid, ppid: process.ppid, ...extra }) + '\n')
10
+ record(mode)
11
+
12
+ if (mode === 'server') {
13
+ // Keep-alive handles after EOF model a server that cannot shut down by EOF alone.
14
+ // The supervisor's hard session teardown must reclaim it, not rely on compliance.
15
+ setInterval(() => {}, 1000)
16
+ process.on('SIGHUP', () => record('server-hup-ignored'))
17
+ process.on('SIGTERM', () => record('server-term-ignored'))
18
+ const input = createInterface({ input: process.stdin })
19
+ input.on('close', () => record('server-eof'))
20
+ input.on('line', line => {
21
+ const req = JSON.parse(line)
22
+ record(req.method)
23
+ if (req.id === undefined) return
24
+ const result = req.method === 'initialize'
25
+ ? { protocolVersion: req.params.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: 'owned-stdio', version: '1' } }
26
+ : req.method === 'tools/list'
27
+ ? { tools: [{ name: 'ping', description: 'Return the serving process ID', inputSchema: { type: 'object', properties: {} } }] }
28
+ : { content: [{ type: 'text', text: `pong:${process.pid}` }] }
29
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n')
30
+ })
31
+ } else if (mode === 'wrapper') {
32
+ const server = spawn(process.execPath, [self, 'server', dir], { stdio: 'inherit' })
33
+ process.on('SIGTERM', () => record('wrapper-term-ignored'))
34
+ process.on('SIGHUP', () => record('wrapper-hup-ignored'))
35
+ server.on('exit', () => process.exit(0))
36
+ } else if (mode === 'peer') {
37
+ const sentinel = spawn('sleep', ['60'], { detached: true, stdio: 'ignore' })
38
+ sentinel.unref()
39
+ record('detached-sentinel', { pid: sentinel.pid })
40
+ const wrapper = spawn(process.execPath, [self, 'wrapper', dir], { stdio: ['pipe', 'pipe', 'inherit'] })
41
+ const input = createInterface({ input: wrapper.stdout })
42
+ const send = (id: number, method: string, params: unknown = {}) => wrapper.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n')
43
+ input.on('line', line => {
44
+ const response = JSON.parse(line)
45
+ if (response.id === 1) send(2, 'tools/list')
46
+ if (response.id === 2) send(3, 'tools/call', { name: 'ping', arguments: {} })
47
+ if (response.id === 3) {
48
+ writeFileSync(`${dir}/ready.json`, JSON.stringify({ peer: process.pid, wrapper: wrapper.pid, result: response.result }))
49
+ console.log('› ready')
50
+ }
51
+ })
52
+ send(1, 'initialize', { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'test-peer', version: '1' } })
53
+ // This is an actual child self-call to the source CLI, not killSession in disguise.
54
+ createInterface({ input: process.stdin }).on('line', line => {
55
+ if (line.replace(/\x1b\[20[01]~/g, '').trim() === 'fresh') spawn(process.execPath, [cli, 'self-fresh'], { stdio: 'inherit', env: process.env })
56
+ })
57
+ }
@@ -0,0 +1,71 @@
1
+ import { spawnSync } from 'node:child_process'
2
+
3
+ export interface ProcessRecord { pid: number; ppid: number; tty: string; token: string }
4
+
5
+ /** No argv/env in this snapshot: ancestry + controlling PTY, not names or groups, own cleanup. */
6
+ export function parseProcessTable(table: string): ProcessRecord[] {
7
+ return table.split('\n').flatMap(line => {
8
+ const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+(.+?)\s*$/.exec(line)
9
+ if (!m || Number(m[1]) <= 0) return []
10
+ return [{ pid: Number(m[1]), ppid: Number(m[2]), tty: m[3], token: m[4] }]
11
+ })
12
+ }
13
+
14
+ function processTable(pid?: number): ProcessRecord[] {
15
+ const args = pid === undefined ? ['-axo', 'pid=,ppid=,tty=,lstart='] : ['-o', 'pid=,ppid=,tty=,lstart=', '-p', String(pid)]
16
+ const r = spawnSync('/bin/ps', args, { encoding: 'utf8', env: { ...process.env, LC_ALL: 'C' } })
17
+ if (r.error || r.status !== 0) throw new Error('could not read process ancestry for session teardown')
18
+ return parseProcessTable(r.stdout)
19
+ }
20
+
21
+ /** Capture BEFORE killing the runtime: afterwards stdio launchers may already have PPID 1.
22
+ * The controlling PTY is the ownership boundary: separate process groups (Codex stdio MCP) remain
23
+ * inside it, whereas setsid/detached services (possibly shared with other clients) leave it. Prune
24
+ * those entire branches. Never signal a whole group: only proven PTY descendants whose start token
25
+ * still matches are ours. This preserves the existing HARD session teardown semantics; it is not
26
+ * a replacement for an MCP client's graceful EOF protocol.
27
+ * Already-reparented children and children racing the snapshot are outside this ancestry snapshot. */
28
+ export function killPtyDescendants(root: number, rootToken: string | null, deps: {
29
+ table?: () => ProcessRecord[]
30
+ identity?: (pid: number) => Pick<ProcessRecord, 'token' | 'tty'> | null
31
+ signal?: (pid: number) => void
32
+ } = {}): { killed: number; skipped: number } {
33
+ if (!Number.isInteger(root) || root <= 1 || root === process.pid) throw new Error('invalid hosted runtime pid')
34
+ const rows = (deps.table ?? processTable)()
35
+ // The root may already have died (terminal.exit), even before this snapshot. Do not
36
+ // adopt an unrelated tree on PID reuse; a missing root needs no ancestry cleanup.
37
+ const currentRoot = rows.find(r => r.pid === root)
38
+ if (!currentRoot) return { killed: 0, skipped: 0 }
39
+ if (!rootToken || currentRoot.token !== rootToken) return { killed: 0, skipped: 1 }
40
+ if (['', '-', '??', '?'].includes(currentRoot.tty)) return { killed: 0, skipped: 1 }
41
+ const children = new Map<number, ProcessRecord[]>()
42
+ for (const row of rows) {
43
+ if (!children.has(row.ppid)) children.set(row.ppid, [])
44
+ children.get(row.ppid)!.push(row)
45
+ }
46
+ const seen = new Set([root, process.pid])
47
+ const owned: ProcessRecord[] = []
48
+ const visit = (parent: number) => {
49
+ for (const row of children.get(parent) ?? []) {
50
+ if (seen.has(row.pid)) continue
51
+ if (row.tty !== currentRoot.tty) continue // detached daemon and everything it owns are not this PTY's
52
+ seen.add(row.pid)
53
+ visit(row.pid)
54
+ owned.push(row) // leaves first, while ancestry is still available
55
+ }
56
+ }
57
+ visit(root)
58
+ let killed = 0, skipped = 0
59
+ for (const row of owned) {
60
+ // Unlike killing the root by its Bun subprocess handle, a descendant is a ps-derived PID.
61
+ // An unreadable token is NOT permission to signal it; fail closed on reuse / uncertainty.
62
+ let current: Pick<ProcessRecord, 'token' | 'tty'> | null
63
+ try { current = (deps.identity ?? (pid => processTable(pid)[0] ?? null))(row.pid) } catch { current = null }
64
+ if (current?.token !== row.token || current.tty !== row.tty) { skipped++; continue }
65
+ try {
66
+ (deps.signal ?? (pid => process.kill(pid, 'SIGKILL')))(row.pid)
67
+ killed++
68
+ } catch { /* exited between verification and signal */ }
69
+ }
70
+ return { killed, skipped }
71
+ }
@@ -36,7 +36,8 @@ import { join } from 'path'
36
36
  import { isInfraRuntime } from '../core/constants.ts'
37
37
  import { IapError } from '../core/errors.ts'
38
38
  import { IAPEER_VERSION } from '../core/version.ts'
39
- import { stampBinaryHealthy } from '../install/index.ts'
39
+ import { iapeerBinPath, stampBinaryHealthy } from '../install/index.ts'
40
+ import { ensureExecutableSignature } from '../install/signature.ts'
40
41
  import { readPeersIndex } from '../registry/index.ts'
41
42
  import {
42
43
  cycleDaemon,
@@ -71,6 +72,9 @@ export interface UpdateDeps {
71
72
  /** Pull + rebuild the binary for `version` (default: npm pack → extract → source install).
72
73
  * Returns true on success. */
73
74
  runInstall?: (version: string, env: NodeJS.ProcessEnv) => boolean
75
+ /** Verify-only installed-binary gate, including same-version stale-daemon heal.
76
+ * Never repair a live binary. The package installer owns staged repair. */
77
+ verifyInstalledSignature?: (env: NodeJS.ProcessEnv) => void
74
78
  /** Restart the daemon onto the new binary (default: cycleDaemon — bootout+bootstrap, the LWCR-safe cycle). */
75
79
  restartDaemon?: (env: NodeJS.ProcessEnv) => DaemonRestartResult
76
80
  /** Re-register loaded foundation-owned infra launchd jobs whose plists run the
@@ -345,7 +349,20 @@ export async function updateIapeer(deps: UpdateDeps = {}): Promise<UpdateResult>
345
349
  : `could not resolve the latest ${IAPEER_PACKAGE} version from npm (offline / registry error)`,
346
350
  }
347
351
  }
352
+ const verifyInstalled = (): string | undefined => {
353
+ try {
354
+ if (deps.verifyInstalledSignature) deps.verifyInstalledSignature(env)
355
+ else ensureExecutableSignature(iapeerBinPath(env), { env, repair: false })
356
+ return undefined
357
+ } catch (e) {
358
+ return `installed foundation signature is invalid; no restart attempted: ${e instanceof Error ? e.message : String(e)}`
359
+ }
360
+ }
348
361
  if (desired === from && !deps.force) {
362
+ // Same-version heal is an activation path too: a valid running inode/version
363
+ // does not prove the current on-disk binary is safe to restart.
364
+ const signatureFailure = verifyInstalled()
365
+ if (signatureFailure) return { status: 'failed', from, latest: desired, reason: signatureFailure }
349
366
  // В53 — the binary is at the target, but is the LIVE daemon running it? A prior
350
367
  // update that swapped the binary and then died before/inside cycleDaemon leaves
351
368
  // the fleet on the old core FOREVER while every retry says "already-latest".
@@ -384,6 +401,9 @@ export async function updateIapeer(deps: UpdateDeps = {}): Promise<UpdateResult>
384
401
  }
385
402
  }
386
403
 
404
+ const signatureFailure = verifyInstalled()
405
+ if (signatureFailure) return { status: 'failed', from, latest: desired, reason: signatureFailure }
406
+
387
407
  const d = (deps.restartDaemon ?? cycleDaemon)(env)
388
408
  const settled = await settleDaemon(d, env, deps)
389
409
  return {