@agfpd/iapeer 0.4.89 → 0.4.91
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/docs/10-messaging.md +4 -0
- package/docs/12-cli-reference.md +1 -0
- package/docs/17-approval.md +7 -5
- package/docs/18-origin-guard.md +66 -0
- package/docs/README.md +1 -0
- 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 +4 -0
- package/docs/ru/12-cli-reference.md +1 -0
- package/docs/ru/18-origin-guard.md +66 -0
- package/docs/ru/README.md +1 -0
- package/package.json +1 -1
- package/src/cli/index.ts +65 -1
- package/src/daemon/approvals.ts +4 -2
- package/src/daemon/fleet.ts +4 -1
- package/src/transport/index.ts +75 -0
- package/src/transport/originGuard.ts +300 -0
package/docs/10-messaging.md
CHANGED
|
@@ -85,3 +85,7 @@ A peer with the wake policy `wake_policy: "ephemeral"` in its profile works diff
|
|
|
85
85
|
- **`iapeer self-done`** — a self-call by a worker that has nothing to send: it frees the queue for the next task without waking anyone. The doctrine is "nothing to send — `self-done` instead of an empty reply".
|
|
86
86
|
|
|
87
87
|
This model keeps a stream of one-off tasks orderly: one worker per task, with no accumulation of parallel sessions and no empty wakes of the recipient.
|
|
88
|
+
|
|
89
|
+
## Replying to a human: the origin guard
|
|
90
|
+
|
|
91
|
+
An agent's reply to a human must land in the channel the human wrote from. The router enforces this mechanically: while the human's latest message to that agent is unanswered, a send targeting a *different* channel is held for a one-command confirmation (`iapeer confirm-send <id>`) instead of being delivered. Same-channel replies and initiative pass with no friction. The full contract — the armed-pair model, TTLs, `confirm-send` — is [18 — Origin guard](18-origin-guard.md).
|
package/docs/12-cli-reference.md
CHANGED
|
@@ -59,6 +59,7 @@ The full list of `iapeer <command>`. General rules: `--help` / `-h` anywhere in
|
|
|
59
59
|
| Command | What it does |
|
|
60
60
|
|---|---|
|
|
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
|
+
| `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. |
|
|
62
63
|
|
|
63
64
|
`--message-file -` reads the body from stdin. `--attachment` is repeatable. `--from` defaults to the current folder's peer identity.
|
|
64
65
|
|
package/docs/17-approval.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 17 — Human approval
|
|
2
2
|
|
|
3
|
-
A peer can run in one of two **approval modes**. In `yolo` (the default — every peer today) the agent acts autonomously: the runtime is launched with its bypass flag and nothing asks a human before a tool runs. In `gated` the peer is launched **without** bypass, and every blocking approval the runtime would raise is routed to a **human** through the daemon's approval broker — a Telegram button, a tray click,
|
|
3
|
+
A peer can run in one of two **approval modes**. In `yolo` (the default — every peer today) the agent acts autonomously: the runtime is launched with its bypass flag and nothing asks a human before a tool runs. In `gated` the peer is launched **without** bypass, and every blocking approval the runtime would raise is routed to a **human** through the daemon's approval broker — a Telegram button, a tray click, `iapeer approve` on the CLI, or the web console's approvals panel. The human's `allow`/`deny` (with a reason) flows back into the runtime and the tool proceeds or is refused.
|
|
4
4
|
|
|
5
5
|
This is a **normative contract**. The approval broker is the daemon's, the fleet endpoints and `ev` kinds below are the surface that operator faces (CLI, tray, the Telegram runtime) are written against — not the daemon source. The interception facts (which runtime event fires, in which shape) are pinned to **Claude Code 2.1.201** and **codex-cli 0.142.5**, taken from the live binaries + current runtime docs.
|
|
6
6
|
|
|
@@ -103,7 +103,7 @@ An unknown tool falls back to a pretty-printed `tool_input`, so nothing is opaqu
|
|
|
103
103
|
|
|
104
104
|
## The broker (single source of truth)
|
|
105
105
|
|
|
106
|
-
The approval queue lives **in the daemon** (the always-on process). Every channel that asks (the runtime hook, the supervisor breaker) and every channel that answers (CLI, tray, the Telegram runtime) is an interface to this one queue: a decision from any face resolves the request everywhere. In-memory + ephemeral — pending requests do **not** survive a daemon restart (the blocking hook connection breaks → the hook fails safe to deny); the durable `approvals.log` is the audit trace, not a recovery store.
|
|
106
|
+
The approval queue lives **in the daemon** (the always-on process). Every channel that asks (the runtime hook, the supervisor breaker) and every channel that answers (CLI, tray, the Telegram runtime, the web console) is an interface to this one queue: a decision from any face resolves the request everywhere. In-memory + ephemeral — pending requests do **not** survive a daemon restart (the blocking hook connection breaks → the hook fails safe to deny); the durable `approvals.log` is the audit trace, not a recovery store.
|
|
107
107
|
|
|
108
108
|
Flow: the gated peer's hook (or the supervisor breaker) blocks on `POST /fleet/v1/approvals` → the broker enqueues, emits `approval-request` (to `approvals.log` → SSE), and holds the connection → a face answers via `POST /fleet/v1/approvals/<id>/(approve|deny)` → the promise resolves, the daemon writes the runtime hook JSON back, the tool proceeds or is blocked with the reason.
|
|
109
109
|
|
|
@@ -118,7 +118,7 @@ The approval surface is served on both daemon listeners under `/fleet/v1`, same
|
|
|
118
118
|
**Events** (compact logfmt line → SSE JSON; the full content is *not* in the event — read the queue for it):
|
|
119
119
|
|
|
120
120
|
- `approval-request` — `id, personality, runtime, kind, tool, summary, created, expires, approvers`.
|
|
121
|
-
- `approval-resolved` — `id, personality, runtime, decision (allow|deny), reason, by (approver), via (cli|tray|telegram|timeout|disconnect), latencyMs`.
|
|
121
|
+
- `approval-resolved` — `id, personality, runtime, decision (allow|deny), reason, by (approver), via (cli|tray|telegram|web|timeout|disconnect), latencyMs`. `via` is the surface the decision came from, **self-declared by the answering face** in its `POST` body (free-form-tolerant, like `kind`; `timeout`/`disconnect` are broker-internal). A face that did not self-identify gets the field **omitted** — the audit line never guesses a surface.
|
|
122
122
|
|
|
123
123
|
**Endpoints:**
|
|
124
124
|
|
|
@@ -126,12 +126,14 @@ The approval surface is served on both daemon listeners under `/fleet/v1`, same
|
|
|
126
126
|
|---|---|
|
|
127
127
|
| `GET /fleet/v1/approvals` | list pending requests (full items) |
|
|
128
128
|
| `GET /fleet/v1/approvals/<id>` | one pending request, full `content` (the multi-line diff / plan / command) |
|
|
129
|
-
| `POST /fleet/v1/approvals/<id>/approve` | `{approver?}` → allow |
|
|
130
|
-
| `POST /fleet/v1/approvals/<id>/deny` | `{reason?, approver?}` → deny (reason to the model) |
|
|
129
|
+
| `POST /fleet/v1/approvals/<id>/approve` | `{approver?, via?}` → allow |
|
|
130
|
+
| `POST /fleet/v1/approvals/<id>/deny` | `{reason?, approver?, via?}` → deny (reason to the model) |
|
|
131
131
|
| `POST /fleet/v1/approvals` | the **blocking long-poll** the asking hook/breaker holds: `{personality, runtime, kind, tool, content, summary?}` → `{id, decision, reason?}` |
|
|
132
132
|
|
|
133
133
|
`kind` is the taxonomy tag (`tool` | `plan` | `question` | `circuit-breaker`; free-form-tolerant). So the SSE/badge stays light while the full content is available in every channel through the two `GET`s.
|
|
134
134
|
|
|
135
|
+
`via` on approve/deny is the answering face's **self-identification of its surface** for the audit trail: the CLI sends `cli`, the tray `tray`, the Telegram runtime `telegram`, the web console `web`. A face MUST send it (the log omits `via` otherwise); the broker passes it through verbatim into `approvals.log`.
|
|
136
|
+
|
|
135
137
|
## CLI
|
|
136
138
|
|
|
137
139
|
- `iapeer approvals [--json]` — the pending queue: personality · runtime · kind · **the verbatim command/content** · age · id.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# 18 — Origin guard
|
|
2
|
+
|
|
3
|
+
[Русский](ru/18-origin-guard.md) · **English**
|
|
4
|
+
|
|
5
|
+
When a human writes to an agent from one channel (say, the web console) and the agent replies into another (say, Telegram), the reply lands where the human is not looking. Until now this error class was held back only by agent doctrine («reply where the message came from») — memory, not mechanics. The **origin guard** enforces it at the router: an agent's *reply* to a human must target the channel the human last wrote from, or it is **held** for an explicit one-command confirmation instead of being delivered.
|
|
6
|
+
|
|
7
|
+
This is a **normative contract** for the guard's behavior, state, and CLI surface.
|
|
8
|
+
|
|
9
|
+
## What counts as a reply
|
|
10
|
+
|
|
11
|
+
The guard keeps one entry per **(human, agent)** pair — humans are peers with `intelligence: natural`:
|
|
12
|
+
|
|
13
|
+
- an ok **human → agent** delivery stamps the pair: the human's channel (`rt` = the sender's runtime) and the instant (`inboundTs`);
|
|
14
|
+
- an ok **agent → human** delivery stamps `answeredTs`.
|
|
15
|
+
|
|
16
|
+
The pair is **armed** when the latest inbound is *unanswered* (`inboundTs > answeredTs`) and younger than the arm TTL (48 h by default). An agent's send to the human **while armed is presumed a reply** and must target the origin channel. Once answered — or once the TTL cuts a stale thread — any further send is *initiative* and passes with no friction.
|
|
17
|
+
|
|
18
|
+
Why not a plain time window: a long task is exactly the high-value catch (asked from web, the agent finishes two hours later — the reply must still land in web), while a long window would false-hold initiative after an already-answered question. Unansweredness is the semantically precise discriminator; the TTL only bounds staleness (a thread closed outside the router — in an attached terminal, by voice — is invisible to the guard and must not arm it forever).
|
|
19
|
+
|
|
20
|
+
## The hold
|
|
21
|
+
|
|
22
|
+
On an armed mismatch — the **intended** channel (`runtime` argument, or the human's default runtime when omitted) differs from the armed origin — the send is **not delivered**. It is persisted verbatim as a *pending held send* and the sender gets an instructive error:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
origin-guard: "arthur" last wrote to you from "web" (2026-07-15T12:58:03+03:00) and that
|
|
26
|
+
message is not yet answered — this send targets "telegram". Message NOT delivered; held
|
|
27
|
+
verbatim as og-1a2b3c4d (expires in 15 min).
|
|
28
|
+
- cross-channel is intentional → deliver as addressed: iapeer confirm-send og-1a2b3c4d
|
|
29
|
+
- reply to the origin channel instead: iapeer confirm-send og-1a2b3c4d --runtime web
|
|
30
|
+
Do not re-send the message text — confirm-send delivers the held message.
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Both exits cost one shell command and **zero message regeneration**: the held send carries the full original input (message, topic, attachments).
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
iapeer confirm-send og-1a2b3c4d # deliver as addressed (intentional cross-channel)
|
|
37
|
+
iapeer confirm-send og-1a2b3c4d --runtime web # deliver the same message to the origin channel
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`confirm-send` re-sends under the **original sender identity**. A peer session (carrying `PEER_IDENTITY`) may confirm only its own held sends; a bare operator shell may confirm any (same-uid trust domain). The claim is atomic — of two concurrent confirms exactly one wins. A failed delivery puts the hold back (retry until TTL); an expired hold answers «re-send the message instead».
|
|
41
|
+
|
|
42
|
+
## Where it runs
|
|
43
|
+
|
|
44
|
+
The guard and both stamps live in `routeSend` — the one routing point **both** entry paths share: the daemon's `send_to_peer` tool *and* the in-process CLI `iapeer send` (which is how the channel bridges deliver human inbound). A daemon-only guard would never see the inbound half and never arm. The bypass used by `confirm-send` is a routing dependency wired by the entry points — it is **not reachable from agent-supplied tool arguments**, so an agent cannot forge it.
|
|
45
|
+
|
|
46
|
+
State is on disk (it must survive daemon restarts and short-lived CLI processes), under the state root:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
~/.iapeer/state/iapeer/origin/
|
|
50
|
+
state.json # (agent → human → {rt, inboundTs, answeredTs}) — last-wins, atomic writes
|
|
51
|
+
pending/<id>.json # held sends, verbatim; lazily GC'd past the pending TTL
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Scope and non-interference
|
|
55
|
+
|
|
56
|
+
The guard touches exactly one traffic class: **agent → human** sends while armed. Everything else is untouched by construction: agent→agent, human→agent, service (`absent`) senders and targets, same-channel replies (armed origin = intended channel), initiative (disarmed), and every flow with the guard disabled. Input typed directly into an attached terminal never passes the router, so it neither arms nor disarms the pair — the arm TTL bounds the resulting staleness. Hard delivery policies (e.g. the telegram sender-face policy) still apply at delivery time, including on the confirm path.
|
|
57
|
+
|
|
58
|
+
Delivery outcomes stay observable in `delivery.log`: a hold is an `ok=false` line whose `err` carries the origin-guard note (grep `origin-guard`); a confirmed delivery carries `og=<id>`, correlating it with its earlier refusal.
|
|
59
|
+
|
|
60
|
+
## Knobs
|
|
61
|
+
|
|
62
|
+
| Env | Default | Meaning |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `IAPEER_ORIGIN_GUARD` | on | `0` disables the guard and the stamps host-wide |
|
|
65
|
+
| `IAPEER_ORIGIN_GUARD_ARM_TTL_MS` | 48 h | how long an unanswered inbound keeps the pair armed |
|
|
66
|
+
| `IAPEER_ORIGIN_GUARD_PENDING_TTL_MS` | 15 min | how long a held send stays confirmable |
|
package/docs/README.md
CHANGED
|
@@ -79,6 +79,7 @@ The sections are layered: from "install and use" to "understand the architecture
|
|
|
79
79
|
- [15 — Fleet API](15-fleet-api.md) — the daemon's HTTP surface for operator clients (dashboards, tray apps, scripts): snapshot, event stream, commands
|
|
80
80
|
- [16 — Tray](16-tray.md) — the macOS menu-bar fleet dashboard (SwiftBar plugin), the first external Fleet API client
|
|
81
81
|
- [17 — Human approval](17-approval.md) — per-peer gated/yolo mode: routing a runtime's blocking approvals to a human through the daemon broker (hook interception, the broker, Fleet API + CLI)
|
|
82
|
+
- [18 — Origin guard](18-origin-guard.md) — an agent's reply to a human must target the channel the human wrote from: the armed-pair model, the hold, `confirm-send`
|
|
82
83
|
|
|
83
84
|
## Status
|
|
84
85
|
|
|
@@ -67,3 +67,7 @@ iapeer compact assistant # сжать диалог
|
|
|
67
67
|
- **`iapeer self-done`** — самовызов воркера, которому нечего отправить: освобождает очередь под следующую задачу, не разбудив никого. Доктрина «нечего отправлять — `self-done` вместо пустого ответа».
|
|
68
68
|
|
|
69
69
|
Эта модель держит поток одноразовых задач упорядоченным: один воркер на задачу, без накопления параллельных сессий, без пустых пробуждений адресата.
|
|
70
|
+
|
|
71
|
+
## Ответ человеку: origin guard
|
|
72
|
+
|
|
73
|
+
Ответ агента человеку обязан лечь в канал, из которого человек писал. Роутер закрепляет это механически: пока последнее сообщение человека этому агенту не отвечено, отправка в *другой* канал удерживается до подтверждения одной командой (`iapeer confirm-send <id>`) — вместо доставки. Ответы в тот же канал и инициатива проходят без трения. Полный контракт — модель взведённой пары, TTL, `confirm-send` — в [18 — Origin guard](18-origin-guard.md).
|
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
| Команда | Что делает |
|
|
60
60
|
|---|---|
|
|
61
61
|
| `send <цель> (--message <текст> \| --message-file <f\|->) [--from <id>] [--runtime <r>] [--attachment <путь>]… [--topic <t>]` | Ручная отправка IAP по тому же пути, что `send_to_peer`. |
|
|
62
|
+
| `confirm-send <id> [--runtime <rt>]` | Доставить удержанную отправку ([origin guard](18-origin-guard.md)): как адресована, либо `--runtime` перенаправляет её в канал-источник. Без повторной отправки текста. |
|
|
62
63
|
|
|
63
64
|
`--message-file -` читает тело из stdin. `--attachment` повторяемый. `--from` по умолчанию — идентичность пира текущей папки.
|
|
64
65
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# 18 — Origin guard (страж канала-источника)
|
|
2
|
+
|
|
3
|
+
**Русский** · [English](../18-origin-guard.md)
|
|
4
|
+
|
|
5
|
+
Когда человек пишет агенту из одного канала (скажем, из web-консоли), а агент отвечает в другой (скажем, в Telegram), ответ уходит туда, куда человек не смотрит. До сих пор этот класс ошибок держался только на доктрине агента («отвечай туда, откуда пришло») — на памяти, не на механике. **Origin guard** закрепляет правило на роутере: *ответ* агента человеку обязан целиться в канал, из которого человек писал в последний раз, иначе он **удерживается** до явного подтверждения одной командой — вместо доставки.
|
|
6
|
+
|
|
7
|
+
Это **нормативный контракт** поведения стража, его состояния и CLI-поверхности.
|
|
8
|
+
|
|
9
|
+
## Что считается ответом
|
|
10
|
+
|
|
11
|
+
Страж ведёт одну запись на пару **(человек, агент)** — люди суть пиры с `intelligence: natural`:
|
|
12
|
+
|
|
13
|
+
- успешная доставка **человек → агент** штампует пару: канал человека (`rt` = рантайм отправителя) и момент (`inboundTs`);
|
|
14
|
+
- успешная доставка **агент → человек** штампует `answeredTs`.
|
|
15
|
+
|
|
16
|
+
Пара **взведена**, когда последнее входящее *не отвечено* (`inboundTs > answeredTs`) и моложе TTL взвода (по умолчанию 48 ч). Отправка агента человеку **при взведённой паре презюмируется ответом** и обязана целиться в канал-источник. После ответа — либо после того как TTL отсёк залежалую нить — любая следующая отправка есть *инициатива* и проходит без трения.
|
|
17
|
+
|
|
18
|
+
Почему не простое окно времени: долгая задача — ровно тот ценный класс, который нужно ловить (спросили из web, агент закончил через два часа — ответ всё равно должен лечь в web), а длинное окно ложно удерживало бы инициативу после уже отвеченного вопроса. Неотвеченность — семантически точный дискриминатор; TTL лишь ограничивает залежалость (нить, закрытая мимо роутера — в приаттаченном терминале, голосом — стражу не видна и не должна держать пару взведённой вечно).
|
|
19
|
+
|
|
20
|
+
## Удержание
|
|
21
|
+
|
|
22
|
+
При взведённом несовпадении — **намеченный** канал (аргумент `runtime`, а при его отсутствии дефолтный рантайм человека) отличается от взведённого источника — отправка **не доставляется**. Она сохраняется дословно как *удержанная* (pending), а отправитель получает инструктивную ошибку:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
origin-guard: "arthur" last wrote to you from "web" (2026-07-15T12:58:03+03:00) and that
|
|
26
|
+
message is not yet answered — this send targets "telegram". Message NOT delivered; held
|
|
27
|
+
verbatim as og-1a2b3c4d (expires in 15 min).
|
|
28
|
+
- cross-channel is intentional → deliver as addressed: iapeer confirm-send og-1a2b3c4d
|
|
29
|
+
- reply to the origin channel instead: iapeer confirm-send og-1a2b3c4d --runtime web
|
|
30
|
+
Do not re-send the message text — confirm-send delivers the held message.
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Оба выхода стоят одну shell-команду и **ноль регенерации сообщения**: удержанная отправка несёт полный исходный ввод (текст, топик, вложения).
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
iapeer confirm-send og-1a2b3c4d # доставить как адресовано (кросс-канал намеренный)
|
|
37
|
+
iapeer confirm-send og-1a2b3c4d --runtime web # доставить то же сообщение в канал-источник
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`confirm-send` отправляет от **исходной идентичности отправителя**. Сессия пира (несущая `PEER_IDENTITY`) может подтверждать только свои удержанные отправки; голый операторский шелл — любые (домен доверия same-uid). Захват атомарен — из двух конкурентных подтверждений побеждает ровно одно. Провал доставки возвращает удержание на место (повтор до TTL); истёкшее отвечает «отправь сообщение заново».
|
|
41
|
+
|
|
42
|
+
## Где это работает
|
|
43
|
+
|
|
44
|
+
Страж и оба штампа живут в `routeSend` — единственной точке маршрутизации, общей для **обоих** путей входа: инструмента `send_to_peer` демона *и* внутрипроцессного CLI `iapeer send` (которым мосты каналов доставляют входящие от человека). Страж только на стороне демона никогда не видел бы входящую половину и никогда бы не взводился. Обход (bypass), которым пользуется `confirm-send`, — зависимость маршрутизации, которую прошивают точки входа; он **недостижим из tool-аргументов агента**, подделать его агент не может.
|
|
45
|
+
|
|
46
|
+
Состояние — на диске (обязано переживать рестарты демона и короткоживущие CLI-процессы), под корнем состояния:
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
~/.iapeer/state/iapeer/origin/
|
|
50
|
+
state.json # (агент → человек → {rt, inboundTs, answeredTs}) — last-wins, атомарные записи
|
|
51
|
+
pending/<id>.json # удержанные отправки, дословно; ленивый GC после TTL
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Границы и невмешательство
|
|
55
|
+
|
|
56
|
+
Страж касается ровно одного класса трафика: отправки **агент → человек** при взведённой паре. Всё прочее не затронуто по построению: агент→агент, человек→агент, сервисные (`absent`) отправители и цели, ответы в тот же канал (источник = намеченный канал), инициатива (пара разряжена) и любые потоки при выключенном страже. Ввод, набранный прямо в приаттаченном терминале, роутер не проходит — он не взводит и не разряжает пару; итоговую залежалость ограничивает TTL взвода. Жёсткие политики доставки (например, telegram-политика лица отправителя) продолжают действовать в момент доставки, включая путь подтверждения.
|
|
57
|
+
|
|
58
|
+
Исходы доставки наблюдаемы в `delivery.log`: удержание — строка `ok=false`, чей `err` несёт ноут стража (grep `origin-guard`); подтверждённая доставка несёт `og=<id>`, связывающий её с прежним отказом.
|
|
59
|
+
|
|
60
|
+
## Ручки
|
|
61
|
+
|
|
62
|
+
| Env | По умолчанию | Смысл |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `IAPEER_ORIGIN_GUARD` | вкл | `0` выключает стража и штампы на всём хосте |
|
|
65
|
+
| `IAPEER_ORIGIN_GUARD_ARM_TTL_MS` | 48 ч | сколько неотвеченное входящее держит пару взведённой |
|
|
66
|
+
| `IAPEER_ORIGIN_GUARD_PENDING_TTL_MS` | 15 мин | сколько удержанная отправка остаётся подтверждаемой |
|
package/docs/ru/README.md
CHANGED
|
@@ -79,6 +79,7 @@ iapeer send assistant --message "привет, ты на связи?"
|
|
|
79
79
|
- [15 — Fleet API](15-fleet-api.md) — HTTP-поверхность демона для операторских клиентов (дашборды, tray-приложения, скрипты): снапшот, поток событий, команды
|
|
80
80
|
- [16 — Трей](16-tray.md) — дашборд флота в меню-баре macOS (плагин SwiftBar), первый внешний клиент Fleet API
|
|
81
81
|
- [17 — Подтверждение человеком](17-approval.md) — режим gated/yolo на пира: маршрутизация блокирующих подтверждений рантайма человеку через брокер демона (перехват хуком, брокер, Fleet API + CLI)
|
|
82
|
+
- [18 — Origin guard](18-origin-guard.md) — ответ агента человеку обязан целиться в канал, из которого человек писал: модель взведённой пары, удержание, `confirm-send`
|
|
82
83
|
|
|
83
84
|
## Статус
|
|
84
85
|
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -1079,6 +1079,12 @@ export interface SendOptions extends CliEnvOptions {
|
|
|
1079
1079
|
message: string
|
|
1080
1080
|
topic?: string
|
|
1081
1081
|
attachments?: string[]
|
|
1082
|
+
/** Origin-guard bypass (docs/18) — set ONLY by `iapeer confirm-send` (the held-send
|
|
1083
|
+
* delivery). Wired into RouteDeps, never derivable from agent-supplied tool args. */
|
|
1084
|
+
originGuardBypass?: boolean
|
|
1085
|
+
/** Held-send id (confirm-send) — stamped into delivery.log as `og=` so a confirmed
|
|
1086
|
+
* delivery is correlatable with its earlier origin-hold refusal. */
|
|
1087
|
+
og?: string
|
|
1082
1088
|
}
|
|
1083
1089
|
|
|
1084
1090
|
const cliWake: WakeFn = req =>
|
|
@@ -1112,7 +1118,12 @@ export async function sendMessage(
|
|
|
1112
1118
|
// noteLiveTopic — CLI-path parity (same seam the daemon wires): a live delivery
|
|
1113
1119
|
// through the CLI fallback must update the target's .topic marker too, or a
|
|
1114
1120
|
// daemon-restart-window send re-opens the stale-marker false-fresh.
|
|
1115
|
-
{
|
|
1121
|
+
{
|
|
1122
|
+
wake: cliWake,
|
|
1123
|
+
ephemeral: makeEphemeralRouteDeps(cfg, env, () => {}),
|
|
1124
|
+
noteLiveTopic: makeNoteLiveTopic(cfg, env),
|
|
1125
|
+
originGuardBypass: opts.originGuardBypass,
|
|
1126
|
+
},
|
|
1116
1127
|
)
|
|
1117
1128
|
// delivery.log sink — CLI-path parity (observability gap: enqueues routed
|
|
1118
1129
|
// through the CLI left to=<peer> at ZERO while real wakes happened; the daemon
|
|
@@ -1135,6 +1146,7 @@ export async function sendMessage(
|
|
|
1135
1146
|
len: opts.message.length,
|
|
1136
1147
|
att: opts.attachments?.length || undefined,
|
|
1137
1148
|
topic: opts.topic,
|
|
1149
|
+
og: opts.og, // held-send id (confirm-send) — correlates with the origin-hold refusal
|
|
1138
1150
|
err: result.ok ? undefined : result.error.message,
|
|
1139
1151
|
})
|
|
1140
1152
|
if (!result.ok) throw new Error(result.error.message)
|
|
@@ -1277,6 +1289,10 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
|
|
|
1277
1289
|
sig: 'send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>]',
|
|
1278
1290
|
desc: 'manual IAP send (fallback)',
|
|
1279
1291
|
},
|
|
1292
|
+
{
|
|
1293
|
+
sig: 'confirm-send <id> [--runtime <rt>]',
|
|
1294
|
+
desc: 'deliver a held send (origin-guard, docs/18): as addressed, or --runtime redirects it to the origin channel — no re-send of the message text',
|
|
1295
|
+
},
|
|
1280
1296
|
{ sig: '<runtime>', desc: "launch the cwd's peer FRESH; on a TTY drop into it (like attach), else detached" },
|
|
1281
1297
|
{ sig: 'connect telegram <peer> [--token <t>]', desc: 'attach a telegram bot to a peer (bot add → interface → router restart; asks only the token). Alias: `iapeer enable telegram <peer>`' },
|
|
1282
1298
|
{ sig: 'enable <plugin> [peer] [--no-setup]', desc: 'install + enable an agfpd capability for a peer' },
|
|
@@ -2136,6 +2152,54 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2136
2152
|
)
|
|
2137
2153
|
return 0
|
|
2138
2154
|
}
|
|
2155
|
+
case 'confirm-send': {
|
|
2156
|
+
// Origin-guard (docs/18): deliver a HELD send — as addressed (intentional
|
|
2157
|
+
// cross-channel), or redirected to the origin channel via --runtime. The held
|
|
2158
|
+
// message is re-sent VERBATIM under the ORIGINAL sender identity; the bypass
|
|
2159
|
+
// flag lives in RouteDeps (not tool args), so this verb is the only gate.
|
|
2160
|
+
const id = positionals[0]
|
|
2161
|
+
if (!id) return argErr(errOut, 'confirm-send needs an id — usage: iapeer confirm-send <id> [--runtime <rt>]')
|
|
2162
|
+
const rtOverride = typeof flags.runtime === 'string' ? flags.runtime : undefined
|
|
2163
|
+
const { claimHeldSend, restoreHeldSend, discardHeldSend } = await import('../transport/originGuard.ts')
|
|
2164
|
+
const claimed = claimHeldSend(id, { env })
|
|
2165
|
+
if (!claimed.ok) {
|
|
2166
|
+
errOut(`${claimed.error.message}\n`)
|
|
2167
|
+
return 1
|
|
2168
|
+
}
|
|
2169
|
+
const held = claimed.value
|
|
2170
|
+
// The confirmer must BE the holder: a peer session carries PEER_IDENTITY, and a
|
|
2171
|
+
// held send may only be confirmed by the peer that produced it. Absent identity
|
|
2172
|
+
// (a bare operator shell) is trusted — same-uid operator, same trust domain.
|
|
2173
|
+
const confirmer = env.PEER_IDENTITY?.trim()
|
|
2174
|
+
if (confirmer && confirmer !== held.caller) {
|
|
2175
|
+
restoreHeldSend(id, { env })
|
|
2176
|
+
errOut(`confirm-send: held send ${id} belongs to ${held.caller}, not ${confirmer} — refused\n`)
|
|
2177
|
+
return 1
|
|
2178
|
+
}
|
|
2179
|
+
try {
|
|
2180
|
+
const r = await sendMessage({
|
|
2181
|
+
target: held.personality,
|
|
2182
|
+
from: held.caller,
|
|
2183
|
+
message: held.message,
|
|
2184
|
+
runtime: rtOverride ?? held.runtime,
|
|
2185
|
+
topic: held.topic,
|
|
2186
|
+
attachments: held.attachments,
|
|
2187
|
+
env,
|
|
2188
|
+
originGuardBypass: true,
|
|
2189
|
+
og: held.id,
|
|
2190
|
+
})
|
|
2191
|
+
discardHeldSend(id, { env })
|
|
2192
|
+
out(`delivered held send ${id} to ${r.delivered_to.personality} (${r.delivered_to.runtime})\n`)
|
|
2193
|
+
return 0
|
|
2194
|
+
} catch (e) {
|
|
2195
|
+
restoreHeldSend(id, { env })
|
|
2196
|
+
errOut(
|
|
2197
|
+
`confirm-send: delivery failed — ${e instanceof Error ? e.message : String(e)}; ` +
|
|
2198
|
+
`the held send remains until its TTL — retry: iapeer confirm-send ${id}\n`,
|
|
2199
|
+
)
|
|
2200
|
+
return 1
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2139
2203
|
case 'version':
|
|
2140
2204
|
case '--version':
|
|
2141
2205
|
case '-v': {
|
package/src/daemon/approvals.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Approval broker — the in-daemon queue that is the SINGLE SOURCE OF TRUTH for
|
|
2
2
|
// human-approval requests (docs/17). Every channel (the runtime hook that ASKS, and
|
|
3
|
-
// the CLI / tray / telegram faces that ANSWER) is an interface to THIS one queue: a
|
|
3
|
+
// the CLI / tray / telegram / web-console faces that ANSWER) is an interface to THIS one queue: a
|
|
4
4
|
// decision from any face resolves the request everywhere (the owner's invariant).
|
|
5
5
|
//
|
|
6
6
|
// Flow: a gated peer's PreToolUse hook blocks and POSTs /fleet/v1/approvals → the fleet
|
|
@@ -56,7 +56,9 @@ export interface ApprovalDecision {
|
|
|
56
56
|
export interface ResolveMeta {
|
|
57
57
|
/** The approver who answered (a nature-peer personality). */
|
|
58
58
|
by?: string
|
|
59
|
-
/** The
|
|
59
|
+
/** The surface that answered: cli | tray | telegram | web | timeout | disconnect.
|
|
60
|
+
* Self-declared by the answering face (free-form-tolerant); absent when the face
|
|
61
|
+
* did not self-identify — the audit line then omits `via` rather than guessing. */
|
|
60
62
|
via?: string
|
|
61
63
|
}
|
|
62
64
|
|
package/src/daemon/fleet.ts
CHANGED
|
@@ -636,7 +636,10 @@ export function buildFleetHandler(opts: FleetHandlerOptions = {}): FleetHandler
|
|
|
636
636
|
/* empty/invalid body is fine — approve/deny carry only optional fields */
|
|
637
637
|
}
|
|
638
638
|
const by = typeof body.approver === 'string' ? body.approver : undefined
|
|
639
|
-
|
|
639
|
+
// The answering face self-identifies its surface in `via` (cli | tray | telegram | web | …;
|
|
640
|
+
// free-form-tolerant like `kind`). NO default — an audit line must never claim a surface the
|
|
641
|
+
// caller did not state; a face that sent nothing is logged with the field omitted.
|
|
642
|
+
const via = typeof body.via === 'string' ? body.via : undefined
|
|
640
643
|
const ok =
|
|
641
644
|
action === 'approve'
|
|
642
645
|
? broker.resolve(id, { decision: 'allow', reason: typeof body.reason === 'string' ? body.reason : undefined }, { by, via })
|
package/src/transport/index.ts
CHANGED
|
@@ -57,6 +57,17 @@ import {
|
|
|
57
57
|
listHostedPeers,
|
|
58
58
|
} from '../launch/ptyHost.ts'
|
|
59
59
|
import { sendControlToHost, type DeliverResult } from '../supervisor/deliver.ts'
|
|
60
|
+
// Origin-guard (docs/18) — reply-channel mechanic for agent→human sends. Lives in
|
|
61
|
+
// transport because routeSend is the ONLY choke point both directions share (human
|
|
62
|
+
// inbound arrives via the CLI entry path, past the daemon — see originGuard.ts header).
|
|
63
|
+
import {
|
|
64
|
+
armedOrigin,
|
|
65
|
+
buildHoldNote,
|
|
66
|
+
holdSend,
|
|
67
|
+
noteHumanAnswered,
|
|
68
|
+
noteHumanInbound,
|
|
69
|
+
originGuardEnabled,
|
|
70
|
+
} from './originGuard.ts'
|
|
60
71
|
import { keysToBytes } from '../supervisor/protocol.ts' // pure send-keys-vocab→pty-byte translation (boot-driver slice a), @xterm-free
|
|
61
72
|
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
|
|
62
73
|
|
|
@@ -971,6 +982,11 @@ export interface RouteDeps {
|
|
|
971
982
|
isLaunchdManaged?: (personality: string) => boolean
|
|
972
983
|
/** Injectable async sleep for the launchd-revive retry poll (tests). Default: setTimeout. */
|
|
973
984
|
sleep?: (ms: number) => Promise<void>
|
|
985
|
+
/** Origin-guard bypass — set ONLY by `iapeer confirm-send` (the held-send delivery).
|
|
986
|
+
* DELIBERATELY a RouteDeps field, not a SendToPeerInput field: tool arguments are
|
|
987
|
+
* agent-supplied, so an input flag would let any agent forge the bypass; deps are
|
|
988
|
+
* wired by the entry points (daemon/CLI), out of the agent's reach. */
|
|
989
|
+
originGuardBypass?: boolean
|
|
974
990
|
}
|
|
975
991
|
|
|
976
992
|
/** Best-effort noteLiveTopic invocation — only on a non-empty topic, never throws. */
|
|
@@ -1295,6 +1311,36 @@ export async function routeSend(
|
|
|
1295
1311
|
caller: ResolvedCaller,
|
|
1296
1312
|
input: SendToPeerInput,
|
|
1297
1313
|
deps: RouteDeps = {},
|
|
1314
|
+
): Promise<Result<RouteResult>> {
|
|
1315
|
+
const result = await routeSendInner(caller, input, deps)
|
|
1316
|
+
// Origin-guard stamps — AFTER an ok outcome only (a failed send answered nothing and
|
|
1317
|
+
// carried no inbound). Best-effort: a stamp failure must never fail a delivered
|
|
1318
|
+
// message. Covers EVERY ok path (live / queued / wake / launchd-revive) in one place.
|
|
1319
|
+
if (result.ok) stampOriginOnDelivered(caller, input.personality, deps.env ?? process.env)
|
|
1320
|
+
return result
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/** Post-ok origin stamps: human→agent ⇒ inbound (arms the pair with the human's origin
|
|
1324
|
+
* channel); agent→human ⇒ answered (disarms). Any other pair — no-op. */
|
|
1325
|
+
function stampOriginOnDelivered(caller: ResolvedCaller, targetPersonality: string, env: NodeJS.ProcessEnv): void {
|
|
1326
|
+
try {
|
|
1327
|
+
if (!originGuardEnabled(env)) return
|
|
1328
|
+
const peer = findPeer(readPeersIndex({ env }), targetPersonality)
|
|
1329
|
+
if (!peer) return
|
|
1330
|
+
if (caller.intelligence === 'natural' && peer.intelligence === 'artificial') {
|
|
1331
|
+
noteHumanInbound(peer.personality, caller.personality, caller.runtime, { env })
|
|
1332
|
+
} else if (caller.intelligence === 'artificial' && peer.intelligence === 'natural') {
|
|
1333
|
+
noteHumanAnswered(caller.personality, peer.personality, { env })
|
|
1334
|
+
}
|
|
1335
|
+
} catch {
|
|
1336
|
+
/* stamps are observability for the guard — never fail a delivered message */
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
async function routeSendInner(
|
|
1341
|
+
caller: ResolvedCaller,
|
|
1342
|
+
input: SendToPeerInput,
|
|
1343
|
+
deps: RouteDeps = {},
|
|
1298
1344
|
): Promise<Result<RouteResult>> {
|
|
1299
1345
|
const { personality, runtime, message } = input
|
|
1300
1346
|
const topic = truncateTopic(input.topic)
|
|
@@ -1324,6 +1370,35 @@ export async function routeSend(
|
|
|
1324
1370
|
return err(`peer "${personality}" is not in the iapeer peers index; message NOT delivered`)
|
|
1325
1371
|
}
|
|
1326
1372
|
|
|
1373
|
+
// Origin-guard (docs/18) — an agent's PRESUMED REPLY to a human (the human's latest
|
|
1374
|
+
// inbound to this agent is unanswered) must target the channel the human wrote from.
|
|
1375
|
+
// Checked FIRST, before any resolve/wake side-effect and before the telegram policy:
|
|
1376
|
+
// a mismatch is HELD (persisted verbatim + instructive error), not delivered — the
|
|
1377
|
+
// agent then confirms cross-channel or redirects to the origin, zero regeneration.
|
|
1378
|
+
// Intended runtime = the explicit override or the human's default; exact for the
|
|
1379
|
+
// natural-peer class (their channels are always-on launchd routers, so the
|
|
1380
|
+
// only-live-session fallback cannot silently diverge outside a seconds-wide crash
|
|
1381
|
+
// window). Initiative (answered / no inbound / ARM_TTL-stale) passes untouched.
|
|
1382
|
+
if (
|
|
1383
|
+
!deps.originGuardBypass &&
|
|
1384
|
+
originGuardEnabled(env) &&
|
|
1385
|
+
caller.intelligence === 'artificial' &&
|
|
1386
|
+
peer.intelligence === 'natural'
|
|
1387
|
+
) {
|
|
1388
|
+
const armed = armedOrigin(caller.personality, peer.personality, { env })
|
|
1389
|
+
const intendedRt = runtime ?? peer.runtime
|
|
1390
|
+
if (armed && armed.rt !== intendedRt) {
|
|
1391
|
+
const held = holdSend(
|
|
1392
|
+
caller.address,
|
|
1393
|
+
{ personality, runtime, message, topic, attachments: attachmentsResult.value },
|
|
1394
|
+
armed,
|
|
1395
|
+
intendedRt,
|
|
1396
|
+
{ env },
|
|
1397
|
+
)
|
|
1398
|
+
return err(buildHoldNote(held, { env }))
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1327
1402
|
// Telegram sender policy — INTENDED channel (explicit override, or the target's
|
|
1328
1403
|
// default). Checked BEFORE any wake side-effect: a wake delivers the envelope as
|
|
1329
1404
|
// the boot first-message, past the resolved-target guards further down.
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Origin-guard (docs/18) — the daemon-layer mechanic against the "agent answered the
|
|
2
|
+
// human in the wrong channel" error class. Doctrine (origin-routing: reply where the
|
|
3
|
+
// human wrote from) lives in every agent's memory — unreliable by construction; this
|
|
4
|
+
// module enforces it MECHANICALLY at the delivery choke point, routeSend (§3 of the
|
|
5
|
+
// host rules: fix errors at the right layer).
|
|
6
|
+
//
|
|
7
|
+
// WHY transport, not the daemon: live forensics of delivery.log showed that ALL
|
|
8
|
+
// human-inbound traffic (telegram-arthur / web-arthur) arrives via `iapeer send`
|
|
9
|
+
// (path=cli — the bridges run the CLI verb, an IN-PROCESS routeSend that never touches
|
|
10
|
+
// the daemon's callTool), and codex agents send through the CLI shim too. routeSend is
|
|
11
|
+
// the ONLY point both directions and both entry paths (daemon MCP + CLI) share — a
|
|
12
|
+
// daemon-side guard would be blind to the inbound half and never arm.
|
|
13
|
+
//
|
|
14
|
+
// Model (per (human, agent) pair, humans = intelligence:natural):
|
|
15
|
+
// • inbound stamp — an ok human→agent delivery records {rt, inboundTs}.
|
|
16
|
+
// • answered stamp — an ok agent→human delivery records answeredTs.
|
|
17
|
+
// • armed — inboundTs > answeredTs (the latest inbound is UNANSWERED) and
|
|
18
|
+
// younger than ARM_TTL. Discriminates reply-vs-initiative
|
|
19
|
+
// semantically: an outbound while armed is presumed a REPLY (must
|
|
20
|
+
// match the origin channel); once answered (or TTL-stale), any
|
|
21
|
+
// further outbound is INITIATIVE — no friction. A pure time
|
|
22
|
+
// window would either miss the long-task class (asked from web,
|
|
23
|
+
// answered 2h later into telegram) or false-hold initiative.
|
|
24
|
+
// • hold — an armed mismatch (intended runtime ≠ origin runtime) is NOT
|
|
25
|
+
// delivered: the send is persisted verbatim as a pending file and
|
|
26
|
+
// the sender gets an instructive error. `iapeer confirm-send <id>`
|
|
27
|
+
// delivers it as addressed; `--runtime <origin>` redirects it to
|
|
28
|
+
// the origin channel — zero message regeneration either way.
|
|
29
|
+
//
|
|
30
|
+
// State is on DISK (not daemon memory) because the CLI entry path is a short-lived
|
|
31
|
+
// process, and stamps must survive daemon restarts. Writes are atomic
|
|
32
|
+
// (storage.writeFileAtomic); concurrent read-modify-write is LAST-WINS by design — the
|
|
33
|
+
// guard is soft, stamp frequency is human-paced, and a lost stamp degrades to at most
|
|
34
|
+
// one extra confirm (owner-approved v1 fork; add locking only if it bites live).
|
|
35
|
+
//
|
|
36
|
+
// Paths resolve from the injected env (the readPeersIndex({env}) pattern) so the
|
|
37
|
+
// IAPEER_ROOT sandbox isolates tests — never from a re-resolved process.env.
|
|
38
|
+
|
|
39
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync } from 'fs'
|
|
40
|
+
import { join } from 'path'
|
|
41
|
+
import { randomBytes } from 'crypto'
|
|
42
|
+
import { err, ok, type Result } from '../core/errors.ts'
|
|
43
|
+
import { pluginStateDir, writeFileAtomic } from '../storage/index.ts'
|
|
44
|
+
import { formatSentAt } from '../codec/index.ts'
|
|
45
|
+
|
|
46
|
+
/** Kill-switch: IAPEER_ORIGIN_GUARD=0 disables the guard AND the stamps host-wide. */
|
|
47
|
+
export function originGuardEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
48
|
+
return env.IAPEER_ORIGIN_GUARD !== '0'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DEFAULT_ARM_TTL_MS = 48 * 60 * 60 * 1000 // 48 h — covers a realistic reply latency, cuts staleness
|
|
52
|
+
const DEFAULT_PENDING_TTL_MS = 15 * 60 * 1000 // 15 min — the agent confirms within its current turn
|
|
53
|
+
|
|
54
|
+
function envPosInt(raw: string | undefined, dflt: number): number {
|
|
55
|
+
const n = parseInt(raw ?? '', 10)
|
|
56
|
+
return Number.isFinite(n) && n > 0 ? n : dflt
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function armTtlMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
60
|
+
return envPosInt(env.IAPEER_ORIGIN_GUARD_ARM_TTL_MS, DEFAULT_ARM_TTL_MS)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function pendingTtlMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
64
|
+
return envPosInt(env.IAPEER_ORIGIN_GUARD_PENDING_TTL_MS, DEFAULT_PENDING_TTL_MS)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface OriginGuardOptions {
|
|
68
|
+
env?: NodeJS.ProcessEnv
|
|
69
|
+
/** Injectable clock (tests). Default Date.now. */
|
|
70
|
+
nowMs?: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Origin state (last inbound / answered, per agent→human pair) ─────────────────
|
|
74
|
+
|
|
75
|
+
export interface OriginEntry {
|
|
76
|
+
/** The runtime the human's latest message arrived FROM (its origin channel). */
|
|
77
|
+
rt: string
|
|
78
|
+
/** When that inbound was delivered ok (epoch ms). */
|
|
79
|
+
inboundTs: number
|
|
80
|
+
/** When the agent last answered this human ok (epoch ms). Absent = never. */
|
|
81
|
+
answeredTs?: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** agent personality → human personality → entry. */
|
|
85
|
+
type OriginState = Record<string, Record<string, OriginEntry>>
|
|
86
|
+
|
|
87
|
+
function originDir(env: NodeJS.ProcessEnv): string {
|
|
88
|
+
return join(pluginStateDir('iapeer', { env }), 'origin')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function statePath(env: NodeJS.ProcessEnv): string {
|
|
92
|
+
return join(originDir(env), 'state.json')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pendingDir(env: NodeJS.ProcessEnv): string {
|
|
96
|
+
return join(originDir(env), 'pending')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readState(env: NodeJS.ProcessEnv): OriginState {
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(readFileSync(statePath(env), 'utf8')) as unknown
|
|
102
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as OriginState
|
|
103
|
+
} catch {
|
|
104
|
+
/* missing / corrupt → empty (the guard fails open: no state, no holds) */
|
|
105
|
+
}
|
|
106
|
+
return {}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function writeState(env: NodeJS.ProcessEnv, state: OriginState): void {
|
|
110
|
+
mkdirSync(originDir(env), { recursive: true })
|
|
111
|
+
writeFileAtomic(statePath(env), `${JSON.stringify(state)}\n`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Record an ok human→agent delivery: the human's origin channel + instant. */
|
|
115
|
+
export function noteHumanInbound(agent: string, human: string, rt: string, opts: OriginGuardOptions = {}): void {
|
|
116
|
+
const env = opts.env ?? process.env
|
|
117
|
+
const now = opts.nowMs ?? Date.now()
|
|
118
|
+
const state = readState(env)
|
|
119
|
+
const perAgent = (state[agent] ??= {})
|
|
120
|
+
const prev = perAgent[human]
|
|
121
|
+
// Keep answeredTs — a NEWER inbound re-arms regardless, since inboundTs > answeredTs.
|
|
122
|
+
perAgent[human] = { rt, inboundTs: now, ...(prev?.answeredTs !== undefined ? { answeredTs: prev.answeredTs } : {}) }
|
|
123
|
+
writeState(env, state)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Record an ok agent→human delivery: the pair is answered (disarms the guard). */
|
|
127
|
+
export function noteHumanAnswered(agent: string, human: string, opts: OriginGuardOptions = {}): void {
|
|
128
|
+
const env = opts.env ?? process.env
|
|
129
|
+
const now = opts.nowMs ?? Date.now()
|
|
130
|
+
const state = readState(env)
|
|
131
|
+
const entry = state[agent]?.[human]
|
|
132
|
+
if (!entry) return // nothing to disarm — never wrote to us via the router
|
|
133
|
+
entry.answeredTs = now
|
|
134
|
+
writeState(env, state)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The ARMED origin for (agent, human), or null: the latest inbound is unanswered AND
|
|
138
|
+
* younger than ARM_TTL. Null = initiative / already answered / stale — no friction. */
|
|
139
|
+
export function armedOrigin(agent: string, human: string, opts: OriginGuardOptions = {}): OriginEntry | null {
|
|
140
|
+
const env = opts.env ?? process.env
|
|
141
|
+
const now = opts.nowMs ?? Date.now()
|
|
142
|
+
const entry = readState(env)[agent]?.[human]
|
|
143
|
+
if (!entry) return null
|
|
144
|
+
if (entry.answeredTs !== undefined && entry.answeredTs >= entry.inboundTs) return null
|
|
145
|
+
if (now - entry.inboundTs > armTtlMs(env)) return null
|
|
146
|
+
return entry
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Pending (held) sends ─────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export interface HeldSend {
|
|
152
|
+
id: string
|
|
153
|
+
/** The sender's address (`<runtime>-<personality>`) — the identity confirm-send re-sends AS. */
|
|
154
|
+
caller: string
|
|
155
|
+
personality: string
|
|
156
|
+
runtime?: string
|
|
157
|
+
message: string
|
|
158
|
+
topic?: string
|
|
159
|
+
attachments?: string[]
|
|
160
|
+
/** The armed origin channel the human last wrote from (unanswered). */
|
|
161
|
+
originRt: string
|
|
162
|
+
/** When that unanswered inbound arrived (epoch ms) — shown in the hold note. */
|
|
163
|
+
originTs: number
|
|
164
|
+
/** The channel this send would have landed in (input.runtime ?? the human's default). */
|
|
165
|
+
intendedRt: string
|
|
166
|
+
createdMs: number
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function pendingPath(env: NodeJS.ProcessEnv, id: string): string {
|
|
170
|
+
return join(pendingDir(env), `${id}.json`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function claimedPath(env: NodeJS.ProcessEnv, id: string): string {
|
|
174
|
+
return join(pendingDir(env), `${id}.json.claimed`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Lazy GC — unlink pending files older than the pending TTL (by mtime). Called on
|
|
178
|
+
* every hold/claim touch; cheap (one readdir of a near-empty dir), no timers. */
|
|
179
|
+
export function sweepExpiredPendings(opts: OriginGuardOptions = {}): void {
|
|
180
|
+
const env = opts.env ?? process.env
|
|
181
|
+
const now = opts.nowMs ?? Date.now()
|
|
182
|
+
const ttl = pendingTtlMs(env)
|
|
183
|
+
let entries: string[]
|
|
184
|
+
try {
|
|
185
|
+
entries = readdirSync(pendingDir(env))
|
|
186
|
+
} catch {
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
for (const name of entries) {
|
|
190
|
+
if (!name.endsWith('.json') && !name.endsWith('.json.claimed')) continue
|
|
191
|
+
const path = join(pendingDir(env), name)
|
|
192
|
+
try {
|
|
193
|
+
if (now - statSync(path).mtimeMs > ttl) unlinkSync(path)
|
|
194
|
+
} catch {
|
|
195
|
+
/* raced away / unreadable — best-effort */
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Persist a held send verbatim and return its record. */
|
|
201
|
+
export function holdSend(
|
|
202
|
+
callerAddress: string,
|
|
203
|
+
input: { personality: string; runtime?: string; message: string; topic?: string; attachments?: string[] },
|
|
204
|
+
origin: OriginEntry,
|
|
205
|
+
intendedRt: string,
|
|
206
|
+
opts: OriginGuardOptions = {},
|
|
207
|
+
): HeldSend {
|
|
208
|
+
const env = opts.env ?? process.env
|
|
209
|
+
const now = opts.nowMs ?? Date.now()
|
|
210
|
+
sweepExpiredPendings(opts)
|
|
211
|
+
const id = `og-${randomBytes(4).toString('hex')}`
|
|
212
|
+
const held: HeldSend = {
|
|
213
|
+
id,
|
|
214
|
+
caller: callerAddress,
|
|
215
|
+
personality: input.personality,
|
|
216
|
+
...(input.runtime !== undefined ? { runtime: input.runtime } : {}),
|
|
217
|
+
message: input.message,
|
|
218
|
+
...(input.topic !== undefined ? { topic: input.topic } : {}),
|
|
219
|
+
...(input.attachments?.length ? { attachments: [...input.attachments] } : {}),
|
|
220
|
+
originRt: origin.rt,
|
|
221
|
+
originTs: origin.inboundTs,
|
|
222
|
+
intendedRt,
|
|
223
|
+
createdMs: now,
|
|
224
|
+
}
|
|
225
|
+
mkdirSync(pendingDir(env), { recursive: true })
|
|
226
|
+
writeFileAtomic(pendingPath(env, id), `${JSON.stringify(held, null, 2)}\n`)
|
|
227
|
+
return held
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The instructive error the sender receives INSTEAD of a delivery. Self-sufficient:
|
|
231
|
+
* it names the mismatch, both zero-regeneration exits, and the TTL. */
|
|
232
|
+
export function buildHoldNote(held: HeldSend, opts: OriginGuardOptions = {}): string {
|
|
233
|
+
const env = opts.env ?? process.env
|
|
234
|
+
const ttlMin = Math.round(pendingTtlMs(env) / 60_000)
|
|
235
|
+
return (
|
|
236
|
+
`origin-guard: "${held.personality}" last wrote to you from "${held.originRt}" ` +
|
|
237
|
+
`(${formatSentAt(new Date(held.originTs))}) and that message is not yet answered — ` +
|
|
238
|
+
`this send targets "${held.intendedRt}". Message NOT delivered; held verbatim as ${held.id} (expires in ${ttlMin} min).\n` +
|
|
239
|
+
`- cross-channel is intentional → deliver as addressed: iapeer confirm-send ${held.id}\n` +
|
|
240
|
+
`- reply to the origin channel instead: iapeer confirm-send ${held.id} --runtime ${held.originRt}\n` +
|
|
241
|
+
`Do not re-send the message text — confirm-send delivers the held message.`
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Claim a pending send for delivery — atomic rename (<id>.json → <id>.json.claimed),
|
|
246
|
+
* so a concurrent second confirm loses cleanly (ENOENT → "no pending"). TTL-checked. */
|
|
247
|
+
export function claimHeldSend(id: string, opts: OriginGuardOptions = {}): Result<HeldSend> {
|
|
248
|
+
const env = opts.env ?? process.env
|
|
249
|
+
const now = opts.nowMs ?? Date.now()
|
|
250
|
+
sweepExpiredPendings(opts)
|
|
251
|
+
const src = pendingPath(env, id)
|
|
252
|
+
const dst = claimedPath(env, id)
|
|
253
|
+
let raw: string
|
|
254
|
+
try {
|
|
255
|
+
renameSync(src, dst) // atomic claim — exactly one confirmer wins
|
|
256
|
+
raw = readFileSync(dst, 'utf8')
|
|
257
|
+
} catch {
|
|
258
|
+
return err(`no pending held send "${id}" (expired, already confirmed, or unknown) — re-send the message instead`)
|
|
259
|
+
}
|
|
260
|
+
let held: HeldSend
|
|
261
|
+
try {
|
|
262
|
+
held = JSON.parse(raw) as HeldSend
|
|
263
|
+
} catch {
|
|
264
|
+
try {
|
|
265
|
+
unlinkSync(dst)
|
|
266
|
+
} catch {
|
|
267
|
+
/* best-effort */
|
|
268
|
+
}
|
|
269
|
+
return err(`held send "${id}" is unreadable (corrupt pending file) — re-send the message instead`)
|
|
270
|
+
}
|
|
271
|
+
if (now - held.createdMs > pendingTtlMs(env)) {
|
|
272
|
+
try {
|
|
273
|
+
unlinkSync(dst)
|
|
274
|
+
} catch {
|
|
275
|
+
/* best-effort */
|
|
276
|
+
}
|
|
277
|
+
return err(`held send "${id}" expired (${Math.round(pendingTtlMs(env) / 60_000)} min TTL) — re-send the message instead`)
|
|
278
|
+
}
|
|
279
|
+
return ok(held)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Delivery failed — put the claimed pending back so confirm-send can be retried until TTL. */
|
|
283
|
+
export function restoreHeldSend(id: string, opts: OriginGuardOptions = {}): void {
|
|
284
|
+
const env = opts.env ?? process.env
|
|
285
|
+
try {
|
|
286
|
+
renameSync(claimedPath(env, id), pendingPath(env, id))
|
|
287
|
+
} catch {
|
|
288
|
+
/* best-effort */
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Delivery succeeded — drop the claimed pending. */
|
|
293
|
+
export function discardHeldSend(id: string, opts: OriginGuardOptions = {}): void {
|
|
294
|
+
const env = opts.env ?? process.env
|
|
295
|
+
try {
|
|
296
|
+
unlinkSync(claimedPath(env, id))
|
|
297
|
+
} catch {
|
|
298
|
+
/* best-effort */
|
|
299
|
+
}
|
|
300
|
+
}
|