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.
- package/dist/agent-scheduler/index.js +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- 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 {
|
|
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(
|
|
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
|
-
|
|
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
|
}
|