@agfpd/iapeer 0.4.91 → 0.4.92

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.91",
3
+ "version": "0.4.92",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -57,6 +57,10 @@ export interface RequestApprovalDeps {
57
57
  fetch?: typeof fetch
58
58
  /** Injectable client timeout (tests). */
59
59
  timeoutMs?: number
60
+ /** External cancel: aborting this signal aborts the long-poll fetch (the daemon sees the requester
61
+ * transport close and settles the pending request cancel/default-deny, clearing it from every
62
+ * face). The supervisor uses it to close a request whose modal disappeared before a decision. */
63
+ signal?: AbortSignal
60
64
  }
61
65
 
62
66
  /**
@@ -73,6 +77,11 @@ export async function requestApproval(base: string, body: ApprovalRequestBody, d
73
77
  const controller = new AbortController()
74
78
  const timer = setTimeout(() => controller.abort(), timeoutMs)
75
79
  ;(timer as { unref?: () => void }).unref?.()
80
+ const onExternalAbort = (): void => controller.abort()
81
+ if (deps.signal) {
82
+ if (deps.signal.aborted) controller.abort()
83
+ else deps.signal.addEventListener('abort', onExternalAbort, { once: true })
84
+ }
76
85
  try {
77
86
  const headers: Record<string, string> = { 'content-type': 'application/json' }
78
87
  const bearer = env.IAPEER_BEARER_TOKEN?.trim()
@@ -88,5 +97,6 @@ export async function requestApproval(base: string, body: ApprovalRequestBody, d
88
97
  return data.decision === 'allow' ? { decision: 'allow' } : { decision: 'deny', reason: data.reason || 'denied' }
89
98
  } finally {
90
99
  clearTimeout(timer)
100
+ deps.signal?.removeEventListener('abort', onExternalAbort)
91
101
  }
92
102
  }
@@ -74,6 +74,19 @@ export interface BreakerRouteDeps {
74
74
  env: NodeJS.ProcessEnv
75
75
  /** Inject keys into the pty (child.terminal.write). */
76
76
  write: (bytes: Buffer) => void
77
+ /**
78
+ * STALE-PROOF (15.07 incident, codex-zapret2-oneclick): return true iff the SAME modal this request
79
+ * was raised for is on screen RIGHT NOW (the caller compares the live pane's nagSignature against
80
+ * the fingerprint captured at request time). Consulted (a) periodically while the long-poll is
81
+ * outstanding — a vanished modal ABORTS the request at the broker (the card clears from every face)
82
+ * — and (b) synchronously immediately before ANY key injection. Without this, a late allow/deny
83
+ * lands its keys in whatever the pty shows NOW (a NEWER live turn — a bare Escape aborted it).
84
+ * REQUIRED by design: keys must never be injected without proof the target modal is still up.
85
+ * A throw is treated as `false` (fail-closed: cannot prove the modal → do not touch the pty).
86
+ */
87
+ stillActive: () => boolean
88
+ /** Cadence of the stale re-check while the long-poll is outstanding. Default 2000 ms. */
89
+ staleCheckMs?: number
77
90
  /** Injectable fetch (tests). */
78
91
  fetch?: typeof fetch
79
92
  /** Injectable client timeout (tests). */
@@ -84,16 +97,57 @@ export interface BreakerRouteDeps {
84
97
  now?: () => number
85
98
  }
86
99
 
100
+ /** routeCircuitBreaker outcome: the decision (human / fail-safe / stale-deny) + whether keys were
101
+ * actually injected into the pty. `stale: true` = the target modal was gone, so the request was
102
+ * closed WITHOUT touching the pty — the caller must not treat it as an answered modal. */
103
+ export interface BreakerRouteResult extends ApprovalDecision {
104
+ /** True iff approve/deny keys were written to the pty. */
105
+ injected: boolean
106
+ /** True iff the request was closed because the target modal disappeared (no keys injected). */
107
+ stale?: boolean
108
+ }
109
+
87
110
  /**
88
111
  * POST the circuit-breaker to the human-approval broker, await the decision, and inject the decided
89
- * keys into the pty. Returns the decision for the caller's cooldown/in-flight bookkeeping. NEVER
90
- * throws — a broker failure fails safe to deny (decline keys injected). Every path leaves one audit
91
- * line: BREAKER-ALLOW / BREAKER-DENY with the taxonomy, the parsed detail, and (on deny) the reason.
112
+ * keys into the pty — IF AND ONLY IF the modal the request was raised for is still on screen. Returns
113
+ * the decision + whether keys were injected. NEVER throws — a broker failure fails safe to deny
114
+ * (decline keys injected, modal permitting).
115
+ *
116
+ * STALE-PROOF invariant (15.07 incident): pty bytes are written ONLY under a live re-proof of the
117
+ * target modal (`deps.stillActive`). Two checkpoints:
118
+ * 1. While the long-poll is outstanding, a poller re-proves the modal every `staleCheckMs`; the
119
+ * moment it is gone the request is ABORTED (the daemon's requester-disconnect cancel settles it
120
+ * and clears the card from every face) — the peer moved on, nobody should keep being asked.
121
+ * 2. Immediately before ANY injection (human decision, fail-safe deny alike) the modal is re-proved
122
+ * one final time — synchronously, with no yield between proof and write, so a pane update cannot
123
+ * interleave. A vanished modal → BREAKER-STALE audit line, NO pty bytes, `stale: true`.
124
+ * Every path leaves one audit line: BREAKER-ALLOW / BREAKER-DENY (keys injected) or BREAKER-STALE
125
+ * (request closed, pty untouched), each with the taxonomy, the parsed detail, and the reason/decision.
92
126
  */
