@agfpd/iapeer 0.4.94 → 0.4.96
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/17-approval.md +62 -0
- package/docs/19-notices.md +95 -9
- package/package.json +1 -1
- package/src/daemon/mutewatch.ts +12 -6
- package/src/daemon/notices.ts +4 -2
- package/src/transport/index.ts +91 -12
package/docs/17-approval.md
CHANGED
|
@@ -146,3 +146,65 @@ The approval surface is served on both daemon listeners under `/fleet/v1`, same
|
|
|
146
146
|
## Compatibility
|
|
147
147
|
|
|
148
148
|
Additive within `/fleet/v1` (docs/15): the `approval-*` endpoints and `ev` kinds grow the surface without a version bump. A pre-approval daemon simply omits `approval_mode` from the snapshot and serves no `/approvals` endpoints; a client feature-detects by the presence of the field / a `200` from `GET /fleet/v1/approvals`.
|
|
149
|
+
|
|
150
|
+
## What an EMPTY broker does and does not mean
|
|
151
|
+
|
|
152
|
+
Diagnosed live on 15.07.2026 after an owner-visible false alarm; recorded here because the
|
|
153
|
+
misreading is natural and cost three people an incident.
|
|
154
|
+
|
|
155
|
+
**An empty `/fleet/v1/approvals` is the NORMAL state of a yolo fleet, not a symptom.** In
|
|
156
|
+
`yolo` the supervisor auto-presses Yes for the known classes (`dangerous-rm`,
|
|
157
|
+
`command-approval`) and the broker is deliberately uninvolved — there is no human decision to
|
|
158
|
+
route, so `approvals.log` correctly stays silent. Only `gated` peers ever reach the broker.
|
|
159
|
+
|
|
160
|
+
**A circuit breaker fires even under `--dangerously-skip-permissions`** — that is what makes it
|
|
161
|
+
a breaker. Seeing a modal on a bypassed peer is not evidence that the bypass failed. The live
|
|
162
|
+
sample: `Dangerous rm operation on working directory or its ancestor: /tmp/codex-probe` on a
|
|
163
|
+
peer launched with the bypass flag; the supervisor auto-confirmed 11 s later (nag-poll
|
|
164
|
+
cadence) and the tool completed 180 ms after that. Nothing hung, nothing leaked.
|
|
165
|
+
|
|
166
|
+
A useful narrowing when diagnosing one of these: under `bypassPermissions` the permission
|
|
167
|
+
engine auto-allows **every** `ask` decision except reasons beginning `Dangerous rm operation` /
|
|
168
|
+
`Dangerous rmdir operation` (established from the 2.1.201 engine). So a live modal on a
|
|
169
|
+
bypassed peer is almost certainly that one class — the "the bypass broke" hypothesis can be
|
|
170
|
+
dropped before investigating it.
|
|
171
|
+
|
|
172
|
+
**The yolo auto-confirm IS audited, but only off-surface — and that is DECIDED, not pending.**
|
|
173
|
+
The supervisor writes `[supervisor] AUTO-CONFIRM <taxonomy> identity=<id> ts=<…> <detail>` to
|
|
174
|
+
its own stderr log (`~/.iapeer/state/supervisor/<identity>.log`). That line is **not** in
|
|
175
|
+
`approvals.log`, **not** in the Fleet API, **not** in the SSE stream, and therefore invisible to
|
|
176
|
+
tray / telegram / web. Measured frequency: 9 auto-confirms across 10 days over the whole real
|
|
177
|
+
fleet (8 `dangerous-rm`, 1 `command-approval`).
|
|
178
|
+
|
|
179
|
+
> **Decision (owner, 15.07.2026): auto-confirms are NOT surfaced as notices.** Proposed after a
|
|
180
|
+
> destructive auto-approval proved invisible to every operator surface; considered, rejected.
|
|
181
|
+
> Rationale: `yolo` **means** silent auto-confirmation — the owner chose it explicitly to stop
|
|
182
|
+
> being asked, so a card per auto-confirm would be precisely the noise he opted out of. Even the
|
|
183
|
+
> narrow variant (destructive class only, post-hoc, one-way) was declined. The audit stays in the
|
|
184
|
+
> supervisor log.
|
|
185
|
+
>
|
|
186
|
+
> **The honest boundary of that decision, accepted knowingly:** observability of auto-confirms
|
|
187
|
+
> lives ONLY in `~/.iapeer/state/supervisor/<identity>.log`. You must know that file exists and
|
|
188
|
+
> that there is a reason to grep it. This is not a defect — it is the price of `yolo`, chosen
|
|
189
|
+
> deliberately. Re-proposing "let's just show them" re-opens a settled question: the counter-
|
|
190
|
+
> argument is above, and the frequency number is the reason it does not accumulate.
|
|
191
|
+
|
|
192
|
+
**The runtime's OWN notifications are outside this contour.** Claude Code pushes a native
|
|
193
|
+
"Claude needs your permission: Bash …" notification to the owner's phone when a modal appears —
|
|
194
|
+
including modals this contour auto-resolves seconds later. It is not ours (no `Notification`
|
|
195
|
+
hook exists in any settings here), we do not control it, and it is indistinguishable from a
|
|
196
|
+
real approval request. An owner-visible push therefore does NOT imply the broker saw anything.
|
|
197
|
+
Whether `preferredNotifChannel` (a real Claude Code setting) can silence this class is
|
|
198
|
+
**unverified**.
|
|
199
|
+
|
|
200
|
+
## Known unverified area: modals from a child runtime process
|
|
201
|
+
|
|
202
|
+
A peer's turn can spawn its own `claude` / `codex` child process (e.g. `claude -p …` inside a
|
|
203
|
+
Bash call). Such a child does **not** carry the peer's launch flags and its modal does **not**
|
|
204
|
+
render in the peer's pane — so the supervisor's nag-watch, the breaker path and the broker are
|
|
205
|
+
all structurally blind to it.
|
|
206
|
+
|
|
207
|
+
This is **not known to be a problem and not known to be safe: it has never been tested.** It is
|
|
208
|
+
recorded here so nobody later concludes the contour covers it. The scenario is real — it
|
|
209
|
+
occurred on this host on 15.07 during live acceptance (`claude --model fable -p ping` fired
|
|
210
|
+
from a peer's turn). Worth probing when there is a reason; not measured to date.
|
package/docs/19-notices.md
CHANGED
|
@@ -117,8 +117,8 @@ resets, the plan) — data, never our interpretation of the cause.
|
|
|
117
117
|
| `summary` | string | one line, e.g. `boris · claude — rate_limit (Fable 5)` |
|
|
118
118
|
| `content` | string | verbatim (claude) / rendered from typed fields (codex) |
|
|
119
119
|
| `sessionId` | string? | correlates with the on-disk transcript |
|
|
120
|
-
| `createdMs` | number |
|
|
121
|
-
| `lastMs` | number | latest
|
|
120
|
+
| `createdMs` | number | when the notice was raised (board clock — the TTL anchor) |
|
|
121
|
+
| `lastMs` | number | when the latest occurrence was folded in (board clock). A sweep that merely re-reads an already-counted event does not move it. |
|
|
122
122
|
| `expiresMs` | number | TTL boundary |
|
|
123
123
|
| `count` | number | distinct OCCURRENCES folded in (≥1) — how many times the peer actually hit the wall. Render as `×N`. The sweep window overlaps on purpose, so the same runtime event is re-read across passes; the board discriminates on the event's own timestamp, and a re-read never bumps this. |
|
|
124
124
|
|
|
@@ -148,6 +148,40 @@ ignore unknown `ev` kinds as always.
|
|
|
148
148
|
4. **Unknown `kind` / `errorType` ⇒ still render.** Both are growth seams.
|
|
149
149
|
5. **Do not key on `id` across daemon restarts** — the board is in-memory; a restart
|
|
150
150
|
re-detects from the on-disk evidence and issues new ids.
|
|
151
|
+
6. **On startup, seed the board SILENTLY.** Whatever is already live when your face comes up
|
|
152
|
+
is history: mark it seen and deliver none of it. Deliver only notices raised *while you
|
|
153
|
+
were running* — i.e. whose `createdMs` is later than your own start.
|
|
154
|
+
|
|
155
|
+
#### Why obligation 6 exists, and what it costs
|
|
156
|
+
|
|
157
|
+
A notice is one-way: nobody is waiting on it, nothing unblocks. So a face restart is an event
|
|
158
|
+
in the **face's** life, not news about the fleet — and re-announcing the live board because
|
|
159
|
+
*you* redeployed tells the owner nothing he did not already know. It is your deploy, buzzing
|
|
160
|
+
his phone.
|
|
161
|
+
|
|
162
|
+
This is not hypothetical: on 15.07.2026 a telegram-runtime deploy re-delivered all five live
|
|
163
|
+
cards; the web-runtime, from the same board and the same version of this document, seeded
|
|
164
|
+
silently. Neither face was wrong — **the contract was silent, so each guessed, and they
|
|
165
|
+
guessed differently.** That is what this obligation ends.
|
|
166
|
+
|
|
167
|
+
**The cost, named:** a notice raised while your face was DOWN is never delivered by you.
|
|
168
|
+
Accepted, because it is bounded and self-healing:
|
|
169
|
+
|
|
170
|
+
- if the peer recovered during your downtime, the notice was moot — there was nothing to act on;
|
|
171
|
+
- if the peer is **still** broken, the board re-raises a FRESH notice when the TTL expires
|
|
172
|
+
(§5). Its `createdMs` is then later than your start, so you deliver it. The worst case is a
|
|
173
|
+
delay of one TTL, not a permanent loss.
|
|
174
|
+
|
|
175
|
+
**Approvals are the OPPOSITE — do not copy this rule across (docs/17).** A pending approval
|
|
176
|
+
has a human on the other end and a ≤300 s default-deny deadline: a face that starts up and
|
|
177
|
+
stays quiet about it lets the request time out into a denial. So approvals *are* delivered on
|
|
178
|
+
startup. The discriminator is not which surface you are, it is **whether anything is blocked
|
|
179
|
+
waiting on a human**. Notices block nothing; approvals block a peer's tool call.
|
|
180
|
+
|
|
181
|
+
**Implementation:** record your start time at boot and compare `createdMs`. No persisted
|
|
182
|
+
seen-set is needed, and it stays correct across a daemon restart — a restarted daemon
|
|
183
|
+
re-detects from the on-disk evidence and stamps newer `createdMs`, so genuinely current
|
|
184
|
+
conditions are delivered rather than suppressed.
|
|
151
185
|
|
|
152
186
|
## 5. Dedup and TTL — one mechanism
|
|
153
187
|
|
|
@@ -176,13 +210,23 @@ The daemon sweeps on its own timer (`DEFAULT_MUTEWATCH_INTERVAL_MS`, 20 s — de
|
|
|
176
210
|
is the product here, and the 60 s supervise tick would put the worst case at the budget with
|
|
177
211
|
nothing to spare). A sweep only opens files whose mtime moved since the last pass.
|
|
178
212
|
|
|
179
|
-
|
|
180
|
-
**
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
213
|
+
Attribution does not derive the path from `cwd`. It reads the cwd each file states **about
|
|
214
|
+
itself** (claude: the `cwd` field on every line; codex: `session_meta.payload.cwd`) and matches
|
|
215
|
+
it against the registry, case-insensitively. A file whose cwd belongs to no peer is ignored —
|
|
216
|
+
a human's own session never notifies.
|
|
217
|
+
|
|
218
|
+
The reason is that one rule must serve both runtimes: the claude slug is a naming convention we
|
|
219
|
+
do not own, and a codex rollout carries no cwd in its path at all (`YYYY/MM/DD/rollout-*`), so
|
|
220
|
+
there is nothing to derive from. Reading the file's own statement works for both.
|
|
221
|
+
|
|
222
|
+
> **Correction (15.07.2026).** This section previously justified the choice by claiming the host
|
|
223
|
+
> carries BOTH `-Users-macmini-Projects-IAPeer` and `-Users-macmini-Projects-iapeer` as two
|
|
224
|
+
> directories for one case-insensitive path. **That was false.** `readdir` lists exactly one
|
|
225
|
+
> (`…-IAPeer`), and both spellings `stat` to the same inode — the filesystem is case-insensitive,
|
|
226
|
+
> so a slug of differing case resolves correctly, and the confirm path in `transport` derives its
|
|
227
|
+
> slug this way and works. The claim came from reading a `grep -c` result of 2 whose second hit was
|
|
228
|
+
> `…-Projects-iapeer-memory`: a count was trusted instead of the entries. The design choice is
|
|
229
|
+
> unchanged; its stated reason is.
|
|
186
230
|
|
|
187
231
|
## 7. What is proven, and what is not
|
|
188
232
|
|
|
@@ -199,3 +243,45 @@ ignored — a human's own session never notifies.
|
|
|
199
243
|
raw fact even if the heuristic picks wrong.
|
|
200
244
|
|
|
201
245
|
Forensic evidence for all of the above: `docs/internals/forensics/model-limit-2026-07-15/`.
|
|
246
|
+
|
|
247
|
+
## 8. Known hole: codex generic API errors — measured, not assumed
|
|
248
|
+
|
|
249
|
+
**A codex peer left mute by anything other than a rate limit is not detected.** Its
|
|
250
|
+
rate-limit path — the actual incident class — IS covered by `rate_limit_reached_type`. Mute
|
|
251
|
+
from overload or an expired auth is not.
|
|
252
|
+
|
|
253
|
+
This is a property of codex, not an omission here. Measured on 15.07.2026:
|
|
254
|
+
|
|
255
|
+
- **A real 429 leaves no trace in the rollout.** An isolated codex (`CODEX_HOME` on a temp
|
|
256
|
+
tree, a custom `model_provider` pointed at a local endpoint — the live fleet untouched) was
|
|
257
|
+
fault-injected with a genuine HTTP 429. Codex's OWN retry and error path ran and the turn
|
|
258
|
+
died: `ERROR: exceeded retry limit, last status: 429 Too Many Requests`. Its rollout
|
|
259
|
+
contains: `session_meta`, `task_started`, three `response_item/message`, `world_state`,
|
|
260
|
+
`turn_context`, `user_message`, `task_complete`. **Zero error items.**
|
|
261
|
+
- **Worse: the rollout affirmatively says the turn SUCCEEDED.** It records
|
|
262
|
+
`task_complete { last_agent_message: null }` for the turn that just died on a 429. The
|
|
263
|
+
rollout is not merely silent about the failure — for this purpose it is wrong.
|
|
264
|
+
- **Corroborated across real history**: 43 real rollouts, ~6147 `token_count` events, zero
|
|
265
|
+
error events of any kind; `TurnAbortedReason`'s variants (`interrupted`, `review_ended`,
|
|
266
|
+
`budget_limited`, `other`) contain no API-error member; codex's own `logs_2.sqlite` holds
|
|
267
|
+
exactly one ERROR row, unrelated (a model-refresh timeout).
|
|
268
|
+
|
|
269
|
+
So codex's only structural error surface is the `rate_limits` snapshot this detector already
|
|
270
|
+
reads. The claude/codex asymmetry in §2 is that fact, not a shortcut.
|
|
271
|
+
|
|
272
|
+
**Rejected, deliberately:** `task_complete` with `last_agent_message: null` is the only tell
|
|
273
|
+
that the turn failed — and it is an **absence, not a signal**. A detector on it would fire on
|
|
274
|
+
every turn that legitimately produced no agent message. Inferring "mute" from it would be the
|
|
275
|
+
exact plausible-inference defect this whole note exists to prevent; it is not built.
|
|
276
|
+
|
|
277
|
+
**Not answered:** whether `stream_error` — a real serde variant of codex's `EventMsg`, next to
|
|
278
|
+
`deprecation_notice` and `patch_apply_begin` — is persisted to the rollout. Codex filters
|
|
279
|
+
which `event_msg` kinds land there (`patch_apply_end` does, `patch_apply_begin` does not), and
|
|
280
|
+
it has never appeared in any rollout here. A mid-stream-break probe was run; codex retries a
|
|
281
|
+
broken stream with a long backoff and did not terminate inside the time box. **This is left
|
|
282
|
+
unanswered rather than inferred from the 429 result.**
|
|
283
|
+
|
|
284
|
+
**Caveat on the measurement:** the probe drove a custom `model_provider`. The real ChatGPT
|
|
285
|
+
provider path could in principle record something this one did not.
|
|
286
|
+
|
|
287
|
+
Artifacts (rollout, injector, config): `docs/internals/forensics/model-limit-2026-07-15/`.
|
package/package.json
CHANGED
package/src/daemon/mutewatch.ts
CHANGED
|
@@ -31,12 +31,18 @@
|
|
|
31
31
|
// binary but never observed live here), so an unknown future variant still detects.
|
|
32
32
|
// Codex DOES carry resets_at per window → its notice states the reset time.
|
|
33
33
|
//
|
|
34
|
-
// PATHS ARE
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
34
|
+
// PATHS ARE NOT DERIVED FROM cwd — but NOT for the reason first claimed here. The original note
|
|
35
|
+
// said this host carries BOTH `-Users-macmini-Projects-IAPeer` and `-Users-macmini-Projects-iapeer`
|
|
36
|
+
// as two directories. That was FALSE, and measured to be false on 15.07: readdir lists exactly one
|
|
37
|
+
// (`…-IAPeer`), and both spellings stat to the SAME inode — the filesystem is case-insensitive, so
|
|
38
|
+
// a slug whose case differs from the on-disk name resolves fine. (The claim came from a grep -c of
|
|
39
|
+
// 2, whose second hit was `…-Projects-iapeer-memory`. The count was read instead of the entries.)
|
|
40
|
+
//
|
|
41
|
+
// The real reason stands on its own: the slug mirrors the cwd STRING as the process was launched,
|
|
42
|
+
// which is a claude-specific naming convention we do not own, and codex has no cwd in its path at
|
|
43
|
+
// all (YYYY/MM/DD/rollout-*). One attribution rule that works for both runtimes beats two derivation
|
|
44
|
+
// schemes: scan the session files that CHANGED, read the cwd each file states about ITSELF (claude:
|
|
45
|
+
// the `cwd` field on every line; codex: session_meta.payload.cwd), and match that against the
|
|
40
46
|
// registry. A file whose cwd is no peer's is ignored — a human's own session never notifies.
|
|
41
47
|
|
|
42
48
|
import { existsSync, openSync, closeSync, fstatSync, readSync, readdirSync, statSync } from 'node:fs'
|
package/src/daemon/notices.ts
CHANGED
|
@@ -62,9 +62,11 @@ export interface Notice extends NoticeInput {
|
|
|
62
62
|
id: string
|
|
63
63
|
summary: string
|
|
64
64
|
dedupKey: string
|
|
65
|
-
/**
|
|
65
|
+
/** When the notice was raised (board clock — the TTL anchor, hence not the event's own
|
|
66
|
+
* timestamp: expiry must be measured from when the owner could first see it). */
|
|
66
67
|
createdMs: number
|
|
67
|
-
/**
|
|
68
|
+
/** When the latest OCCURRENCE was folded in (board clock). A sweep that merely re-reads an
|
|
69
|
+
* already-counted event does NOT move it — only a genuinely new occurrence does. */
|
|
68
70
|
lastMs: number
|
|
69
71
|
expiresMs: number
|
|
70
72
|
/** Distinct occurrences folded into this notice (≥1) — how many times the peer actually
|
package/src/transport/index.ts
CHANGED
|
@@ -182,11 +182,40 @@ const LIVENESS_POLL_MS = 100
|
|
|
182
182
|
// `IAP_HOST_LIVENESS_GRACE_MS` overrides (operator calibration; tests set 0 for an immediate
|
|
183
183
|
// deterministic fail). Prefer a false-FAIL (sender retries) over a false-OK (silent loss, forbidden).
|
|
184
184
|
const CONFIRM_GRACE_MS = 4000
|
|
185
|
-
|
|
186
|
-
|
|
185
|
+
|
|
186
|
+
// ATTACHMENT grace. The "sub-second" assumption above holds only WITHOUT attachments. Measured
|
|
187
|
+
// 15.07.2026 on a live att=4 delivery (1.3 MB of PNGs) into an IDLE receiver: the record was
|
|
188
|
+
// stamped 19:34:59.269, the confirm polled the right file until 19:35:03.4 and did not see it,
|
|
189
|
+
// and the receiver's first output came at 19:35:21.7. The receiver writes the user-record when it
|
|
190
|
+
// BEGINS the turn, and loading/encoding the images pushed that start ~22 s out — so a 4 s grace
|
|
191
|
+
// expired on a message that had landed. Every att>0 delivery to a claude receiver false-FAILED,
|
|
192
|
+
// the sender retried on the lie, and the message duplicated in the receiver's context.
|
|
193
|
+
//
|
|
194
|
+
// 60 s is a CONSERVATIVE BOUND FROM THAT MEASUREMENT (≤22 s observed, ×3 headroom), not a
|
|
195
|
+
// calibrated number: the exact paste→disk latency is still unmeasured (see docs/10). It costs
|
|
196
|
+
// nothing on the happy path — the confirm returns the moment the record lands — and costs the
|
|
197
|
+
// sender up to a minute only when an attachment message genuinely fails, which is rare.
|
|
198
|
+
const CONFIRM_GRACE_ATTACHMENTS_MS = 60_000
|
|
199
|
+
|
|
200
|
+
function envNonNegative(raw: string | undefined): number | null {
|
|
187
201
|
const n = raw === undefined ? NaN : Number(raw)
|
|
188
|
-
|
|
189
|
-
|
|
202
|
+
return Number.isFinite(n) && n >= 0 ? n : null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Does this envelope carry attachments — i.e. will the receiver have to ingest files before it
|
|
206
|
+
* records anything? Same marker `confirmNeedles` splits on, so the two cannot disagree. */
|
|
207
|
+
export function envelopeHasAttachments(envelope: string): boolean {
|
|
208
|
+
return envelope.includes('<attachments>') && envelope.includes('</attachments>')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function confirmGraceMs(hasAttachments: boolean): number {
|
|
212
|
+
// An EXPLICIT operator/test override wins for both modes — tests set 0 for a deterministic
|
|
213
|
+
// immediate fail, and an operator calibrating one number must not be silently overridden by
|
|
214
|
+
// the attachment branch.
|
|
215
|
+
const explicit = envNonNegative(process.env.IAP_HOST_LIVENESS_GRACE_MS)
|
|
216
|
+
if (explicit !== null) return explicit
|
|
217
|
+
if (!hasAttachments) return CONFIRM_GRACE_MS
|
|
218
|
+
return envNonNegative(process.env.IAP_HOST_LIVENESS_GRACE_ATTACHMENTS_MS) ?? CONFIRM_GRACE_ATTACHMENTS_MS
|
|
190
219
|
}
|
|
191
220
|
|
|
192
221
|
// Launchd-revive retry — a MISS on a launchd-managed target the daemon can't wake (H4)
|
|
@@ -328,7 +357,7 @@ async function deliverViaHost(
|
|
|
328
357
|
// false-OK (silent loss the contract forbids).
|
|
329
358
|
const confirm = seam.confirmLanded ?? ((payload: string) => transcriptCarriesEnvelope(baseline, payload, { env }))
|
|
330
359
|
const sleep = seam.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
|
|
331
|
-
const graceMs = confirmGraceMs()
|
|
360
|
+
const graceMs = confirmGraceMs(envelopeHasAttachments(envelope))
|
|
332
361
|
const graceDeadline = monotonicMs() + graceMs
|
|
333
362
|
while (true) {
|
|
334
363
|
if (confirm(envelope)) return ok(undefined)
|
|
@@ -574,13 +603,63 @@ export function compactTranscriptHasDone(
|
|
|
574
603
|
|
|
575
604
|
/** Deep-search every string value of a parsed JSON record for `needle` (CR-stripped on the haystack
|
|
576
605
|
* side; the needle is pre-stripped by the caller). */
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
if
|
|
580
|
-
|
|
606
|
+
/** ALL needles must be present in ONE string value — not scattered across the record. Splitting
|
|
607
|
+
* the confirm into head+tail (see confirmNeedles) only stays as forge-resistant as the whole
|
|
608
|
+
* envelope if both halves are found in the SAME string the receiver actually stored. */
|
|
609
|
+
function jsonStringValuesContainAll(value: unknown, needles: readonly string[]): boolean {
|
|
610
|
+
if (typeof value === 'string') {
|
|
611
|
+
const s = value.replace(/\r/g, '')
|
|
612
|
+
return needles.every(n => s.includes(n))
|
|
613
|
+
}
|
|
614
|
+
if (Array.isArray(value)) return value.some(v => jsonStringValuesContainAll(v, needles))
|
|
615
|
+
if (value && typeof value === 'object') return Object.values(value).some(v => jsonStringValuesContainAll(v, needles))
|
|
581
616
|
return false
|
|
582
617
|
}
|
|
583
618
|
|
|
619
|
+
/**
|
|
620
|
+
* The substrings whose presence in ONE new record proves the receiver took THIS envelope.
|
|
621
|
+
*
|
|
622
|
+
* Normally that is the whole envelope: long, unique, and a receiver's own turn does not
|
|
623
|
+
* reproduce it verbatim, so it cannot forge a confirm.
|
|
624
|
+
*
|
|
625
|
+
* WITH ATTACHMENTS the whole envelope is the wrong needle, because the receiver REWRITES it.
|
|
626
|
+
* Measured 15.07.2026 (claude receiver, 4 PNGs). Sent:
|
|
627
|
+
*
|
|
628
|
+
* <iap …>⏎Reply via send_to_peer.⏎<attachments>a.png⏎…⏎d.png</attachments>⏎<msg>…</msg>⏎</iap>
|
|
629
|
+
*
|
|
630
|
+
* Stored by the receiver:
|
|
631
|
+
*
|
|
632
|
+
* [Image #108] [Image #109]<iap …>⏎Reply via send_to_peer.⏎d.png</attachments>⏎<msg>…</msg>⏎</iap>
|
|
633
|
+
*
|
|
634
|
+
* The runtime hoists the images into separate image blocks, prepends `[Image #N]` placeholders,
|
|
635
|
+
* and eats the OPENING `<attachments>` tag plus the paths it consumed. The envelope no longer
|
|
636
|
+
* contains itself → no match → a 4 s false-FAIL on a message that DID land. The sender then
|
|
637
|
+
* retries and duplicates it in the receiver's context (three times, live, before this fix).
|
|
638
|
+
*
|
|
639
|
+
* The mutation is structurally CONFINED to the attachments region: the `<iap …>` header and the
|
|
640
|
+
* whole `<msg>…</msg></iap>` tail were byte-intact in the stored record. So we match around the
|
|
641
|
+
* damage — head + tail, both required in the same string value.
|
|
642
|
+
*
|
|
643
|
+
* NOT a short unique token in the header, which is the tempting fix: a short, predictable needle
|
|
644
|
+
* is one the receiver can reproduce in its OWN turn (agents quote IAP headers when reporting),
|
|
645
|
+
* and that forges a confirm. A false-OK is silent loss — the one outcome this contract forbids —
|
|
646
|
+
* while a false-FAIL merely costs a retry. Head+tail keeps the original forge-resistance bar:
|
|
647
|
+
* to fake it the receiver must echo both the header and the entire body verbatim.
|
|
648
|
+
*/
|
|
649
|
+
export function confirmNeedles(envelope: string): string[] {
|
|
650
|
+
const n = envelope.replace(/\r/g, '')
|
|
651
|
+
const OPEN = '<attachments>'
|
|
652
|
+
const CLOSE = '</attachments>'
|
|
653
|
+
const open = n.indexOf(OPEN)
|
|
654
|
+
if (open < 0) return [n] // no attachments → nothing rewrites the envelope → strongest needle
|
|
655
|
+
const close = n.indexOf(CLOSE, open + OPEN.length)
|
|
656
|
+
if (close < 0) return [n] // malformed → fall back to the whole envelope rather than guess
|
|
657
|
+
const head = n.slice(0, open).trimEnd()
|
|
658
|
+
const tail = n.slice(close + CLOSE.length).trimStart()
|
|
659
|
+
const parts = [head, tail].filter(p => p.length > 0)
|
|
660
|
+
return parts.length ? parts : [n]
|
|
661
|
+
}
|
|
662
|
+
|
|
584
663
|
/**
|
|
585
664
|
* Did a NEW transcript/session-jsonl record CARRYING `envelope` appear since `baseline` (the
|
|
586
665
|
* per-runtime absolute file paths + SIZES captured immediately before delivery)? This is the
|
|
@@ -618,8 +697,8 @@ export function transcriptCarriesEnvelope(
|
|
|
618
697
|
envelope: string,
|
|
619
698
|
opts: { env?: NodeJS.ProcessEnv } = {},
|
|
620
699
|
): boolean {
|
|
621
|
-
const
|
|
622
|
-
if (!
|
|
700
|
+
const needles = confirmNeedles(envelope)
|
|
701
|
+
if (!needles.length || needles.every(n => !n)) return false
|
|
623
702
|
// Baseline files at their pre-deliver offset; a file NOT in the baseline (born after it) is read from 0.
|
|
624
703
|
const offsets = new Map(baseline.files.map(f => [f.path, f.size]))
|
|
625
704
|
for (const path of compactCandidateFiles(baseline.runtime, baseline.cwd, opts.env)) {
|
|
@@ -639,7 +718,7 @@ export function transcriptCarriesEnvelope(
|
|
|
639
718
|
} catch {
|
|
640
719
|
continue
|
|
641
720
|
}
|
|
642
|
-
if (
|
|
721
|
+
if (jsonStringValuesContainAll(obj, needles)) return true
|
|
643
722
|
}
|
|
644
723
|
}
|
|
645
724
|
return false
|