@agfpd/iapeer 0.4.109 → 0.4.110
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 +9 -0
- package/docs/06-storage.md +2 -1
- package/docs/10-messaging.md +12 -0
- package/docs/12-cli-reference.md +1 -1
- package/docs/13-architecture-and-contract.md +1 -0
- package/docs/15-fleet-api.md +2 -2
- package/docs/18-origin-guard.md +2 -2
- package/docs/ru/06-/321/205/321/200/320/260/320/275/320/265/320/275/320/270/320/265.md +2 -1
- package/docs/ru/10-/320/276/320/261/320/274/320/265/320/275-/321/201/320/276/320/276/320/261/321/211/320/265/320/275/320/270/321/217/320/274/320/270.md +12 -0
- package/docs/ru/12-cli-reference.md +1 -1
- 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 +1 -0
- package/docs/ru/15-fleet-api.md +2 -2
- package/docs/ru/18-origin-guard.md +2 -2
- package/package.json +1 -1
- package/src/cli/index.ts +23 -1
- package/src/daemon/fleet.ts +8 -1
- package/src/daemon/index.ts +1 -1
- package/src/transport/attachments.ts +163 -0
- package/src/transport/index.ts +28 -4
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **IAP attachments now transfer ownership instead of forwarding fragile source paths.**
|
|
16
|
+
Every `send_to_peer`, `iapeer send`, and Fleet API send copies regular-file sources
|
|
17
|
+
into the target peer's durable, content-addressed foundation inbox before an
|
|
18
|
+
envelope can be accepted. The recipient can read or forward the copy after the
|
|
19
|
+
sender deletes `/tmp` or otherwise removes the source. Copies live for the
|
|
20
|
+
recipient peer's lifetime and `iapeer remove` purges its inbox.
|
|
21
|
+
|
|
13
22
|
## [0.4.25] - 2026-06-22
|
|
14
23
|
|
|
15
24
|
### Fixed
|
package/docs/06-storage.md
CHANGED
|
@@ -26,6 +26,7 @@ The same plugin can store data at both levels: shared in `~/.iapeer/`, peer-boun
|
|
|
26
26
|
│ └── .iapeer/ (peer-level layout — see below)
|
|
27
27
|
├── state/
|
|
28
28
|
│ ├── iapeer/ daemon state (router.sock, router.json …)
|
|
29
|
+
│ │ └── attachments/ recipient-owned IAP attachment copies
|
|
29
30
|
│ └── <plugin>/ a plugin's mutable state
|
|
30
31
|
├── logs/
|
|
31
32
|
│ ├── iapeer/ daemon logs: lifecycle.log, delivery.log, exits.log
|
|
@@ -53,7 +54,7 @@ The same plugin can store data at both levels: shared in `~/.iapeer/`, peer-boun
|
|
|
53
54
|
A few nodes worth highlighting:
|
|
54
55
|
|
|
55
56
|
- **`peers-profiles.json`** — the registry. Written only through a single writer under the file lock `peers-profiles.lock`; direct editing is forbidden at the core level.
|
|
56
|
-
- **`state/iapeer/`** — where the daemon lives: its local socket `router.sock
|
|
57
|
+
- **`state/iapeer/`** — where the daemon lives: its local socket `router.sock`, config `router.json`, and `attachments/<recipient>/<sha256>/<basename>` inboxes. Attachment inboxes are durable recipient state (not cache); peer removal cleans that recipient's inbox.
|
|
57
58
|
- **`logs/<name>/`** — an infrastructure peer's logs are placed by peer name at the shared level (not in the peer's folder), because it's the output of a launchd service.
|
|
58
59
|
- **`runtimes/<runtime>/`** — dual ownership: the root belongs to the runtime itself (Telegram keeps bot tokens and poll state here), while the `plugins/<plugin>/` subfolder is the plugins' space, holding their config under this runtime.
|
|
59
60
|
- **`docs/<package>/`** — on-host reference docs, one folder per ecosystem package (see below).
|
package/docs/10-messaging.md
CHANGED
|
@@ -23,6 +23,18 @@ iapeer send assistant --message "..." --topic "release" --from claude-reviewer
|
|
|
23
23
|
|
|
24
24
|
The `--from` flag sets the sender (`<runtime>-<name>`); by default it's the identity of the current folder's peer. `--attachment` is repeatable. `--message-file`, with the body from a file or stdin, is handy for long, multi-line messages.
|
|
25
25
|
|
|
26
|
+
## Attachment ownership
|
|
27
|
+
|
|
28
|
+
An attachment argument is a **source path**, not the delivered path. `routeSend` opens every source as a regular file and copies its bytes into the target peer's foundation-owned inbox before it builds the envelope:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
~/.iapeer/state/iapeer/attachments/<recipient>/<sha256>/<basename>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The envelope contains these absolute inbox paths. Therefore an `ok`/`delivered`/`queued` result also means that every attachment already has a readable recipient copy; the sender may immediately delete or rename its source. A missing, unreadable, or non-regular source fails the send before delivery. Re-sending an inbox file to a third peer makes another copy in that third peer's inbox.
|
|
35
|
+
|
|
36
|
+
The copy is recipient-owned state, not cache: it is not age-GCed and lives for the recipient peer's lifetime. `iapeer remove <recipient>` removes the complete inbox. Removing the sender, cleaning `/tmp`, restarting the daemon, draining an ephemeral queue, and bridge history rotation do not remove it.
|
|
37
|
+
|
|
26
38
|
## The delivery path
|
|
27
39
|
|
|
28
40
|
The daemon finds the recipient in the registry and selects its endpoint first. With no explicit `runtime`, the endpoint is always the recipient's `default_runtime`; liveness of other declared runtimes does not change that choice. An explicit `runtime` selects that exact surface. The daemon then looks at the selected endpoint's state:
|
package/docs/12-cli-reference.md
CHANGED
|
@@ -61,7 +61,7 @@ The full list of `iapeer <command>`. General rules: `--help` / `-h` anywhere in
|
|
|
61
61
|
| `send <target> (--message <text> \| --message-file <f\|->) [--from <id>] [--runtime <r>] [--attachment <path>]… [--topic <t>]` | Manual IAP send over the same path as `send_to_peer`. |
|
|
62
62
|
| `confirm-send <id> [--runtime <rt>]` | Deliver a held send ([origin guard](18-origin-guard.md)): as addressed, or `--runtime` redirects it to the origin channel. No re-send of the message text. |
|
|
63
63
|
|
|
64
|
-
`--message-file -` reads the body from stdin. `--attachment` is repeatable. `--from` defaults to the current folder's peer identity.
|
|
64
|
+
`--message-file -` reads the body from stdin. `--attachment` is repeatable and names an absolute source file; the router copies it into recipient-owned state before accepting the send. `--from` defaults to the current folder's peer identity.
|
|
65
65
|
|
|
66
66
|
## Launch and runtimes
|
|
67
67
|
|
|
@@ -13,6 +13,7 @@ The system's behavior rests on these:
|
|
|
13
13
|
- **A single registry writer.** `peers-profiles.json` is written only through one writer under a file lock. Direct writing of the file is forbidden at the core level — this rules out races.
|
|
14
14
|
- **Liveness is defined by a peer's live session.** A peer is alive if and only if it has a live session under the supervisor. This isn't a separate state flag that can desync, but an observable fact.
|
|
15
15
|
- **`delivered` ⟺ landed in a live session.** Delivery success means the message was accepted by a live session (the daemon waited for confirmation). If it didn't confirm — the daemon treats the session as dead rather than lying about success.
|
|
16
|
+
- **Attachments are copied before routing.** Caller paths are only sources. Before an envelope can be accepted as delivered or queued, `routeSend` copies every attachment into the target peer's foundation-owned inbox and puts those copy paths into the envelope. A later delete, rename, or cleanup of the caller's source cannot break the received attachment. The copies live for the lifetime of the recipient peer and `iapeer remove <peer>` removes that inbox.
|
|
16
17
|
- **The source of truth is the local profile.** The registry is a reprojectable projection of the peer profiles. On a divergence, the profile wins.
|
|
17
18
|
- **The binary is installed on the host.** The daemon runs from the installed `~/.local/bin/iapeer`, not from a source working tree. An update is replacing the binary and restarting the daemon onto it.
|
|
18
19
|
|
package/docs/15-fleet-api.md
CHANGED
|
@@ -203,10 +203,10 @@ What the headline states look like on the stream: a peer **waking** is `ev=wake`
|
|
|
203
203
|
`POST /fleet/v1/send` — deliver a message through the same routing path as the `send_to_peer` tool (wake-on-miss, queueing, delivery log — identical semantics):
|
|
204
204
|
|
|
205
205
|
```json
|
|
206
|
-
{ "personality": "boris", "message": "…", "runtime": "claude", "topic": "…", "from": "nova" }
|
|
206
|
+
{ "personality": "boris", "message": "…", "runtime": "claude", "topic": "…", "attachments": ["/tmp/report.pdf"], "from": "nova" }
|
|
207
207
|
```
|
|
208
208
|
|
|
209
|
-
`from` is optional: a full `<runtime>-<personality>`, or a bare personality (its default runtime). Default: the **single natural-intelligence peer** of the registry — a GUI-originated message is the human's. With zero or several natural peers the request is refused (`400`) with an instruction to pass `from`.
|
|
209
|
+
`from` is optional: a full `<runtime>-<personality>`, or a bare personality (its default runtime). Default: the **single natural-intelligence peer** of the registry — a GUI-originated message is the human's. With zero or several natural peers the request is refused (`400`) with an instruction to pass `from`. `attachments`, when present, is the same array of absolute source paths as the MCP tool; the shared `routeSend` path copies them into the recipient inbox before acceptance.
|
|
210
210
|
|
|
211
211
|
Not part of the *command* API, deliberately: **attach** (a terminal handoff — a client opens the system terminal with `iapeer attach <peer>`), registry/host **admin verbs** (`create`/`remove`/`update`/`uninstall` — will be added when a client actually needs them). The **human-approval broker** now lives under `/fleet/v1/approvals*` (`GET` the queue / one item, `POST /<id>/(approve|deny)`) with the `approval-request` / `approval-resolved` SSE `ev` kinds and the `approvals` snapshot field above — the full contract is **docs/17-approval**. The **notice board** lives under `/fleet/v1/notices*` (`GET` the board / one item — **read-only**: a notice is one-way and has nothing to resolve) with the `notice-raised` SSE `ev` kind and the `notices` snapshot field above — the full contract is **docs/19-notices**.
|
|
212
212
|
|
package/docs/18-origin-guard.md
CHANGED
|
@@ -30,7 +30,7 @@ verbatim as og-1a2b3c4d (expires in 15 min).
|
|
|
30
30
|
Do not re-send the message text — confirm-send delivers the held message.
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Both exits cost one shell command and **zero message regeneration**: the held send carries the
|
|
33
|
+
Both exits cost one shell command and **zero message regeneration**: the held send carries the original message/topic/runtime and recipient-owned copies of every attachment. Copying happens before the hold is persisted, so deleting a caller's temporary sources during the 15-minute decision window cannot break `confirm-send`.
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
36
|
iapeer confirm-send og-1a2b3c4d # deliver as addressed (intentional cross-channel)
|
|
@@ -48,7 +48,7 @@ State is on disk (it must survive daemon restarts and short-lived CLI processes)
|
|
|
48
48
|
```text
|
|
49
49
|
~/.iapeer/state/iapeer/origin/
|
|
50
50
|
state.json # (agent → human → {rt, inboundTs, answeredTs}) — last-wins, atomic writes
|
|
51
|
-
pending/<id>.json # held sends
|
|
51
|
+
pending/<id>.json # held sends; attachment fields already name recipient-inbox copies
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
## Scope and non-interference
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
│ └── .iapeer/ (раскладка уровня пира — см. ниже)
|
|
27
27
|
├── state/
|
|
28
28
|
│ ├── iapeer/ состояние демона (router.sock, router.json …)
|
|
29
|
+
│ │ └── attachments/ копии IAP-вложений, принадлежащие получателям
|
|
29
30
|
│ └── <плагин>/ изменяемое состояние плагина
|
|
30
31
|
├── logs/
|
|
31
32
|
│ ├── iapeer/ журналы демона: lifecycle.log, delivery.log, exits.log
|
|
@@ -53,7 +54,7 @@
|
|
|
53
54
|
Несколько узлов стоит выделить:
|
|
54
55
|
|
|
55
56
|
- **`peers-profiles.json`** — реестр. Пишется только через единственного писателя под файловой блокировкой `peers-profiles.lock`; прямое редактирование запрещено на уровне ядра.
|
|
56
|
-
- **`state/iapeer/`** — здесь живёт демон: его локальный сокет `router.sock` и
|
|
57
|
+
- **`state/iapeer/`** — здесь живёт демон: его локальный сокет `router.sock`, конфигурация `router.json` и inbox-каталоги `attachments/<получатель>/<sha256>/<имя-файла>`. Inbox — долгоживущее состояние получателя, а не кэш; удаление пира очищает его inbox.
|
|
57
58
|
- **`logs/<имя>/`** — журналы инфраструктурных пиров кладутся по имени пира на общем уровне (а не в папке пира), потому что это вывод службы под launchd.
|
|
58
59
|
- **`runtimes/<рантайм>/`** — двойное владение: корень принадлежит самому рантайму (Telegram держит здесь токены ботов, состояние опроса), а подпапка `plugins/<плагин>/` — это пространство плагинов, где лежит их конфиг под этим рантаймом.
|
|
59
60
|
- **`docs/<пакет>/`** — справочные доки на хосте, по одной папке на каждый пакет экосистемы (см. ниже).
|
|
@@ -23,6 +23,18 @@ iapeer send assistant --message "..." --topic "релиз" --from claude-reviewe
|
|
|
23
23
|
|
|
24
24
|
Флаг `--from` задаёт отправителя (`<рантайм>-<имя>`); по умолчанию — идентичность пира текущей папки. `--attachment` повторяемый. `--message-file` с телом из файла или stdin удобен для длинных и многострочных сообщений.
|
|
25
25
|
|
|
26
|
+
## Владение вложениями
|
|
27
|
+
|
|
28
|
+
Аргумент вложения — **путь источника**, а не доставленный путь. До сборки конверта `routeSend` открывает каждый источник как обычный файл и копирует его байты в foundation-owned inbox целевого пира:
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
~/.iapeer/state/iapeer/attachments/<получатель>/<sha256>/<имя-файла>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
В конверт попадают абсолютные пути этих копий. Поэтому исход `ok`/`delivered`/`queued` одновременно значит, что каждое вложение уже имеет читаемую копию получателя; отправитель может сразу удалить или переименовать исходник. Отсутствующий, нечитаемый или необычный файл валит отправку до доставки. Пересылка inbox-файла третьему пиру создаёт отдельную копию в inbox третьего пира.
|
|
35
|
+
|
|
36
|
+
Копия — состояние получателя, а не кэш: по возрасту не удаляется и живёт столько же, сколько пир-получатель. `iapeer remove <получатель>` удаляет весь inbox. Удаление отправителя, очистка `/tmp`, рестарт демона, дренаж ephemeral-очереди и ротация истории моста её не удаляют.
|
|
37
|
+
|
|
26
38
|
## Путь доставки
|
|
27
39
|
|
|
28
40
|
Демон находит получателя в реестре и сначала выбирает поверхность доставки. Без явного `runtime` это всегда `default_runtime` адресата; живость других объявленных рантаймов выбор не меняет. Явный `runtime` выбирает ровно указанную поверхность. Затем демон смотрит на состояние выбранной поверхности:
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
| `send <цель> (--message <текст> \| --message-file <f\|->) [--from <id>] [--runtime <r>] [--attachment <путь>]… [--topic <t>]` | Ручная отправка IAP по тому же пути, что `send_to_peer`. |
|
|
62
62
|
| `confirm-send <id> [--runtime <rt>]` | Доставить удержанную отправку ([origin guard](18-origin-guard.md)): как адресована, либо `--runtime` перенаправляет её в канал-источник. Без повторной отправки текста. |
|
|
63
63
|
|
|
64
|
-
`--message-file -` читает тело из stdin. `--attachment`
|
|
64
|
+
`--message-file -` читает тело из stdin. `--attachment` повторяемый и задаёт абсолютный путь файла-источника; до принятия отправки роутер копирует его в состояние получателя. `--from` по умолчанию — идентичность пира текущей папки.
|
|
65
65
|
|
|
66
66
|
## Запуск и рантаймы
|
|
67
67
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
- **Единственный писатель реестра.** `peers-profiles.json` пишется только через одного писателя под файловой блокировкой. Прямая запись файла запрещена на уровне ядра — это исключает гонки.
|
|
14
14
|
- **Живость определяется live-сессией пира.** Пир жив тогда и только тогда, когда у него есть живая сессия под супервизором. Это не отдельный флаг в состоянии, который можно рассинхронизировать, а наблюдаемый факт.
|
|
15
15
|
- **`доставлено` ⟺ легло в живую сессию.** Успех доставки означает, что сообщение принято живой сессией (демон дождался подтверждения). Не подтвердила — демон считает сессию мёртвой, а не лжёт об успехе.
|
|
16
|
+
- **Вложения копируются до маршрутизации.** Пути вызывающего — только источники. Прежде чем конверт может быть принят как delivered или queued, `routeSend` копирует каждое вложение в принадлежащий foundation inbox целевого пира и вкладывает в конверт уже пути копий. Последующие удаление, переименование или очистка исходника не ломают полученное вложение. Копии живут столько же, сколько пир-получатель; `iapeer remove <пир>` удаляет его inbox.
|
|
16
17
|
- **Источник истины — локальный профиль.** Реестр — пересобираемая проекция профилей пиров. При расхождении прав профиль.
|
|
17
18
|
- **Бинарь установлен на хост.** Демон работает с установленного `~/.local/bin/iapeer`, а не из рабочего дерева исходников. Обновление — это замена бинаря и перезапуск демона на него.
|
|
18
19
|
|
package/docs/ru/15-fleet-api.md
CHANGED
|
@@ -133,10 +133,10 @@ GET /fleet/v1/events?replay=50
|
|
|
133
133
|
`POST /fleet/v1/send` — доставить сообщение тем же маршрутом, что инструмент `send_to_peer` (wake-on-miss, очереди, delivery-лог — семантика идентична):
|
|
134
134
|
|
|
135
135
|
```json
|
|
136
|
-
{ "personality": "boris", "message": "…", "runtime": "claude", "topic": "…", "from": "nova" }
|
|
136
|
+
{ "personality": "boris", "message": "…", "runtime": "claude", "topic": "…", "attachments": ["/tmp/report.pdf"], "from": "nova" }
|
|
137
137
|
```
|
|
138
138
|
|
|
139
|
-
`from` опционален: полный `<runtime>-<personality>` или голая personality (её default-рантайм). По умолчанию — **единственный natural-intelligence пир** реестра: сообщение из GUI принадлежит человеку. При нуле или нескольких natural-пирах запрос отклоняется (`400`) с указанием передать `from`.
|
|
139
|
+
`from` опционален: полный `<runtime>-<personality>` или голая personality (её default-рантайм). По умолчанию — **единственный natural-intelligence пир** реестра: сообщение из GUI принадлежит человеку. При нуле или нескольких natural-пирах запрос отклоняется (`400`) с указанием передать `from`. `attachments`, если передан, — тот же массив абсолютных путей источников, что и у MCP-инструмента; общий путь `routeSend` копирует их в inbox получателя до принятия.
|
|
140
140
|
|
|
141
141
|
Не входит в *командный* API, намеренно: **attach** (передача терминала — клиент открывает системный терминал с `iapeer attach <peer>`), реестровые/хостовые **admin-verb'ы** (`create`/`remove`/`update`/`uninstall` — добавятся, когда появится клиент с реальной потребностью). **Брокер human-approval** теперь живёт под `/fleet/v1/approvals*` (`GET` очередь / один item, `POST /<id>/(approve|deny)`) с видами SSE `approval-request` / `approval-resolved` и полем `approvals` в снапшоте выше — полный контракт в **docs/17-approval**.
|
|
142
142
|
|
|
@@ -30,7 +30,7 @@ verbatim as og-1a2b3c4d (expires in 15 min).
|
|
|
30
30
|
Do not re-send the message text — confirm-send delivers the held message.
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Оба выхода стоят одну shell-команду и **ноль регенерации сообщения**: удержанная отправка несёт
|
|
33
|
+
Оба выхода стоят одну shell-команду и **ноль регенерации сообщения**: удержанная отправка несёт исходные текст/топик/рантайм и принадлежащие получателю копии каждого вложения. Копирование идёт до сохранения hold, поэтому удаление временных исходников в 15-минутном окне решения не ломает `confirm-send`.
|
|
34
34
|
|
|
35
35
|
```bash
|
|
36
36
|
iapeer confirm-send og-1a2b3c4d # доставить как адресовано (кросс-канал намеренный)
|
|
@@ -48,7 +48,7 @@ iapeer confirm-send og-1a2b3c4d --runtime web # доставить то же
|
|
|
48
48
|
```text
|
|
49
49
|
~/.iapeer/state/iapeer/origin/
|
|
50
50
|
state.json # (агент → человек → {rt, inboundTs, answeredTs}) — last-wins, атомарные записи
|
|
51
|
-
pending/<id>.json # удержанные
|
|
51
|
+
pending/<id>.json # удержанные отправки; вложения уже указывают на копии inbox
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
## Границы и невмешательство
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -26,7 +26,15 @@ import { cascadeTail, recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaem
|
|
|
26
26
|
import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
|
|
27
27
|
import { ensureGlobalIapScaffold } from '../storage/index.ts'
|
|
28
28
|
import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../registry/index.ts'
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
compactDoneBaseline,
|
|
31
|
+
isPeerLive,
|
|
32
|
+
purgeAttachmentInbox,
|
|
33
|
+
routeControl,
|
|
34
|
+
routeSend,
|
|
35
|
+
waitForCompactDone,
|
|
36
|
+
type WakeFn,
|
|
37
|
+
} from '../transport/index.ts'
|
|
30
38
|
import {
|
|
31
39
|
attachPeer,
|
|
32
40
|
clearIdleReaped,
|
|
@@ -888,6 +896,9 @@ export interface RemoveOutcome {
|
|
|
888
896
|
* personality inherits the dead namesake's parking (defect: stale .stopped →
|
|
889
897
|
* `mode=refused cause=stopped` on a freshly-created peer). */
|
|
890
898
|
purgedState?: string[]
|
|
899
|
+
/** Recipient-owned IAP attachment inbox cleanup. Completed attachment copies
|
|
900
|
+
* are state for the peer lifetime, so explicit peer removal owns their purge. */
|
|
901
|
+
attachmentInbox?: string
|
|
891
902
|
}
|
|
892
903
|
|
|
893
904
|
/**
|
|
@@ -1039,11 +1050,19 @@ export async function removePeerCli(
|
|
|
1039
1050
|
// remove never half-purges a still-registered peer.
|
|
1040
1051
|
const cfg = loadLifecycleConfig(env)
|
|
1041
1052
|
const purgedState = peer.runtimes.flatMap(rt => purgeIdentityState(cfg, buildProcessAddress(rt, personality)))
|
|
1053
|
+
let attachmentInbox: string | undefined
|
|
1054
|
+
try {
|
|
1055
|
+
const purged = purgeAttachmentInbox(personality, env)
|
|
1056
|
+
if (purged.removed) attachmentInbox = `removed ${purged.path}`
|
|
1057
|
+
} catch (e) {
|
|
1058
|
+
attachmentInbox = `FAILED: ${e instanceof Error ? e.message : String(e)}`
|
|
1059
|
+
}
|
|
1042
1060
|
return {
|
|
1043
1061
|
personality,
|
|
1044
1062
|
action: 'removed',
|
|
1045
1063
|
cwd: peer.cwd,
|
|
1046
1064
|
purgedState,
|
|
1065
|
+
...(attachmentInbox ? { attachmentInbox } : {}),
|
|
1047
1066
|
...(plistTeardown ? { plistTeardown } : {}),
|
|
1048
1067
|
...(unprovisionOutcomes.length ? { unprovision: unprovisionOutcomes } : {}),
|
|
1049
1068
|
...(trustCleaned ? { codexTrust: trustCleaned } : {}),
|
|
@@ -2280,6 +2299,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2280
2299
|
if (o.purgedState?.length) {
|
|
2281
2300
|
out(`lifecycle state purged: ${o.purgedState.join(', ')}\n`)
|
|
2282
2301
|
}
|
|
2302
|
+
if (o.attachmentInbox) {
|
|
2303
|
+
out(`attachment inbox: ${o.attachmentInbox}\n`)
|
|
2304
|
+
}
|
|
2283
2305
|
// Deliberate: the registry reap never deletes user data — but SAY so, or
|
|
2284
2306
|
// the default-location peers leave silent orphan folders.
|
|
2285
2307
|
if (o.cwd && existsSync(o.cwd)) {
|
package/src/daemon/fleet.ts
CHANGED
|
@@ -658,7 +658,13 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
|
|
|
658
658
|
return sendJson(res, 400, { error: e instanceof Error ? e.message : String(e) })
|
|
659
659
|
}
|
|
660
660
|
if (typeof body.personality !== 'string' || !body.personality || typeof body.message !== 'string' || !body.message) {
|
|
661
|
-
return sendJson(res, 400, { error: 'send needs {personality, message} (+ optional runtime/topic/from)' })
|
|
661
|
+
return sendJson(res, 400, { error: 'send needs {personality, message} (+ optional runtime/topic/attachments/from)' })
|
|
662
|
+
}
|
|
663
|
+
if (
|
|
664
|
+
body.attachments !== undefined &&
|
|
665
|
+
(!Array.isArray(body.attachments) || !body.attachments.every(item => typeof item === 'string'))
|
|
666
|
+
) {
|
|
667
|
+
return sendJson(res, 400, { error: 'send attachments must be an array of absolute local source-file paths' })
|
|
662
668
|
}
|
|
663
669
|
let caller: ResolvedCaller
|
|
664
670
|
try {
|
|
@@ -676,6 +682,7 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
|
|
|
676
682
|
runtime: typeof body.runtime === 'string' ? body.runtime : undefined,
|
|
677
683
|
message: body.message,
|
|
678
684
|
topic: typeof body.topic === 'string' ? body.topic : undefined,
|
|
685
|
+
attachments: Array.isArray(body.attachments) ? body.attachments : undefined,
|
|
679
686
|
},
|
|
680
687
|
ctx.deps,
|
|
681
688
|
)
|
package/src/daemon/index.ts
CHANGED
|
@@ -163,7 +163,7 @@ export function listTools(): unknown[] {
|
|
|
163
163
|
topic: { type: 'string', description: 'Optional short topic for threading related peer messages.', maxLength: MAX_TOPIC_LEN },
|
|
164
164
|
attachments: {
|
|
165
165
|
type: 'array',
|
|
166
|
-
description: 'Optional absolute local file paths. IAP
|
|
166
|
+
description: 'Optional absolute local source-file paths. Before accepting the send, IAP copies each regular file into durable recipient-owned state and puts the copy paths in the separate <attachments> field; the sources may be deleted immediately after success.',
|
|
167
167
|
maxItems: MAX_ATTACHMENTS,
|
|
168
168
|
items: { type: 'string' },
|
|
169
169
|
},
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// IAP attachment ownership — copy-on-send into recipient-owned foundation state.
|
|
2
|
+
//
|
|
3
|
+
// A caller-supplied attachment path is a SOURCE, never the delivered object. Before
|
|
4
|
+
// routeSend builds an envelope, each regular file is copied byte-for-byte into:
|
|
5
|
+
//
|
|
6
|
+
// <IAPEER_ROOT>/state/iapeer/attachments/<recipient>/<sha256>/<basename>
|
|
7
|
+
//
|
|
8
|
+
// and only that durable path enters the envelope. The content hash makes repeat
|
|
9
|
+
// sends idempotent without coupling the copy to an ephemeral message/queue entry;
|
|
10
|
+
// a recipient can forward its copy and the next route creates a separate copy in
|
|
11
|
+
// the next recipient's inbox. Completed copies are STATE (not cache): no age GC.
|
|
12
|
+
// The recipient's explicit peer removal purges its complete inbox.
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
chmodSync,
|
|
16
|
+
closeSync,
|
|
17
|
+
existsSync,
|
|
18
|
+
fstatSync,
|
|
19
|
+
fsyncSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
openSync,
|
|
22
|
+
readSync,
|
|
23
|
+
renameSync,
|
|
24
|
+
rmSync,
|
|
25
|
+
writeSync,
|
|
26
|
+
} from 'node:fs'
|
|
27
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
28
|
+
import { basename, join } from 'node:path'
|
|
29
|
+
import { STATE_DIR, isValidName } from '../core/constants.ts'
|
|
30
|
+
import { IapError, err, ok, type Result } from '../core/errors.ts'
|
|
31
|
+
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
32
|
+
|
|
33
|
+
const DIR_MODE = 0o700
|
|
34
|
+
const FILE_MODE = 0o600
|
|
35
|
+
const COPY_BUFFER_BYTES = 1024 * 1024
|
|
36
|
+
|
|
37
|
+
export const ATTACHMENT_SPOOL_DIR = 'attachments'
|
|
38
|
+
|
|
39
|
+
export interface SpooledAttachments {
|
|
40
|
+
paths: string[]
|
|
41
|
+
sha256: string[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AttachmentPurgeResult {
|
|
45
|
+
path: string
|
|
46
|
+
removed: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function attachmentSpoolRoot(env: NodeJS.ProcessEnv = process.env): string {
|
|
50
|
+
return join(resolveGlobalRoot(env), STATE_DIR, 'iapeer', ATTACHMENT_SPOOL_DIR)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function attachmentInboxDir(
|
|
54
|
+
recipient: string,
|
|
55
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
56
|
+
): string {
|
|
57
|
+
if (!isValidName(recipient)) {
|
|
58
|
+
throw new IapError(`invalid attachment recipient "${recipient}"`)
|
|
59
|
+
}
|
|
60
|
+
return join(attachmentSpoolRoot(env), recipient)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Copy one source through an fsynced staging file while calculating its SHA-256.
|
|
65
|
+
* The staging file lives in the recipient inbox (same filesystem as the final
|
|
66
|
+
* path), so the final rename is atomic. A partial copy is never exposed in an IAP
|
|
67
|
+
* envelope and is cleaned on every failure visible to this process.
|
|
68
|
+
*/
|
|
69
|
+
function copyOne(
|
|
70
|
+
source: string,
|
|
71
|
+
recipient: string,
|
|
72
|
+
env: NodeJS.ProcessEnv,
|
|
73
|
+
): { path: string; sha256: string } {
|
|
74
|
+
const inbox = attachmentInboxDir(recipient, env)
|
|
75
|
+
mkdirSync(inbox, { recursive: true, mode: DIR_MODE })
|
|
76
|
+
const tmp = join(inbox, `.incoming-${process.pid}-${randomUUID()}`)
|
|
77
|
+
let sourceFd: number | undefined
|
|
78
|
+
let targetFd: number | undefined
|
|
79
|
+
try {
|
|
80
|
+
sourceFd = openSync(source, 'r')
|
|
81
|
+
const sourceStat = fstatSync(sourceFd)
|
|
82
|
+
if (!sourceStat.isFile()) throw new Error('source is not a regular file')
|
|
83
|
+
|
|
84
|
+
targetFd = openSync(tmp, 'wx', FILE_MODE)
|
|
85
|
+
const hash = createHash('sha256')
|
|
86
|
+
const buffer = Buffer.allocUnsafe(COPY_BUFFER_BYTES)
|
|
87
|
+
while (true) {
|
|
88
|
+
const read = readSync(sourceFd, buffer, 0, buffer.length, null)
|
|
89
|
+
if (read === 0) break
|
|
90
|
+
hash.update(buffer.subarray(0, read))
|
|
91
|
+
let written = 0
|
|
92
|
+
while (written < read) {
|
|
93
|
+
const n = writeSync(targetFd, buffer, written, read - written)
|
|
94
|
+
if (n <= 0) {
|
|
95
|
+
throw new Error(`write made no progress (${written}/${read} bytes in current chunk)`)
|
|
96
|
+
}
|
|
97
|
+
written += n
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
fsyncSync(targetFd)
|
|
101
|
+
closeSync(targetFd)
|
|
102
|
+
targetFd = undefined
|
|
103
|
+
closeSync(sourceFd)
|
|
104
|
+
sourceFd = undefined
|
|
105
|
+
|
|
106
|
+
const sha256 = hash.digest('hex')
|
|
107
|
+
const hashDir = join(inbox, sha256)
|
|
108
|
+
mkdirSync(hashDir, { recursive: true, mode: DIR_MODE })
|
|
109
|
+
const path = join(hashDir, basename(source))
|
|
110
|
+
// Atomic publish. Replacing an existing same-hash+basename object is safe and
|
|
111
|
+
// repairs a manually damaged copy; concurrent publishers carry identical bytes.
|
|
112
|
+
renameSync(tmp, path)
|
|
113
|
+
chmodSync(path, FILE_MODE)
|
|
114
|
+
return { path, sha256 }
|
|
115
|
+
} finally {
|
|
116
|
+
if (targetFd !== undefined) {
|
|
117
|
+
try { closeSync(targetFd) } catch { /* already closed */ }
|
|
118
|
+
}
|
|
119
|
+
if (sourceFd !== undefined) {
|
|
120
|
+
try { closeSync(sourceFd) } catch { /* already closed */ }
|
|
121
|
+
}
|
|
122
|
+
try { rmSync(tmp, { force: true }) } catch { /* renamed or best-effort cleanup */ }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Materialize every attachment in the target peer's inbox. All validation and
|
|
128
|
+
* copying completes before the caller receives paths for envelope construction;
|
|
129
|
+
* any failure aborts the send loudly. Copies already completed before a later
|
|
130
|
+
* item fails stay as harmless recipient state and are content-address deduplicated
|
|
131
|
+
* on retry (removing them here could delete a copy referenced by an earlier send).
|
|
132
|
+
*/
|
|
133
|
+
export function spoolAttachments(
|
|
134
|
+
sources: readonly string[],
|
|
135
|
+
recipient: string,
|
|
136
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
137
|
+
): Result<SpooledAttachments> {
|
|
138
|
+
const paths: string[] = []
|
|
139
|
+
const sha256: string[] = []
|
|
140
|
+
for (let i = 0; i < sources.length; i++) {
|
|
141
|
+
const source = sources[i]!
|
|
142
|
+
try {
|
|
143
|
+
const copied = copyOne(source, recipient, env)
|
|
144
|
+
paths.push(copied.path)
|
|
145
|
+
sha256.push(copied.sha256)
|
|
146
|
+
} catch (e) {
|
|
147
|
+
const reason = e instanceof Error ? e.message : String(e)
|
|
148
|
+
return err(`attachment ${i + 1} "${source}" could not be copied for "${recipient}": ${reason}; message NOT delivered`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return ok({ paths, sha256 })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Recipient-lifetime cleanup: called by `iapeer remove <peer>`. */
|
|
155
|
+
export function purgeAttachmentInbox(
|
|
156
|
+
recipient: string,
|
|
157
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
158
|
+
): AttachmentPurgeResult {
|
|
159
|
+
const path = attachmentInboxDir(recipient, env)
|
|
160
|
+
const removed = existsSync(path)
|
|
161
|
+
rmSync(path, { recursive: true, force: true })
|
|
162
|
+
return { path, removed }
|
|
163
|
+
}
|
package/src/transport/index.ts
CHANGED
|
@@ -71,6 +71,17 @@ import {
|
|
|
71
71
|
} from './originGuard.ts'
|
|
72
72
|
import { keysToBytes } from '../supervisor/protocol.ts' // pure send-keys-vocab→pty-byte translation (boot-driver slice a), @xterm-free
|
|
73
73
|
import { paneLogComposerOccupied } from '../launch/readyGateModel.ts' // spawn-flip Ф0b-3 slice 3c: hosted busy-composer detector (slice 2). @xterm dynamic-loaded inside it → warm path stays @xterm-free with the flip off
|
|
74
|
+
import { spoolAttachments } from './attachments.ts'
|
|
75
|
+
|
|
76
|
+
export {
|
|
77
|
+
ATTACHMENT_SPOOL_DIR,
|
|
78
|
+
attachmentInboxDir,
|
|
79
|
+
attachmentSpoolRoot,
|
|
80
|
+
purgeAttachmentInbox,
|
|
81
|
+
spoolAttachments,
|
|
82
|
+
type AttachmentPurgeResult,
|
|
83
|
+
type SpooledAttachments,
|
|
84
|
+
} from './attachments.ts'
|
|
74
85
|
|
|
75
86
|
export interface OnlinePeer {
|
|
76
87
|
personality: string
|
|
@@ -1502,8 +1513,11 @@ async function routeSendInner(
|
|
|
1502
1513
|
// Origin-guard (docs/18) — an agent's PRESUMED REPLY to a human (the human's latest
|
|
1503
1514
|
// inbound to this agent is unanswered) must target the channel the human wrote from.
|
|
1504
1515
|
// Checked FIRST, before any resolve/wake side-effect and before the telegram policy:
|
|
1505
|
-
// a mismatch is HELD
|
|
1506
|
-
//
|
|
1516
|
+
// a mismatch is HELD, not delivered — the agent then confirms cross-channel or
|
|
1517
|
+
// redirects to the origin, zero regeneration. Attachment PATHS are the deliberate
|
|
1518
|
+
// exception to "verbatim": sources are copied into the intended recipient's durable
|
|
1519
|
+
// inbox before the hold is persisted, so deleting a /tmp source cannot break a later
|
|
1520
|
+
// confirm-send. Message/topic/runtime remain verbatim.
|
|
1507
1521
|
// Intended runtime = the explicit override or the human's default; exact for the
|
|
1508
1522
|
// natural-peer class (implicit routing is strictly default_runtime-anchored).
|
|
1509
1523
|
// Initiative (answered / no inbound / ARM_TTL-stale) passes untouched.
|
|
@@ -1516,9 +1530,11 @@ async function routeSendInner(
|
|
|
1516
1530
|
const armed = armedOrigin(caller.personality, peer.personality, { env })
|
|
1517
1531
|
const intendedRt = runtime ?? peer.runtime
|
|
1518
1532
|
if (armed && armed.rt !== intendedRt) {
|
|
1533
|
+
const spooled = spoolAttachments(attachmentsResult.value, peer.personality, env)
|
|
1534
|
+
if (!spooled.ok) return spooled
|
|
1519
1535
|
const held = holdSend(
|
|
1520
1536
|
caller.address,
|
|
1521
|
-
{ personality, runtime, message, topic, attachments:
|
|
1537
|
+
{ personality, runtime, message, topic, attachments: spooled.value.paths },
|
|
1522
1538
|
armed,
|
|
1523
1539
|
intendedRt,
|
|
1524
1540
|
{ env },
|
|
@@ -1533,6 +1549,14 @@ async function routeSendInner(
|
|
|
1533
1549
|
const intendedGuard = telegramSenderGuard(caller, runtime ?? peer.runtime, personality)
|
|
1534
1550
|
if (!intendedGuard.ok) return intendedGuard
|
|
1535
1551
|
|
|
1552
|
+
// Attachment ownership boundary: caller paths are SOURCES only. Materialize
|
|
1553
|
+
// recipient-owned, content-addressed copies BEFORE building any live/wake/queue
|
|
1554
|
+
// envelope. Every path below (including ephemeral/composer queues and bridge
|
|
1555
|
+
// runtimes) therefore has the same durability semantics. Copy failure is a
|
|
1556
|
+
// synchronous NOT-delivered error; no route side effect has happened yet.
|
|
1557
|
+
const spooled = spoolAttachments(attachmentsResult.value, peer.personality, env)
|
|
1558
|
+
if (!spooled.ok) return spooled
|
|
1559
|
+
|
|
1536
1560
|
// Stamped ONCE at router acceptance: the same instant travels as the envelope's
|
|
1537
1561
|
// `ts` attribute (recipient side) AND is returned as the result's `ts` (sender
|
|
1538
1562
|
// side) — the two correspond literally, which is what makes async-desync
|
|
@@ -1547,7 +1571,7 @@ async function routeSendInner(
|
|
|
1547
1571
|
fromIntelligence: caller.intelligence,
|
|
1548
1572
|
sentAt,
|
|
1549
1573
|
topic,
|
|
1550
|
-
attachments:
|
|
1574
|
+
attachments: spooled.value.paths,
|
|
1551
1575
|
message,
|
|
1552
1576
|
})
|
|
1553
1577
|
|