switchroom 0.18.11 → 0.18.13

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.
Files changed (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -0,0 +1,327 @@
1
+ /**
2
+ * #3084 follow-up — THE OUTCOME TEST. The leash holds.
3
+ *
4
+ * On 2026-07-11 Telegram flood-banned the `overlord` bot token for 4.6 hours. A
5
+ * Claude Code permission prompt fired during the ban. The gateway raised the
6
+ * card, the send failed, the error was discarded — and the operator never saw
7
+ * it. Sixty minutes later (PERMISSION_TTL_DEFAULT_MS = 60 * 60_000) the TTL
8
+ * sweep would have AUTO-DENIED an approval no human had ever been shown, told
9
+ * the agent the operator declined, and skipped the #2862 missed-approval
10
+ * re-offer (which is anchored on `cards[0]` — empty, because nothing landed).
11
+ *
12
+ * We escaped it only because a human answered in tmux at ~50 minutes.
13
+ *
14
+ * An undeliverable approval must never become a verdict. Ken's call, explicit:
15
+ * HOLD, and make the block visible. Never auto-approve. Never auto-deny — not
16
+ * even on a deadline. `reference/invariants.md` § no-self-escalation, § on-leash.
17
+ *
18
+ * The scenario below is the incident: a 4h flood window, one permission
19
+ * request, and a clock advanced past the TTL.
20
+ *
21
+ * gateway.ts is not unit-importable, so this drives `approval-hold-harness.ts`,
22
+ * which composes the REAL retry policy, the REAL on-disk flood window, the REAL
23
+ * hold decisions and the REAL TTL. Only the Telegram transport and the IPC
24
+ * verdict dispatch are spies — and the verdict spy is the whole point: it is
25
+ * what proves no `deny` was ever fabricated.
26
+ *
27
+ * No fake timers — bun-compatible (see #3097).
28
+ */
29
+
30
+ import { describe, it, expect, afterEach } from 'vitest'
31
+ import { rmSync, statSync, readFileSync } from 'node:fs'
32
+ import { createHarness } from './approval-hold-harness.js'
33
+ import { PERMISSION_TTL_DEFAULT_MS } from '../gateway/permission-timeout.js'
34
+ import { shouldExpirePermission } from '../gateway/approval-hold.js'
35
+
36
+ const MINUTE = 60_000
37
+ const FOUR_HOURS = 4 * 60 * 60_000
38
+
39
+ const dirs: string[] = []
40
+ afterEach(() => {
41
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
42
+ })
43
+
44
+ function harness(opts: { targets?: string[] } = {}) {
45
+ const h = createHarness(opts)
46
+ dirs.push(h.stateDir)
47
+ return h
48
+ }
49
+
50
+ /** The incident: a 4h ban is open, and the agent asks to run a command. */
51
+ async function incident(opts: { targets?: string[] } = {}) {
52
+ const h = harness(opts)
53
+ h.banFor(FOUR_HOURS)
54
+ await h.raise('req-overlord-1', 'Bash', '{"command":"rm -rf ./build"}')
55
+ await h.settle()
56
+ return h
57
+ }
58
+
59
+ describe('an undeliverable approval is HELD, never denied', () => {
60
+ it('(a) no card is sent while the flood window is open', async () => {
61
+ const h = await incident()
62
+
63
+ expect(h.sends.length).toBe(0)
64
+ expect(h.floodRemainingMs()).toBeGreaterThan(0)
65
+ // The agent is still blocked — which is correct. It asked; nobody answered.
66
+ expect(h.pending.has('req-overlord-1')).toBe(true)
67
+ expect(h.verdicts).toEqual([])
68
+ })
69
+
70
+ it('(b) the blocked record is on disk immediately — not on a 60s sweep', async () => {
71
+ const h = await incident()
72
+
73
+ // Written synchronously in the failure handler. The operator is locked out
74
+ // of Telegram; this record is the ONLY way they learn an agent is blocked,
75
+ // so it must be live in under a second, not up to a minute.
76
+ const rec = h.blockedStore.read()
77
+ expect(rec).not.toBeNull()
78
+ expect(rec!.agent).toBe('overlord')
79
+ expect(rec!.requestId).toBe('req-overlord-1')
80
+ expect(rec!.toolName).toBe('Bash')
81
+ expect(rec!.reason).toBe('flood_wait')
82
+ expect(rec!.action).toBeTruthy()
83
+ expect(rec!.blockedSince).toBeGreaterThan(0)
84
+ expect(rec!.undeliverableSince).toBeGreaterThan(0)
85
+ // The window end, so the surface can say WHEN it comes back.
86
+ expect(rec!.retryableAt - rec!.undeliverableSince).toBeGreaterThan(3 * 60 * 60_000)
87
+
88
+ // Readable by a non-owner uid (switchroom-web runs as uid 1000 and cannot
89
+ // read the agent's 0600 state files — a 0600 record = an empty dashboard).
90
+ expect(statSync(h.blockedStore.path).mode & 0o004).toBe(0o004)
91
+
92
+ // …and it carries no raw tool input. The file is world-readable; the tool
93
+ // input here is `rm -rf ./build`, and in the general case it is an
94
+ // arbitrary command that may contain a credential.
95
+ const raw = readFileSync(h.blockedStore.path, 'utf-8')
96
+ expect(raw).not.toContain('rm -rf')
97
+ expect(raw).not.toContain('inputPreview')
98
+ expect(raw).not.toContain('input_preview')
99
+ })
100
+
101
+ // ─────────────────────────────────────────────────────────────────────────
102
+ // (c) THE LEASH. This is the assertion the incident owes.
103
+ // ─────────────────────────────────────────────────────────────────────────
104
+ it('(c) at T+61min — PAST the 60-minute TTL — NO deny verdict is ever dispatched', async () => {
105
+ const h = await incident()
106
+ expect(PERMISSION_TTL_DEFAULT_MS).toBe(60 * 60_000) // the TTL we must outlive
107
+
108
+ // Tick the reaper across the whole TTL and out the other side. On
109
+ // origin/main the sweep fires at exactly this point and auto-denies.
110
+ for (let elapsed = 0; elapsed <= 61 * MINUTE; elapsed += MINUTE) {
111
+ await h.tickSettled()
112
+ h.clock.advance(MINUTE)
113
+ }
114
+
115
+ // The agent asked. No human ever saw the ask. So no human ever answered —
116
+ // and the gateway must NOT answer on their behalf.
117
+ expect(h.verdicts).toEqual([])
118
+ expect(h.verdicts.some(v => v.behavior === 'deny')).toBe(false)
119
+
120
+ // The request is still pending: the agent stays blocked. An indefinite
121
+ // block is BY DESIGN — /pending and tmux remain the escape hatches.
122
+ expect(h.pending.has('req-overlord-1')).toBe(true)
123
+ expect(h.pending.get('req-overlord-1')?.undeliverable?.reason).toBe('flood_wait')
124
+
125
+ // And the operator can still see the block from outside Telegram.
126
+ expect(h.blockedStore.read()?.requestId).toBe('req-overlord-1')
127
+ })
128
+
129
+ it('(c-proof) the entry IS past its TTL — only the leash saves it', () => {
130
+ // Documents origin/main's behaviour WITHOUT re-implementing it. The pending
131
+ // entry is genuinely past the 60-minute TTL; the ONLY reason it survives is the
132
+ // held check inside `shouldExpirePermission`. Delete that check and (c) above
133
+ // goes RED — there is exactly one implementation, and both the gateway sweep
134
+ // and this harness call it.
135
+ const held = {
136
+ startedAt: 0,
137
+ undeliverable: { since: 0, retryableAt: 9e15, reason: 'flood_wait' as const },
138
+ }
139
+ const seen = { startedAt: 0 }
140
+ const past = PERMISSION_TTL_DEFAULT_MS + 1
141
+
142
+ // A card the operator COULD see, left unanswered past the TTL → expires. That is
143
+ // the normal, correct timeout, and it is untouched.
144
+ expect(shouldExpirePermission(seen, past, PERMISSION_TTL_DEFAULT_MS)).toBe(true)
145
+ // The SAME clock, on a card that never landed → never expires.
146
+ expect(shouldExpirePermission(held, past, PERMISSION_TTL_DEFAULT_MS)).toBe(false)
147
+ expect(shouldExpirePermission(held, 9e15, PERMISSION_TTL_DEFAULT_MS)).toBe(false)
148
+ })
149
+
150
+ it('(d) past the ban, the card lands EXACTLY once — no loss, no duplicate', async () => {
151
+ const h = await incident()
152
+
153
+ // Ride out the full 4h ban, ticking every minute as the reaper does.
154
+ for (let elapsed = 0; elapsed < FOUR_HOURS; elapsed += MINUTE) {
155
+ await h.tickSettled()
156
+ h.clock.advance(MINUTE)
157
+ }
158
+ expect(h.sends.length).toBe(0) // nothing escaped during the ban
159
+
160
+ // Window closes.
161
+ h.clock.advance(2 * MINUTE)
162
+ await h.tickSettled()
163
+
164
+ expect(h.sends.length).toBe(1)
165
+ expect(h.sends[0]!.requestId).toBe('req-overlord-1')
166
+
167
+ // Keep ticking — the card must not be re-posted, and must not now expire
168
+ // instantly against a TTL that ran while the operator could not answer.
169
+ for (let i = 0; i < 10; i++) {
170
+ h.clock.advance(MINUTE)
171
+ await h.tickSettled()
172
+ }
173
+ expect(h.sends.length).toBe(1)
174
+ expect(h.verdicts).toEqual([])
175
+ expect(h.pending.has('req-overlord-1')).toBe(true)
176
+
177
+ // We never issued a request into a window we already knew was open — the
178
+ // #3094 amplifier guard held for the whole run.
179
+ expect(h.sentIntoOpenWindow).toBe(false)
180
+ })
181
+
182
+ it('(d2) the TTL clock RESTARTS on delivery — the operator gets a full window', async () => {
183
+ const h = await incident()
184
+
185
+ h.clock.advance(FOUR_HOURS + MINUTE)
186
+ await h.tickSettled() // re-delivers
187
+ expect(h.sends.length).toBe(1)
188
+
189
+ // Without the startedAt reset, the card lands already 4h old against a
190
+ // 60-min TTL and the very next tick auto-denies it — we would have MOVED
191
+ // the silent denial, not removed it.
192
+ h.clock.advance(59 * MINUTE)
193
+ await h.tickSettled()
194
+ expect(h.verdicts).toEqual([])
195
+ expect(h.pending.has('req-overlord-1')).toBe(true)
196
+ })
197
+
198
+ it('(e) a tap on the re-delivered card resolves the ORIGINAL requestId', async () => {
199
+ const h = await incident()
200
+
201
+ h.clock.advance(FOUR_HOURS + MINUTE)
202
+ await h.tickSettled()
203
+ expect(h.sends.length).toBe(1)
204
+
205
+ // The operator finally sees the card and taps Allow.
206
+ h.tap('req-overlord-1', 'allow')
207
+
208
+ // The verdict carries the ORIGINAL request id — the id the suspended claude
209
+ // turn is blocked on. A new id would dispatch into the void and the agent
210
+ // would stay wedged forever.
211
+ expect(h.verdicts).toEqual([{ requestId: 'req-overlord-1', behavior: 'allow' }])
212
+ expect(h.pending.has('req-overlord-1')).toBe(false)
213
+ // The block is over — the surface goes quiet.
214
+ expect(h.blockedStore.read()).toBeNull()
215
+ })
216
+
217
+ // F6 — the leash hole review found. Only LONG flood-waits (>120s) raise
218
+ // FLOOD_WAIT_ACTIVE. A short-but-persistent 429, a network partition, a Telegram
219
+ // 5xx, or a full disk all produce a plain GIVE-UP — and every one of them was
220
+ // still auto-denying a card the operator never saw. Same breach as the incident,
221
+ // different first cause. The rule is about whether the operator got to answer,
222
+ // not about which fault shut the channel.
223
+ it('(c2) a NETWORK give-up is held too — the leash is not flood-specific', async () => {
224
+ const h = harness()
225
+ dirs.push(h.stateDir)
226
+ h.breakTarget('-100200', 'econnreset') // network partition, not a flood ban
227
+ await h.raise('req-net', 'Bash', '{"command":"rm -rf ./build"}')
228
+ await h.settle()
229
+
230
+ expect(h.sends.length).toBe(0)
231
+ expect(h.pending.get('req-net')?.undeliverable?.reason).toBe('transient')
232
+ expect(h.blockedStore.read()?.requestId).toBe('req-net')
233
+
234
+ // Past the 60-minute TTL — still no fabricated verdict.
235
+ for (let elapsed = 0; elapsed <= 61 * MINUTE; elapsed += MINUTE) {
236
+ await h.tickSettled()
237
+ h.clock.advance(MINUTE)
238
+ }
239
+ expect(h.verdicts).toEqual([])
240
+ expect(h.pending.has('req-net')).toBe(true)
241
+ })
242
+
243
+ it('the whole run dispatches exactly ONE verdict, and it is the operator’s', async () => {
244
+ const h = await incident()
245
+
246
+ for (let elapsed = 0; elapsed < FOUR_HOURS + 5 * MINUTE; elapsed += MINUTE) {
247
+ await h.tickSettled()
248
+ h.clock.advance(MINUTE)
249
+ }
250
+ h.tap('req-overlord-1', 'allow')
251
+
252
+ expect(h.sends.length).toBe(1)
253
+ expect(h.verdicts).toEqual([{ requestId: 'req-overlord-1', behavior: 'allow' }])
254
+ })
255
+ })
256
+
257
+ /** Source-text pins for the three gateway changes (gateway.ts isn't importable). */
258
+ describe('gateway wiring — the leash', () => {
259
+ const GATEWAY_SRC = readFileSync(
260
+ new URL('../gateway/gateway.ts', import.meta.url),
261
+ 'utf8',
262
+ )
263
+
264
+ /**
265
+ * One anchor + one span for the TTL sweep, shared by every pin below.
266
+ *
267
+ * They drifted apart before: one pin still anchored on the inline `for` loop the
268
+ * shared-sweep extraction had deleted, so it silently matched nothing. A single
269
+ * definition means a future move breaks every pin at once — loudly — instead of
270
+ * quietly disarming one of them.
271
+ *
272
+ * The span must cover the whole `onExpire` body (~4.2k chars today).
273
+ */
274
+ const SWEEP_ANCHOR = 'sweepPermissionTtl({'
275
+ const SWEEP_SPAN = 6000
276
+
277
+ it('the TTL sweep routes through the SHARED shouldExpirePermission()', () => {
278
+ // This pin is no longer the safety net — the behavioural tests above are, and
279
+ // they now drive the same `shouldExpirePermission` the gateway does, so deleting
280
+ // the leash turns them RED. This only guards the WIRING: that the sweep can't
281
+ // regrow a private inline check that drifts from what the test exercises.
282
+ const sweepStart = GATEWAY_SRC.indexOf(SWEEP_ANCHOR)
283
+ expect(sweepStart).toBeGreaterThan(-1)
284
+ const sweep = GATEWAY_SRC.slice(sweepStart, sweepStart + SWEEP_SPAN)
285
+ expect(GATEWAY_SRC).toContain('sweepPermissionTtl({')
286
+ // …and must NOT carry its own copy of the leash or the raw TTL comparison.
287
+ // …and the gateway must not regrow a private inline leash check.
288
+ expect(sweep).not.toContain('if (isHeldUndeliverable(v)) continue')
289
+ expect(sweep).not.toContain('if (now - v.startedAt > ttl)')
290
+ })
291
+
292
+ it('successful (re)delivery resets startedAt', () => {
293
+ expect(GATEWAY_SRC).toContain('live.startedAt = Date.now()')
294
+ })
295
+
296
+ it('the missed-approvals re-offer no longer drops a card that never landed', () => {
297
+ // The cards[0] hole: `origin === undefined` meant the auto-deny ALSO erased
298
+ // the request from the only record that would have re-offered it.
299
+ expect(GATEWAY_SRC).toContain('v.cards[0] ?? resolvePermissionCardTargets()[0]')
300
+ })
301
+
302
+ it('nothing in the permission path auto-approves', () => {
303
+ // Never, under any circumstance. A held approval that "times out" into an
304
+ // ALLOW would be the worst possible reading of Ken's call — the other half of
305
+ // no-self-escalation.
306
+ //
307
+ // This pin was DEAD. It anchored on `for (const [k, v] of pendingPermissions) {`,
308
+ // the inline loop that the shared-sweep extraction deleted. `indexOf` returned
309
+ // -1, `slice(-1, 2499)` returned the EMPTY STRING, and `not.toContain` passes on
310
+ // "" — so the sweep could be changed to dispatch `behavior: 'allow'` and all 13
311
+ // tests stayed green. Exactly the failure class this file exists to prevent,
312
+ // inside the file that exists to prevent it.
313
+ //
314
+ // Two rules now, and they apply to every grep pin here:
315
+ // 1. ASSERT THE ANCHOR RESOLVES. A silent -1 is what turns a pin into a lie.
316
+ // 2. Assert something POSITIVE inside the window, so a window that is too
317
+ // small (or empty) fails loudly instead of vacuously passing a `not`.
318
+ const sweepStart = GATEWAY_SRC.indexOf(SWEEP_ANCHOR)
319
+ expect(sweepStart).toBeGreaterThan(-1)
320
+ const sweep = GATEWAY_SRC.slice(sweepStart, sweepStart + SWEEP_SPAN)
321
+
322
+ // POSITIVE: proves the window actually covers the verdict dispatch.
323
+ expect(sweep).toContain("behavior: 'deny',")
324
+ // …and the sweep may only ever deny. Never allow.
325
+ expect(sweep).not.toContain("behavior: 'allow'")
326
+ })
327
+ })