93
- export async function routeCircuitBreaker(input: BreakerApprovalInput, deps: BreakerRouteDeps): Promise<ApprovalDecision> {
127
+ export async function routeCircuitBreaker(input: BreakerApprovalInput, deps: BreakerRouteDeps): Promise<BreakerRouteResult> {
94
128
  const now = deps.now ?? Date.now
95
129
  const { content, summary } = composeApprovalContent(input)
130
+ // Fail-closed re-proof: an unreadable pane model is NOT proof the modal is up → never inject on it.
131
+ const stillActive = (): boolean => {
132
+ try {
133
+ return deps.stillActive()
134
+ } catch {
135
+ return false
136
+ }
137
+ }
138
+ // Checkpoint 1 — poll the modal while the long-poll is outstanding; a vanished modal aborts the
139
+ // request at the broker (requester-disconnect → cancel → the card clears from every face).
140
+ let stale = false
141
+ const controller = new AbortController()
142
+ const stalePoll = setInterval(() => {
143
+ if (!stillActive()) {
144
+ stale = true
145
+ controller.abort()
146
+ }
147
+ }, deps.staleCheckMs ?? 2000)
148
+ ;(stalePoll as { unref?: () => void }).unref?.()
96
149
  let decision: ApprovalDecision
150
+ let lateDecision: string | undefined // a human decision that arrived for an already-gone modal
97
151
  try {
98
152
  decision = await requestApproval(
99
153
  resolveFleetBase(deps.env),
@@ -105,16 +159,30 @@ export async function routeCircuitBreaker(input: BreakerApprovalInput, deps: Bre
105
159
  content,
106
160
  summary,
107
161
  },
108
- { env: deps.env, fetch: deps.fetch, timeoutMs: deps.timeoutMs },
162
+ { env: deps.env, fetch: deps.fetch, timeoutMs: deps.timeoutMs, signal: controller.signal },
109
163
  )
164
+ lateDecision = decision.decision
110
165
  } catch (e) {
111
- // Broker unreachable / abort / non-200 → DENY (never auto-Yes). gated = fail-safe.
166
+ // Broker unreachable / abort / non-200 → DENY (never auto-Yes). gated = fail-safe. Whether the
167
+ // deny KEYS may be pressed is still gated on the modal being up (checkpoint 2 below).
112
168
  decision = { decision: 'deny', reason: `iapeer approval unavailable (${e instanceof Error ? e.message : String(e)}) — denied fail-safe` }
169
+ } finally {
170
+ clearInterval(stalePoll)
171
+ }
172
+ // Checkpoint 2 — final synchronous re-proof immediately before the injection. No yield between this
173
+ // check and the write below, so the pane model cannot change in between (single-threaded).
174
+ if (!stale && !stillActive()) stale = true
175
+ if (stale) {
176
+ deps.log?.(
177
+ `[supervisor] BREAKER-STALE ${input.taxonomy} ts=${new Date(now()).toISOString()} ${input.detail}` +
178
+ ` — modal gone before resolution${lateDecision ? ` (late human ${lateDecision} NOT injected)` : ''}; request closed, NO pty bytes`,
179
+ )
180
+ return { decision: 'deny', reason: 'stale — modal cleared before the decision; no keys injected', injected: false, stale: true }
113
181
  }
114
182
  deps.write(decision.decision === 'allow' ? input.approveBytes : input.denyBytes)
115
183
  deps.log?.(
116
184
  `[supervisor] BREAKER-${decision.decision.toUpperCase()} ${input.taxonomy} ts=${new Date(now()).toISOString()} ${input.detail}` +
117
185
  (decision.reason ? ` reason=${JSON.stringify(decision.reason)}` : ''),
118
186
  )
119
- return decision
187
+ return { ...decision, injected: true }
120
188
  }
@@ -795,6 +795,16 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
795
795
  // safe to DENY on any broker fault. For unknown-modal the broker content carries EXPLICIT button
796
796
  // semantics (Allow presses option 1 "<label>"; Deny cancels via Esc) so the human decides informed.
797
797
  brokerInFlight = true
798
+ // STALE-PROOF (15.07 incident): pin THIS modal's content signature at request time; the route
799
+ // re-proves it against the LIVE pane model before any key injection. Modal gone / replaced by a
800
+ // different one → signature mismatch → the request closes WITHOUT touching the pty (a late
801
+ // allow/deny must never land in a newer live turn). A transient model-read throw propagates —
802
+ // routeCircuitBreaker treats it as fail-closed false (cannot prove the modal → no bytes).
803
+ const requestSig = sig
804
+ const stillActive = (): boolean => {
805
+ const encNow = { appCursorKeys: xterm.modes.applicationCursorKeysMode }
806
+ return nagSignature(nextNagAction(nagAdapter, modelToPlainText(xterm, cols, rows), encNow)) === requestSig
807
+ }
798
808
  void routeCircuitBreaker(
799
809
  {
800
810
  taxonomy: action.taxonomy,
@@ -809,6 +819,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
809
819
  runtime: brokerRuntime,
810
820
  env: brokerEnv,
811
821
  write: b => child.terminal.write(b),
822
+ stillActive,
812
823
  log: line => {
813
824
  try {
814
825
  console.warn(line)