@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 +18 -0
- package/docs/04-lifecycle-and-daemon.md +11 -0
- package/docs/07-install-and-update.md +3 -3
- package/docs/13-architecture-and-contract.md +43 -0
- package/docs/ru/04-/320/266/320/270/320/267/320/275/320/265/320/275/320/275/321/213/320/271-/321/206/320/270/320/272/320/273-/320/270-/320/264/320/265/320/274/320/276/320/275.md +11 -0
- package/docs/ru/07-/321/203/321/201/321/202/320/260/320/275/320/276/320/262/320/272/320/260-/320/270-/320/276/320/261/320/275/320/276/320/262/320/273/320/265/320/275/320/270/320/265.md +3 -3
- package/docs/ru/13-/320/260/321/200/321/205/320/270/321/202/320/265/320/272/321/202/321/203/321/200/320/260-/320/270-/320/272/320/276/320/275/321/202/321/200/320/260/320/272/321/202.md +45 -0
- package/package.json +4 -2
- package/scripts/proof-install-signature.ts +189 -0
- package/src/cli/index.ts +2 -2
- package/src/install/index.ts +41 -16
- package/src/install/signature.ts +94 -0
- package/src/install/signing.ts +7 -7
- package/src/launch/adapters/claude.ts +7 -0
- package/src/launch/launchdRun.ts +1 -1
- package/src/launch/ptyHost.ts +1 -1
- package/src/lifecycle/index.ts +1 -1
- package/src/runtime/deploy.ts +21 -0
- package/src/runtime/signature.ts +35 -0
- package/src/runtime/update.ts +11 -4
- package/src/supervisor/daemon.ts +12 -1
- package/src/supervisor/fixtures/owned-stdio.ts +57 -0
- package/src/supervisor/processTree.ts +71 -0
- package/src/update/index.ts +21 -1
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,
|
|
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
|
|
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
|
|
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 один раз спросит пароль логин-связки при создании этой идентичности. Это ожидаемо (инсталлер анонсирует). При отказе
|
|
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`
|
|
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.
|
|
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.
|
|
1939
|
-
//
|
|
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')
|
package/src/install/index.ts
CHANGED
|
@@ -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
|
|
46
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|
package/src/install/signing.ts
CHANGED
|
@@ -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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
|
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
|
|
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} —
|
|
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}`} —
|
|
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?') ||
|
package/src/launch/launchdRun.ts
CHANGED
|
@@ -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 (
|
|
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)
|
package/src/launch/ptyHost.ts
CHANGED
|
@@ -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
|
|
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)
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -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
|
|
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.
|
package/src/runtime/deploy.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/runtime/update.ts
CHANGED
|
@@ -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
|
|
14
|
-
// only
|
|
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) }
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/update/index.ts
CHANGED
|
@@ -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 {
|