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,533 @@
1
+ /**
2
+ * Telegram-native OAuth loopback relay coverage (issue #2582).
3
+ *
4
+ * Pins the deterministic contracts that make Google/Microsoft account
5
+ * add/re-auth completable entirely over Telegram — no SSH, no keyboard:
6
+ *
7
+ * 1. Redirect-URL parser: valid / no-scheme / missing-state / missing-code /
8
+ * wrong-host / wrong-port / provider-error.
9
+ * 2. Consent-URL extraction + parse: state + loopback port pulled out;
10
+ * non-loopback redirect rejected.
11
+ * 3. startLoopbackFlow surfaces the consent URL scraped from CLI stdout.
12
+ * 4. A valid paste completes the (mocked) exchange and resolves ok.
13
+ * 5. A wrong-state paste is REJECTED and the flow stays pending (retryable).
14
+ * 6. A second paste after completion is rejected (single-use).
15
+ * 7. Verb parser: `/auth google add` / `/auth microsoft add` + flags.
16
+ *
17
+ * Outcome-asserting (not code-path-touching): each test checks the ok/reason
18
+ * the relay returns, and that pending state is or isn't cleared.
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest'
22
+ import { EventEmitter } from 'node:events'
23
+
24
+ import {
25
+ parseLoopbackRedirect,
26
+ extractConsentUrl,
27
+ parseConsentUrl,
28
+ startLoopbackFlow,
29
+ submitLoopbackRedirect,
30
+ scrubSensitive,
31
+ shouldConsumeLoopbackPaste,
32
+ looksLikeLoopbackCodePaste,
33
+ trackFlowExit,
34
+ cancelLoopbackFlow,
35
+ MAX_PASTE_ATTEMPTS,
36
+ type RelayChild,
37
+ type PendingLoopbackFlow,
38
+ } from '../gateway/auth-loopback-relay.js'
39
+ import { parseAuthCommand } from '../gateway/auth-command.js'
40
+
41
+ // A fake child process satisfying the narrow RelayChild seam. Emits stdout /
42
+ // stderr / exit on demand so tests drive the relay with no real CLI.
43
+ class FakeChild extends EventEmitter {
44
+ stdout = new EventEmitter()
45
+ stderr = new EventEmitter()
46
+ killed = false
47
+ kill(): boolean {
48
+ this.killed = true
49
+ return true
50
+ }
51
+ }
52
+
53
+ function makeChild(): FakeChild {
54
+ const c = new FakeChild()
55
+ // The relay reads child.stdout / child.stderr as ReadableStream; the
56
+ // EventEmitter satisfies the `.on('data', …)` usage.
57
+ return c
58
+ }
59
+
60
+ const STATE = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4'
61
+ const PORT = 43117
62
+ const GOOGLE_CONSENT = `https://accounts.google.com/o/oauth2/auth?client_id=x.apps.googleusercontent.com&redirect_uri=${encodeURIComponent(
63
+ `http://127.0.0.1:${PORT}`,
64
+ )}&response_type=code&scope=drive&state=${STATE}`
65
+
66
+ // ─── Redirect-URL parser ─────────────────────────────────────────────────────
67
+
68
+ describe('parseLoopbackRedirect', () => {
69
+ it('accepts a full loopback redirect with code + state', () => {
70
+ const r = parseLoopbackRedirect(
71
+ `http://127.0.0.1:${PORT}/?code=4/abc-DEF_ghi&state=${STATE}`,
72
+ )
73
+ expect(r.ok).toBe(true)
74
+ if (r.ok) {
75
+ expect(r.port).toBe(PORT)
76
+ expect(r.state).toBe(STATE)
77
+ expect(r.code).toBe('4/abc-DEF_ghi')
78
+ }
79
+ })
80
+
81
+ it('tolerates a missing scheme and surrounding whitespace', () => {
82
+ const r = parseLoopbackRedirect(` 127.0.0.1:${PORT}/?code=xyz&state=${STATE} `)
83
+ expect(r.ok).toBe(true)
84
+ if (r.ok) expect(r.host).toBe('127.0.0.1')
85
+ })
86
+
87
+ it('rejects a non-loopback host', () => {
88
+ const r = parseLoopbackRedirect(`http://evil.example/?code=xyz&state=${STATE}`)
89
+ expect(r.ok).toBe(false)
90
+ if (!r.ok) expect(r.reason).toMatch(/127\.0\.0\.1/)
91
+ })
92
+
93
+ it('rejects a missing state parameter', () => {
94
+ const r = parseLoopbackRedirect(`http://127.0.0.1:${PORT}/?code=xyz`)
95
+ expect(r.ok).toBe(false)
96
+ if (!r.ok) expect(r.reason).toMatch(/state/)
97
+ })
98
+
99
+ it('rejects a missing code parameter', () => {
100
+ const r = parseLoopbackRedirect(`http://127.0.0.1:${PORT}/?state=${STATE}`)
101
+ expect(r.ok).toBe(false)
102
+ if (!r.ok) expect(r.reason).toMatch(/code/)
103
+ })
104
+
105
+ it('surfaces a provider error param', () => {
106
+ const r = parseLoopbackRedirect(`http://127.0.0.1:${PORT}/?error=access_denied`)
107
+ expect(r.ok).toBe(false)
108
+ if (!r.ok) expect(r.reason).toMatch(/access_denied/)
109
+ })
110
+
111
+ it('rejects a missing port', () => {
112
+ const r = parseLoopbackRedirect(`http://127.0.0.1/?code=xyz&state=${STATE}`)
113
+ expect(r.ok).toBe(false)
114
+ if (!r.ok) expect(r.reason).toMatch(/port/)
115
+ })
116
+ })
117
+
118
+ // ─── Consent-URL extraction + parse ──────────────────────────────────────────
119
+
120
+ describe('extractConsentUrl + parseConsentUrl', () => {
121
+ it('pulls the Google consent URL out of noisy CLI stdout', () => {
122
+ const stdout = `Opening your browser to authorize this agent...\n ${GOOGLE_CONSENT}\n\nWaiting for browser callback...`
123
+ const url = extractConsentUrl(stdout, 'google')
124
+ expect(url).toBe(GOOGLE_CONSENT)
125
+ const parsed = parseConsentUrl(url!)
126
+ expect(parsed.state).toBe(STATE)
127
+ expect(parsed.port).toBe(PORT)
128
+ })
129
+
130
+ it('extracts a Microsoft consent URL', () => {
131
+ const ms = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?redirect_uri=${encodeURIComponent(
132
+ 'http://127.0.0.1:5000',
133
+ )}&state=deadbeefdeadbeefdeadbeefdeadbeef&client_id=y`
134
+ const url = extractConsentUrl(`visit:\n ${ms}\n`, 'microsoft')
135
+ expect(url).toBe(ms)
136
+ expect(parseConsentUrl(url!).port).toBe(5000)
137
+ })
138
+
139
+ it('returns null when no consent URL is present yet', () => {
140
+ expect(extractConsentUrl('Connecting Google account...', 'google')).toBeNull()
141
+ })
142
+
143
+ it('rejects a non-loopback redirect_uri', () => {
144
+ const bad = `https://accounts.google.com/o/oauth2/auth?redirect_uri=${encodeURIComponent(
145
+ 'https://evil.example/cb',
146
+ )}&state=${STATE}`
147
+ expect(() => parseConsentUrl(bad)).toThrow(/loopback/)
148
+ })
149
+ })
150
+
151
+ // ─── Flow lifecycle ──────────────────────────────────────────────────────────
152
+
153
+ describe('startLoopbackFlow', () => {
154
+ it('resolves with the consent URL scraped from stdout', async () => {
155
+ const child = makeChild()
156
+ const p = startLoopbackFlow('google', 'user@example.com', {
157
+ spawnImpl: () => child as unknown as RelayChild,
158
+ urlTimeoutMs: 1000,
159
+ })
160
+ // Emit the consent URL as the CLI would.
161
+ child.stdout.emit('data', Buffer.from(` ${GOOGLE_CONSENT}\n`))
162
+ const res = await p
163
+ expect(res.consentUrl).toBe(GOOGLE_CONSENT)
164
+ expect(res.state).toBe(STATE)
165
+ expect(res.port).toBe(PORT)
166
+ })
167
+
168
+ it('rejects and kills the child if it exits before a URL', async () => {
169
+ const child = makeChild()
170
+ const p = startLoopbackFlow('google', 'user@example.com', {
171
+ spawnImpl: () => child as unknown as RelayChild,
172
+ urlTimeoutMs: 1000,
173
+ })
174
+ child.emit('exit', 1)
175
+ await expect(p).rejects.toThrow(/before printing a consent URL/)
176
+ expect(child.killed).toBe(true)
177
+ })
178
+ })
179
+
180
+ // Build a pending flow around a fake child for submit tests.
181
+ function makePendingFlow(child: FakeChild): PendingLoopbackFlow {
182
+ return {
183
+ provider: 'google',
184
+ email: 'user@example.com',
185
+ state: STATE,
186
+ port: PORT,
187
+ consentUrl: GOOGLE_CONSENT,
188
+ child: child as unknown as RelayChild,
189
+ startedAt: Date.now(),
190
+ submitting: false,
191
+ attempts: 0,
192
+ }
193
+ }
194
+
195
+ describe('submitLoopbackRedirect', () => {
196
+ it('completes the mocked exchange on a valid paste (child exits 0)', async () => {
197
+ const child = makeChild()
198
+ const flow = makePendingFlow(child)
199
+ let fetched: string | null = null
200
+ const fetchImpl = (async (url: string) => {
201
+ fetched = url
202
+ // The real listener closes the socket after accepting the code, then the
203
+ // CLI registers the account and exits 0. Simulate the exit.
204
+ setTimeout(() => child.emit('exit', 0), 0)
205
+ return new Response('ok')
206
+ }) as unknown as typeof fetch
207
+
208
+ const res = await submitLoopbackRedirect(
209
+ flow,
210
+ `http://127.0.0.1:${PORT}/?code=4/secret-CODE&state=${STATE}`,
211
+ { fetchImpl, completionTimeoutMs: 1000 },
212
+ )
213
+ expect(res.ok).toBe(true)
214
+ // The code reached the loopback listener, on the right port.
215
+ expect(fetched).toContain(`127.0.0.1:${PORT}`)
216
+ expect(fetched).toContain('code=4%2Fsecret-CODE')
217
+ expect(flow.submitting).toBe(true)
218
+ })
219
+
220
+ it('rejects a wrong-state paste and keeps the flow pending (retryable)', async () => {
221
+ const child = makeChild()
222
+ const flow = makePendingFlow(child)
223
+ let fetchCalls = 0
224
+ const fetchImpl = (async () => {
225
+ fetchCalls++
226
+ return new Response('ok')
227
+ }) as unknown as typeof fetch
228
+
229
+ const res = await submitLoopbackRedirect(
230
+ flow,
231
+ `http://127.0.0.1:${PORT}/?code=xyz&state=WRONGSTATE`,
232
+ { fetchImpl },
233
+ )
234
+ expect(res.ok).toBe(false)
235
+ if (!res.ok) {
236
+ expect(res.retryable).toBe(true)
237
+ expect(res.reason).toMatch(/state/i)
238
+ }
239
+ // Never contacted the listener; flow is still usable.
240
+ expect(fetchCalls).toBe(0)
241
+ expect(flow.submitting).toBe(false)
242
+ })
243
+
244
+ it('rejects a bad paste as retryable without touching the listener', async () => {
245
+ const child = makeChild()
246
+ const flow = makePendingFlow(child)
247
+ const res = await submitLoopbackRedirect(flow, 'not a url at all', {
248
+ fetchImpl: (async () => {
249
+ throw new Error('should not be called')
250
+ }) as unknown as typeof fetch,
251
+ })
252
+ expect(res.ok).toBe(false)
253
+ if (!res.ok) expect(res.retryable).toBe(true)
254
+ expect(flow.submitting).toBe(false)
255
+ })
256
+
257
+ it('rejects a second paste after completion (single-use)', async () => {
258
+ const child = makeChild()
259
+ const flow = makePendingFlow(child)
260
+ const fetchImpl = (async () => {
261
+ setTimeout(() => child.emit('exit', 0), 0)
262
+ return new Response('ok')
263
+ }) as unknown as typeof fetch
264
+ const first = await submitLoopbackRedirect(
265
+ flow,
266
+ `http://127.0.0.1:${PORT}/?code=abc&state=${STATE}`,
267
+ { fetchImpl, completionTimeoutMs: 1000 },
268
+ )
269
+ expect(first.ok).toBe(true)
270
+ // Second valid paste on the spent flow.
271
+ const second = await submitLoopbackRedirect(
272
+ flow,
273
+ `http://127.0.0.1:${PORT}/?code=abc&state=${STATE}`,
274
+ { fetchImpl, completionTimeoutMs: 1000 },
275
+ )
276
+ expect(second.ok).toBe(false)
277
+ if (!second.ok) {
278
+ expect(second.retryable).toBe(false)
279
+ expect(second.reason).toMatch(/completed/i)
280
+ }
281
+ })
282
+ })
283
+
284
+ // ─── Consume gate (PR #3100 review finding 1) ───────────────────────────────
285
+
286
+ describe('shouldConsumeLoopbackPaste', () => {
287
+ it('does NOT consume unrelated chatter mentioning localhost', () => {
288
+ expect(shouldConsumeLoopbackPaste("what's listening on localhost?")).toBe(false)
289
+ expect(shouldConsumeLoopbackPaste('is 127.0.0.1 reachable from the container?')).toBe(false)
290
+ expect(shouldConsumeLoopbackPaste('curl http://localhost:3000/health please')).toBe(false)
291
+ })
292
+
293
+ it('consumes a valid loopback redirect carrying a code', () => {
294
+ expect(
295
+ shouldConsumeLoopbackPaste(`http://127.0.0.1:${PORT}/?code=abc&state=${STATE}`),
296
+ ).toBe(true)
297
+ // Scheme-less + whitespace variant too.
298
+ expect(
299
+ shouldConsumeLoopbackPaste(` 127.0.0.1:${PORT}/?code=abc&state=${STATE} `),
300
+ ).toBe(true)
301
+ })
302
+
303
+ it('consumes a loopback redirect carrying a provider error param', () => {
304
+ expect(
305
+ shouldConsumeLoopbackPaste(`http://127.0.0.1:${PORT}/?error=access_denied`),
306
+ ).toBe(true)
307
+ })
308
+
309
+ it('does not consume a non-loopback URL with a code', () => {
310
+ expect(
311
+ shouldConsumeLoopbackPaste(`https://evil.example/?code=abc&state=${STATE}`),
312
+ ).toBe(false)
313
+ })
314
+
315
+ // Fail-safe: malformed-but-code-bearing loopback pastes (security audit
316
+ // #3084, F2). These do NOT parse as a clean redirect, yet still carry a
317
+ // possibly-live OAuth `code` and MUST be consumed + redacted, not forwarded.
318
+ it('consumes a malformed loopback paste that still carries a code', () => {
319
+ // Dropped port.
320
+ expect(
321
+ shouldConsumeLoopbackPaste(`http://127.0.0.1/?code=abc&state=${STATE}`),
322
+ ).toBe(true)
323
+ // Missing `/` before the query.
324
+ expect(
325
+ shouldConsumeLoopbackPaste(`127.0.0.1:${PORT}?code=abc&state=${STATE}`),
326
+ ).toBe(true)
327
+ // Loopback URL buried in surrounding chatter.
328
+ expect(
329
+ shouldConsumeLoopbackPaste(`here it is: 127.0.0.1 code=abcdef123&state=${STATE} thanks`),
330
+ ).toBe(true)
331
+ // localhost variant, malformed.
332
+ expect(
333
+ shouldConsumeLoopbackPaste(`localhost blah code=xyz`),
334
+ ).toBe(true)
335
+ })
336
+ })
337
+
338
+ describe('looksLikeLoopbackCodePaste', () => {
339
+ it('is true only when BOTH a loopback host ref and a code/error param exist', () => {
340
+ expect(looksLikeLoopbackCodePaste(`127.0.0.1:${PORT}/?code=abc&state=${STATE}`)).toBe(true)
341
+ expect(looksLikeLoopbackCodePaste(`localhost/?error=access_denied`)).toBe(true)
342
+ // Loopback host but no code — ordinary chatter, must NOT match.
343
+ expect(looksLikeLoopbackCodePaste("what's listening on localhost?")).toBe(false)
344
+ expect(looksLikeLoopbackCodePaste('is 127.0.0.1 reachable from the container?')).toBe(false)
345
+ // Code but no loopback host — foreign URL, must NOT match (narrowness).
346
+ expect(looksLikeLoopbackCodePaste(`https://evil.example/?code=abc&state=${STATE}`)).toBe(false)
347
+ // Empty.
348
+ expect(looksLikeLoopbackCodePaste('')).toBe(false)
349
+ })
350
+ })
351
+
352
+ // ─── Pre-paste child exit (PR #3100 review finding 4) ───────────────────────
353
+
354
+ describe('submitLoopbackRedirect — child exited before paste', () => {
355
+ it('fails fast (non-retryable) instead of hanging out the timeout', async () => {
356
+ const child = makeChild()
357
+ const flow = makePendingFlow(child)
358
+ trackFlowExit(flow)
359
+ child.emit('exit', 1) // CLI died before the operator pasted
360
+ const started = Date.now()
361
+ const res = await submitLoopbackRedirect(
362
+ flow,
363
+ `http://127.0.0.1:${PORT}/?code=abc&state=${STATE}`,
364
+ {
365
+ fetchImpl: (async () => {
366
+ throw new Error('listener is gone — must not be called')
367
+ }) as unknown as typeof fetch,
368
+ completionTimeoutMs: 5_000,
369
+ },
370
+ )
371
+ expect(res.ok).toBe(false)
372
+ if (!res.ok) {
373
+ expect(res.retryable).toBe(false)
374
+ expect(res.reason).toMatch(/exited/i)
375
+ }
376
+ // Fast fail, not a completion-timeout wait.
377
+ expect(Date.now() - started).toBeLessThan(1_000)
378
+ })
379
+ })
380
+
381
+ // ─── Bounded paste attempts (PR #3100 review finding 5) ─────────────────────
382
+
383
+ describe('submitLoopbackRedirect — bounded attempts', () => {
384
+ it(`ends the flow (non-retryable) after ${MAX_PASTE_ATTEMPTS} rejected pastes`, async () => {
385
+ const child = makeChild()
386
+ const flow = makePendingFlow(child)
387
+ const fetchImpl = (async () => {
388
+ throw new Error('must never be called on rejected pastes')
389
+ }) as unknown as typeof fetch
390
+
391
+ for (let i = 1; i < MAX_PASTE_ATTEMPTS; i++) {
392
+ const r = await submitLoopbackRedirect(flow, 'garbage paste', { fetchImpl })
393
+ expect(r.ok).toBe(false)
394
+ if (!r.ok) expect(r.retryable).toBe(true)
395
+ }
396
+ // The Nth rejection ends the flow.
397
+ const last = await submitLoopbackRedirect(flow, 'garbage paste', { fetchImpl })
398
+ expect(last.ok).toBe(false)
399
+ if (!last.ok) {
400
+ expect(last.retryable).toBe(false)
401
+ expect(last.reason).toMatch(/ending this flow/i)
402
+ }
403
+ expect(flow.attempts).toBe(MAX_PASTE_ATTEMPTS)
404
+ })
405
+
406
+ it('attempts-exhausted child is killed by cancelLoopbackFlow (no listener leak)', async () => {
407
+ // The gateway's non-retryable branch calls cancelLoopbackFlow before
408
+ // deleting the entry — pin that the cancel actually kills a child that
409
+ // is still alive on the attempts-exhausted path (re-review finding).
410
+ const child = makeChild()
411
+ const flow = makePendingFlow(child)
412
+ const fetchImpl = (async () => {
413
+ throw new Error('never called')
414
+ }) as unknown as typeof fetch
415
+ let last: Awaited<ReturnType<typeof submitLoopbackRedirect>> | undefined
416
+ for (let i = 0; i < MAX_PASTE_ATTEMPTS; i++) {
417
+ last = await submitLoopbackRedirect(flow, 'garbage', { fetchImpl })
418
+ }
419
+ expect(last!.ok).toBe(false)
420
+ if (!last!.ok) expect(last!.retryable).toBe(false)
421
+ expect(child.killed).toBe(false) // still alive — the leak the gateway must close
422
+ cancelLoopbackFlow(flow)
423
+ expect(child.killed).toBe(true)
424
+ })
425
+
426
+ it('a valid paste after some rejections still succeeds', async () => {
427
+ const child = makeChild()
428
+ const flow = makePendingFlow(child)
429
+ const badFetch = (async () => {
430
+ throw new Error('not called')
431
+ }) as unknown as typeof fetch
432
+ await submitLoopbackRedirect(flow, 'garbage', { fetchImpl: badFetch })
433
+ await submitLoopbackRedirect(flow, 'more garbage', { fetchImpl: badFetch })
434
+ const goodFetch = (async () => {
435
+ setTimeout(() => child.emit('exit', 0), 0)
436
+ return new Response('ok')
437
+ }) as unknown as typeof fetch
438
+ const res = await submitLoopbackRedirect(
439
+ flow,
440
+ `http://127.0.0.1:${PORT}/?code=abc&state=${STATE}`,
441
+ { fetchImpl: goodFetch, completionTimeoutMs: 1000 },
442
+ )
443
+ expect(res.ok).toBe(true)
444
+ })
445
+ })
446
+
447
+ // ─── In-flight submit guard (PR #3100 review finding 2, module contract) ────
448
+
449
+ describe('submitLoopbackRedirect — submitting flag', () => {
450
+ it('a flow with submitting=true rejects non-retryably without contacting the listener', async () => {
451
+ const child = makeChild()
452
+ const flow = makePendingFlow(child)
453
+ flow.submitting = true // first submit in flight
454
+ let fetchCalls = 0
455
+ const res = await submitLoopbackRedirect(
456
+ flow,
457
+ `http://127.0.0.1:${PORT}/?code=abc&state=${STATE}`,
458
+ {
459
+ fetchImpl: (async () => {
460
+ fetchCalls++
461
+ return new Response('ok')
462
+ }) as unknown as typeof fetch,
463
+ },
464
+ )
465
+ expect(res.ok).toBe(false)
466
+ if (!res.ok) expect(res.retryable).toBe(false)
467
+ expect(fetchCalls).toBe(0)
468
+ })
469
+ })
470
+
471
+ // ─── Secret scrubbing ────────────────────────────────────────────────────────
472
+
473
+ describe('scrubSensitive', () => {
474
+ it('redacts codes, tokens, and loopback URLs', () => {
475
+ const s = scrubSensitive(
476
+ `error at http://127.0.0.1:9000/?code=4/secret&state=x with access_token=tok123`,
477
+ )
478
+ expect(s).not.toContain('4/secret')
479
+ expect(s).not.toContain('tok123')
480
+ expect(s).not.toContain('127.0.0.1:9000')
481
+ })
482
+ })
483
+
484
+ // ─── Verb parser ─────────────────────────────────────────────────────────────
485
+
486
+ describe('parseAuthCommand — provider verbs (#2582)', () => {
487
+ it('parses /auth google add <email>', () => {
488
+ const p = parseAuthCommand('/auth google add user@example.com')
489
+ expect(p).toEqual({
490
+ kind: 'provider-add',
491
+ provider: 'google',
492
+ email: 'user@example.com',
493
+ replace: false,
494
+ write: false,
495
+ orgMode: false,
496
+ })
497
+ })
498
+
499
+ it('parses google flags --replace --write', () => {
500
+ const p = parseAuthCommand('/auth google add user@example.com --replace --write')
501
+ expect(p).toMatchObject({ kind: 'provider-add', replace: true, write: true })
502
+ })
503
+
504
+ it('parses /auth microsoft add with --org-mode', () => {
505
+ const p = parseAuthCommand('/auth microsoft add user@corp.com --org-mode')
506
+ expect(p).toMatchObject({
507
+ kind: 'provider-add',
508
+ provider: 'microsoft',
509
+ orgMode: true,
510
+ })
511
+ })
512
+
513
+ it('rejects an unknown flag with help', () => {
514
+ const p = parseAuthCommand('/auth google add user@example.com --nope')
515
+ expect(p).toMatchObject({ kind: 'help' })
516
+ })
517
+
518
+ it('parses provider cancel', () => {
519
+ expect(parseAuthCommand('/auth google cancel')).toEqual({
520
+ kind: 'provider-cancel',
521
+ provider: 'google',
522
+ })
523
+ })
524
+
525
+ it('help on missing email', () => {
526
+ expect(parseAuthCommand('/auth microsoft add')).toMatchObject({ kind: 'help' })
527
+ })
528
+
529
+ it('does not treat --write as a Microsoft flag', () => {
530
+ const p = parseAuthCommand('/auth microsoft add user@corp.com --write')
531
+ expect(p).toMatchObject({ kind: 'help' })
532
+ })
533
+ })
@@ -11,7 +11,13 @@ import { tmpdir } from 'node:os'
11
11
  import { join } from 'node:path'
12
12
  import { startBootCard } from '../gateway/boot-card.js'
13
13
  import type { BotApiForBootCard } from '../gateway/boot-card.js'
14
- import { computeFloodWait, writeFloodState, floodStatePath, makeFloodWaitRecorder } from '../flood-circuit-breaker.js'
14
+ import {
15
+ computeFloodWait,
16
+ writeFloodState,
17
+ floodStatePath,
18
+ makeFloodWaitRecorder,
19
+ suppressNonEssentialSendMs,
20
+ } from '../flood-circuit-breaker.js'
15
21
  import { createRetryApiCall } from '../retry-api-call.js'
16
22
  import { errors } from './fake-bot-api.js'
17
23
 
@@ -81,9 +87,13 @@ it('POSTS normally when no flood state file exists (back-compat)', async () => {
81
87
  expect(sends).toEqual(['chat1'])
82
88
  })
83
89
 
84
- it('INTEGRATION: a 429 through the real retry path suppresses a later boot card', async () => {
90
+ it('INTEGRATION: a SHORT 429 through the real retry path suppresses a later boot card', async () => {
85
91
  // Wire the two halves exactly as gateway.ts does: the retry wrapper's
86
92
  // onFloodWait recorder and the boot card BOTH point at the same file.
93
+ //
94
+ // This is the UNDER-ceiling path (#3084): retryApiCall sleeps the wait and
95
+ // retries, exactly as it always has. The window must still be recorded, and
96
+ // a restart's boot card must still be suppressed.
87
97
  const p = floodStatePath(dir)
88
98
  const now = 5_000_000
89
99
  const retry = createRetryApiCall({
@@ -91,15 +101,51 @@ it('INTEGRATION: a 429 through the real retry path suppresses a later boot card'
91
101
  onFloodWait: makeFloodWaitRecorder(p, () => now),
92
102
  })
93
103
 
94
- // Drive a real GrammyError 429 through the wrapper (first call floods, then
95
- // succeeds) — this is what records the window on disk.
96
104
  let n = 0
97
- await retry(async () => {
98
- if (n++ === 0) throw errors.floodWait(4116) // ~68 min ban
105
+ const out = await retry(async () => {
106
+ if (n++ === 0) throw errors.floodWait(75) // the historic real-world wait
99
107
  return 'ok'
100
108
  })
109
+ expect(out).toBe('ok') // slept and retried — call still succeeded
101
110
 
102
- // Now a restart's boot card must be suppressed via the SAME file.
111
+ const { bot, sends } = makeBot()
112
+ const logs: string[] = []
113
+ const handle = await startBootCard('chat1', undefined, bot, mkOpts(p, now), undefined, (l) =>
114
+ logs.push(l),
115
+ )
116
+ expect(sends).toEqual([]) // not posted into the open ban window
117
+ expect(handle.messageId).toBe(-1)
118
+ expect(logs.join('')).toMatch(/SUPPRESSED/)
119
+ })
120
+
121
+ it('INTEGRATION: a LONG 429 that FAILS FAST still suppresses a later boot card', async () => {
122
+ // #3084 changed this path: a ~68min ban is over the in-process sleep ceiling,
123
+ // so retryApiCall no longer sleeps it — it records the window and THROWS
124
+ // FLOOD_WAIT_ACTIVE. The behaviour that matters to #2923 is unchanged and is
125
+ // what this test pins: the window is recorded BEFORE the throw, so a restart
126
+ // during the ban still suppresses its boot card instead of sending into the
127
+ // open window and extending the ban.
128
+ //
129
+ // If the recording were ever moved below the throw (or skipped on this path),
130
+ // `sends` would become ['chat1'] and this test goes red — which is exactly
131
+ // the #2923 regression it exists to catch.
132
+ const p = floodStatePath(dir)
133
+ const now = 5_000_000
134
+ const retry = createRetryApiCall({
135
+ sleep: async () => {},
136
+ onFloodWait: makeFloodWaitRecorder(p, () => now),
137
+ })
138
+
139
+ await expect(
140
+ retry(async () => {
141
+ throw errors.floodWait(4116) // ~68 min ban — over the ceiling
142
+ }),
143
+ ).rejects.toThrow('FLOOD_WAIT_ACTIVE')
144
+
145
+ // The window landed on disk despite the throw…
146
+ expect(suppressNonEssentialSendMs(p, now)).toBe(4116 * 1000)
147
+
148
+ // …so the boot card is still suppressed through the SAME file.
103
149
  const { bot, sends } = makeBot()
104
150
  const logs: string[] = []
105
151
  const handle = await startBootCard('chat1', undefined, bot, mkOpts(p, now), undefined, (l) =>
@@ -64,6 +64,7 @@ function flushDeps(claudeBusyKeys: Set<string>, claudeBusyKeySince: Map<string,
64
64
  activeDraftStreams: new Map(),
65
65
  clearActiveReactions: vi.fn(),
66
66
  disposeProgressDriver: vi.fn(),
67
+ stopTurnTypingLoops: vi.fn(), // #2650: required dep — turn-typing sweep on bridge death
67
68
  log: vi.fn(),
68
69
  }
69
70
  }