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,224 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ readFloodWindows,
7
+ writeFloodWindow,
8
+ makeFloodWindowRecorder,
9
+ loadInitialFloodWindows,
10
+ floodWindowsPath,
11
+ floodStatePath,
12
+ writeFloodState,
13
+ computeFloodWait,
14
+ FLOOD_STATE_MODE,
15
+ FLOOD_WINDOWS_CORRUPT_SUPPRESS_MS,
16
+ } from '../flood-circuit-breaker.js'
17
+ import { createSendGate, type Clock } from '../send-gate.js'
18
+
19
+ /**
20
+ * #3084 PR 2 — restart-proof SCOPED flood windows (part3-design §7). Verifies
21
+ * the write-through/round-trip contract on real temp files (isolated per the
22
+ * repo's vault/shared-state test discipline) and that a gate reconstructed from
23
+ * disk is STILL blocked — the whole point of §7 (a boot mid-ban must not resend
24
+ * into the open window).
25
+ */
26
+ describe('#3084 scoped flood-window persistence', () => {
27
+ let dir: string
28
+ beforeEach(() => {
29
+ dir = mkdtempSync(join(tmpdir(), 'flood-win-'))
30
+ })
31
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
32
+
33
+ it('round-trips a scoped window and prunes expired entries', () => {
34
+ const path = floodWindowsPath(dir)
35
+ const now = 1_000_000
36
+ writeFloodWindow(path, { scopeKey: 'chat:5', untilTs: now + 60_000, retryAfterSrc: '429', observedAt: now }, now)
37
+ writeFloodWindow(path, { scopeKey: 'global', untilTs: now + 10_000, retryAfterSrc: '429', observedAt: now }, now)
38
+
39
+ // Before either expires: both present.
40
+ const live = readFloodWindows(path, now + 5_000)
41
+ expect(live.map((w) => w.scopeKey).sort()).toEqual(['chat:5', 'global'])
42
+
43
+ // After the global one expires: only chat:5 survives (prune-on-read).
44
+ const later = readFloodWindows(path, now + 20_000)
45
+ expect(later.map((w) => w.scopeKey)).toEqual(['chat:5'])
46
+ })
47
+
48
+ it('EXTENDS never shortens a window on a later, shorter re-record (boot-never-shortens)', () => {
49
+ const path = floodWindowsPath(dir)
50
+ const now = 1_000_000
51
+ // A long ban is recorded.
52
+ writeFloodWindow(path, { scopeKey: 'global', untilTs: now + 600_000, retryAfterSrc: '429', observedAt: now }, now)
53
+ // A later, SHORTER 429 (or a restart re-observing a smaller retry_after)
54
+ // must not pull the expiry earlier.
55
+ writeFloodWindow(
56
+ path,
57
+ { scopeKey: 'global', untilTs: now + 1_000 + 5_000, retryAfterSrc: '429', observedAt: now + 1_000 },
58
+ now + 1_000,
59
+ )
60
+ const w = readFloodWindows(path, now + 2_000).find((x) => x.scopeKey === 'global')
61
+ expect(w?.untilTs).toBe(now + 600_000)
62
+ })
63
+
64
+ it('loadInitialFloodWindows combines the global flood-wait.json window with scoped windows', () => {
65
+ const now = 1_000_000
66
+ const statePath = floodStatePath(dir)
67
+ const winPath = floodWindowsPath(dir)
68
+ writeFloodState(statePath, computeFloodWait(null, 300, now)) // global, +300s
69
+ writeFloodWindow(winPath, { scopeKey: 'chat:9', untilTs: now + 120_000, retryAfterSrc: '429', observedAt: now }, now)
70
+
71
+ const initial = loadInitialFloodWindows(statePath, winPath, now)
72
+ const scopes = initial.map((w) => w.scopeKey).sort()
73
+ expect(scopes).toEqual(['chat:9', 'global'])
74
+ expect(initial.find((w) => w.scopeKey === 'global')?.untilTs).toBe(now + 300_000)
75
+ })
76
+
77
+ it('gate opens a window on a 429 → persists → a NEW gate built from disk is still blocked', async () => {
78
+ const winPath = floodWindowsPath(dir)
79
+ const statePath = floodStatePath(dir)
80
+ const recorder = makeFloodWindowRecorder(winPath, () => 0)
81
+
82
+ // Gate 1: a critical send hits a 429 (FLOOD_WAIT_ACTIVE) which opens +
83
+ // persists scope windows via onWindowOpen.
84
+ const clock1: Clock = { now: () => 0, sleep: () => Promise.resolve() }
85
+ const gate1 = createSendGate({
86
+ enabled: true,
87
+ clock: clock1,
88
+ onWindowOpen: (scopeKey, untilTs) => recorder(scopeKey, untilTs),
89
+ })
90
+ const floodErr = Object.assign(new Error('FLOOD_WAIT_ACTIVE'), {
91
+ retryAfterSec: 600,
92
+ untilTs: 600_000,
93
+ error_code: 429 as const,
94
+ parameters: { retry_after: 600 },
95
+ original: null,
96
+ })
97
+ await expect(
98
+ gate1.gate(
99
+ async () => {
100
+ throw floodErr
101
+ },
102
+ { chat_id: '7', priorityClass: 'critical' },
103
+ ),
104
+ ).rejects.toBe(floodErr)
105
+
106
+ // The window is on disk.
107
+ const persisted = readFloodWindows(winPath, 0)
108
+ expect(persisted.map((w) => w.scopeKey).sort()).toEqual(['chat:7', 'global'])
109
+
110
+ // Gate 2 (simulated restart): built BEFORE any outbound call from the
111
+ // persisted windows. A cosmetic send on chat:7 must still shed.
112
+ const clock2: Clock = { now: () => 0, sleep: () => Promise.resolve() }
113
+ const gate2 = createSendGate({
114
+ enabled: true,
115
+ clock: clock2,
116
+ initialWindows: loadInitialFloodWindows(statePath, winPath, 0),
117
+ })
118
+ const res = await gate2.gate(async () => 'should-not-run', {
119
+ chat_id: '7',
120
+ priorityClass: 'cosmetic',
121
+ })
122
+ expect(res).toBeUndefined()
123
+ expect(gate2.stats().global.shed).toBe(1)
124
+
125
+ // And a critical into the still-long window fails fast — restart did not
126
+ // reset the ban.
127
+ await expect(
128
+ gate2.gate(async () => 'nope', { chat_id: '7', priorityClass: 'critical' }),
129
+ ).rejects.toThrow('FLOOD_WAIT_ACTIVE')
130
+ })
131
+
132
+ it('H2: writes flood-windows.json world-READABLE (0o644), not 0o600', () => {
133
+ // A 0o600 marker is unreadable to a gateway running under a DIFFERENT uid
134
+ // (root vs agent uid share the state dir), which silently defeats the
135
+ // restart-proof windows — a restart under the other uid boots blind and
136
+ // resends into the ban. Must match `flood-wait.json` (FLOOD_STATE_MODE).
137
+ const path = floodWindowsPath(dir)
138
+ const now = 2_000_000
139
+ writeFloodWindow(path, { scopeKey: 'global', untilTs: now + 60_000, retryAfterSrc: '429', observedAt: now }, now)
140
+ expect(statSync(path).mode & 0o777).toBe(FLOOD_STATE_MODE)
141
+ expect(FLOOD_STATE_MODE).toBe(0o644)
142
+ })
143
+
144
+ it('M1: a CORRUPT flood-windows.json fails SAFE (conservative global window), not open', () => {
145
+ const path = floodWindowsPath(dir)
146
+ const now = 3_000_000
147
+ writeFileSync(path, '{ this is not valid json ][', 'utf-8')
148
+ const logs: string[] = []
149
+ const windows = readFloodWindows(path, now, (l) => logs.push(l))
150
+ // NOT [] (fail-open) — a bounded conservative global window instead.
151
+ expect(windows).toHaveLength(1)
152
+ expect(windows[0]!.scopeKey).toBe('global')
153
+ expect(windows[0]!.untilTs).toBe(now + FLOOD_WINDOWS_CORRUPT_SUPPRESS_MS)
154
+ // Logged loudly.
155
+ expect(logs.join('')).toMatch(/failing SAFE/)
156
+ })
157
+
158
+ it('M1: a non-array flood-windows.json fails SAFE', () => {
159
+ const path = floodWindowsPath(dir)
160
+ const now = 3_500_000
161
+ writeFileSync(path, '{"scopeKey":"global"}', 'utf-8') // object, not array
162
+ const windows = readFloodWindows(path, now, () => {})
163
+ expect(windows).toEqual([
164
+ {
165
+ scopeKey: 'global',
166
+ untilTs: now + FLOOD_WINDOWS_CORRUPT_SUPPRESS_MS,
167
+ retryAfterSrc: expect.stringContaining('failsafe'),
168
+ observedAt: now,
169
+ },
170
+ ])
171
+ })
172
+
173
+ it('M1: an UNREADABLE flood-windows.json (read error, not ENOENT) fails SAFE', () => {
174
+ // Root bypasses file-mode EACCES, so simulate a non-ENOENT read failure
175
+ // deterministically by making the windows path a DIRECTORY — readFileSync
176
+ // throws EISDIR, the same "file exists but I can't read it" class as EACCES.
177
+ const path = floodWindowsPath(dir)
178
+ const now = 4_000_000
179
+ mkdirSync(path)
180
+ const logs: string[] = []
181
+ const windows = readFloodWindows(path, now, (l) => logs.push(l))
182
+ expect(windows).toHaveLength(1)
183
+ expect(windows[0]!.scopeKey).toBe('global')
184
+ expect(windows[0]!.untilTs).toBe(now + FLOOD_WINDOWS_CORRUPT_SUPPRESS_MS)
185
+ expect(logs.join('')).toMatch(/unreadable/)
186
+ })
187
+
188
+ it('M1: a genuinely ABSENT file is still "no windows" (ENOENT ≠ corrupt)', () => {
189
+ const path = floodWindowsPath(dir) // never written
190
+ expect(readFloodWindows(path, 5_000_000, () => {})).toEqual([])
191
+ })
192
+
193
+ it('M3: flag-OFF gate opens NO window and writes NO flood-windows.json', () => {
194
+ // The gateway wires onFloodWait → openFloodWindow('global', …)
195
+ // unconditionally; with the flag OFF that must be a pure no-op — no in-memory
196
+ // suppression, no fs side effect (a poison-perms file created before the
197
+ // feature is ever enabled).
198
+ const path = floodWindowsPath(dir)
199
+ const recorder = makeFloodWindowRecorder(path, () => 0)
200
+ const clock: Clock = { now: () => 0, sleep: () => Promise.resolve() }
201
+ const gate = createSendGate({
202
+ enabled: false,
203
+ clock,
204
+ onWindowOpen: (scopeKey, untilTs) => recorder(scopeKey, untilTs),
205
+ })
206
+ gate.openFloodWindow('global', 600_000)
207
+ expect(existsSync(path)).toBe(false)
208
+ })
209
+
210
+ it('a boot loading initial windows never shortens an on-disk window', async () => {
211
+ const clock: Clock = { now: () => 0, sleep: () => Promise.resolve() }
212
+ // Boot with a long window applied at construction.
213
+ const gate = createSendGate({
214
+ enabled: true,
215
+ clock,
216
+ initialWindows: [{ scopeKey: 'global', untilTs: 600_000 }],
217
+ })
218
+ // Re-opening the SAME scope with a shorter untilTs must not shorten it.
219
+ gate.openFloodWindow('global', 10_000)
220
+ // A critical still fails fast against the ORIGINAL long window.
221
+ const e = await gate.gate(async () => 'x', { priorityClass: 'critical' }).catch((err) => err)
222
+ expect((e as { untilTs: number }).untilTs).toBe(600_000)
223
+ })
224
+ })
@@ -43,7 +43,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
43
43
  // window to ~5KB so we don't pick up the unrelated
44
44
  // `removeTurnActiveMarker` call in the onTurnComplete handler
45
45
  // way down at the bottom of the file.
46
- const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 9000)
46
+ const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 12000)
47
47
  const clearMatches = setupWindow.match(/removeTurnActiveMarker\s*\(\s*STATE_DIR\s*\)/g) ?? []
48
48
  expect(clearMatches.length).toBe(1)
49
49
  })
@@ -53,7 +53,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
53
53
  // must not prevent the gateway from coming up. Pin the try-wrapping
54
54
  // to make sure a future refactor doesn't drop it.
55
55
  const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
56
- const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 9000)
56
+ const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 12000)
57
57
  expect(setupWindow).toMatch(/try\s*\{\s*removeTurnActiveMarker\(STATE_DIR\)\s*\}\s*catch/)
58
58
  })
59
59
 
@@ -62,7 +62,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
62
62
  // Clearing the marker first means the watchdog sees a clean slate
63
63
  // immediately, even if pin sweep takes 30s+ to finish.
64
64
  const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
65
- const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 9000)
65
+ const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 12000)
66
66
  const clearIdx = setupWindow.indexOf('removeTurnActiveMarker(STATE_DIR)')
67
67
  const pinSweepIdx = setupWindow.indexOf('Boot-time pin sweep')
68
68
  expect(clearIdx).toBeGreaterThan(0)
@@ -36,6 +36,7 @@ function makeDeps(agentName: string | null) {
36
36
  const finalizeB = vi.fn(async () => {})
37
37
  const clearActiveReactions = vi.fn()
38
38
  const disposeProgressDriver = vi.fn()
39
+ const stopTurnTypingLoops = vi.fn()
39
40
  const log = vi.fn()
40
41
 
41
42
  const activeStatusReactions = new Map<string, FakeCtrl>([
@@ -72,7 +73,7 @@ function makeDeps(agentName: string | null) {
72
73
  // carry or clear it.
73
74
 
74
75
  return {
75
- spies: { setDoneA, setDoneB, finalizeA, finalizeB, clearActiveReactions, disposeProgressDriver, log },
76
+ spies: { setDoneA, setDoneB, finalizeA, finalizeB, clearActiveReactions, disposeProgressDriver, stopTurnTypingLoops, log },
76
77
  deps: {
77
78
  agentName,
78
79
  activeStatusReactions,
@@ -83,6 +84,7 @@ function makeDeps(agentName: string | null) {
83
84
  activeDraftStreams,
84
85
  clearActiveReactions,
85
86
  disposeProgressDriver,
87
+ stopTurnTypingLoops,
86
88
  log,
87
89
  },
88
90
  }
@@ -121,6 +123,16 @@ describe('flushOnAgentDisconnect — anonymous clients (the regression scenario)
121
123
  expect(spies.setDoneA).toHaveBeenCalledTimes(0)
122
124
  expect(spies.setDoneB).toHaveBeenCalledTimes(0)
123
125
  })
126
+
127
+ it('#2650: does NOT sweep the turn-typing loops for anonymous disconnects', () => {
128
+ // An anonymous one-shot IPC client (recall.py, etc.) never owned a
129
+ // turn-typing loop. The sweep MUST be gated by the agentName-null
130
+ // early-return so a constant stream of anonymous disconnects can't
131
+ // kill a live agent's typing indicator.
132
+ const { spies, deps } = makeDeps(null)
133
+ flushOnAgentDisconnect(deps)
134
+ expect(spies.stopTurnTypingLoops).not.toHaveBeenCalled()
135
+ })
124
136
  })
125
137
 
126
138
  describe('flushOnAgentDisconnect — registered agent disconnects (existing behavior preserved)', () => {
@@ -144,6 +156,15 @@ describe('flushOnAgentDisconnect — registered agent disconnects (existing beha
144
156
  expect(spies.clearActiveReactions).toHaveBeenCalledTimes(1)
145
157
  })
146
158
 
159
+ it('#2650: sweeps the turn-typing loops on a real agent disconnect', () => {
160
+ // The bridge died mid-turn — the canonical turn-end that would stop the
161
+ // per-(chat,thread) `typing…` interval will never arrive, so the flush
162
+ // must sweep every live loop or a stale "typing…" shows until next turn.
163
+ const { spies, deps } = makeDeps('clerk')
164
+ flushOnAgentDisconnect(deps)
165
+ expect(spies.stopTurnTypingLoops).toHaveBeenCalledTimes(1)
166
+ })
167
+
147
168
  it('finalizes only non-final draft streams and clears the maps', () => {
148
169
  const { spies, deps } = makeDeps('clerk')
149
170
  flushOnAgentDisconnect(deps)
@@ -172,6 +193,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
172
193
  const onDanglingTurnsSwept = vi.fn()
173
194
  const clearActiveReactions = vi.fn()
174
195
  const disposeProgressDriver = vi.fn()
196
+ const stopTurnTypingLoops = vi.fn()
175
197
  const log = vi.fn()
176
198
  const deps = {
177
199
  agentName: 'clerk',
@@ -185,6 +207,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
185
207
  activeDraftStreams: new Map<string, FakeStream>(),
186
208
  clearActiveReactions,
187
209
  disposeProgressDriver,
210
+ stopTurnTypingLoops,
188
211
  onDanglingTurnsSwept,
189
212
  log,
190
213
  }
@@ -243,6 +266,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
243
266
  activeDraftStreams: new Map<string, FakeStream>(),
244
267
  clearActiveReactions: vi.fn(),
245
268
  disposeProgressDriver: vi.fn(),
269
+ stopTurnTypingLoops: vi.fn(),
246
270
  onDanglingTurnsSwept,
247
271
  log: vi.fn(),
248
272
  }
@@ -278,6 +302,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
278
302
  activeDraftStreams: new Map<string, FakeStream>(),
279
303
  clearActiveReactions: vi.fn(),
280
304
  disposeProgressDriver: vi.fn(),
305
+ stopTurnTypingLoops: vi.fn(),
281
306
  onDanglingTurnsSwept,
282
307
  log,
283
308
  }
@@ -310,6 +335,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
310
335
  activeDraftStreams: new Map<string, FakeStream>(),
311
336
  clearActiveReactions: vi.fn(),
312
337
  disposeProgressDriver: vi.fn(),
338
+ stopTurnTypingLoops: vi.fn(),
313
339
  }
314
340
  // Singular form.
315
341
  flushOnAgentDisconnect({
@@ -348,6 +374,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
348
374
  activeDraftStreams: new Map<string, FakeStream>(),
349
375
  clearActiveReactions: vi.fn(),
350
376
  disposeProgressDriver: vi.fn(),
377
+ stopTurnTypingLoops: vi.fn(),
351
378
  log,
352
379
  })
353
380
  expect(log.mock.calls.some((c: unknown[]) =>
@@ -368,6 +395,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
368
395
  activeDraftStreams: new Map<string, FakeStream>(),
369
396
  clearActiveReactions: vi.fn(),
370
397
  disposeProgressDriver: vi.fn(),
398
+ stopTurnTypingLoops: vi.fn(),
371
399
  // onDanglingTurnsSwept intentionally omitted.
372
400
  log: vi.fn(),
373
401
  }
@@ -0,0 +1,66 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+
4
+ /**
5
+ * Structural integration test for the loopback-paste fail-safe redaction in
6
+ * the gateway's inbound handler (security audit #3084, F2/F3).
7
+ *
8
+ * Why structural: gateway/gateway.ts does not export its inbound handler, so a
9
+ * pure-functional invocation would require end-to-end harnessing of
10
+ * Bot/Grammy/Context. The decision predicate (shouldConsumeLoopbackPaste, incl.
11
+ * the malformed-paste fail-safe) is exhaustively unit-tested in
12
+ * `auth-loopback-relay.test.ts`, and the redaction primitive
13
+ * (redactAuthCodeMessage) in `auth-code-redact.test.ts`. What's left to assert
14
+ * here is that the gateway actually WIRES a fail-safe so a code-bearing paste
15
+ * that is malformed, or arrives just after the intercept TTL, or has no active
16
+ * flow, is redacted + dropped rather than forwarded to the agent.
17
+ *
18
+ * The invariants:
19
+ * 1. The stale (TTL-expired) loopback branch does NOT let the paste fall
20
+ * through to the agent — it flows into the fail-safe below.
21
+ * 2. A fail-safe `if (shouldConsumeLoopbackPaste(text))` block exists that
22
+ * redacts (redactAuthCodeMessage) and returns, sitting AFTER the
23
+ * pending-flow branch but BEFORE the generic auth-code / agent-forward
24
+ * handlers.
25
+ */
26
+ describe('gateway loopback-paste fail-safe redaction — structural wiring', () => {
27
+ const src = readFileSync(
28
+ new URL('../gateway/gateway.ts', import.meta.url),
29
+ 'utf8',
30
+ )
31
+
32
+ it('imports the consume predicate + redaction helper', () => {
33
+ expect(src).toMatch(/shouldConsumeLoopbackPaste/)
34
+ expect(src).toMatch(/redactAuthCodeMessage/)
35
+ })
36
+
37
+ it('has a fail-safe redact-and-return block guarded by shouldConsumeLoopbackPaste', () => {
38
+ // Isolate the fail-safe block: `if (shouldConsumeLoopbackPaste(text)) { ... }`
39
+ // that is NOT part of the `pendingLoop && ...` pending-flow branch.
40
+ const failSafe = src.match(
41
+ /if \(shouldConsumeLoopbackPaste\(text\)\) \{[\s\S]{0,600}?\n {2}\}/g,
42
+ )
43
+ expect(failSafe).not.toBeNull()
44
+ // The standalone fail-safe (the one NOT preceded by `pendingLoop &&`).
45
+ const standalone = (failSafe ?? []).find(
46
+ (b) => !b.startsWith('if (pendingLoop'),
47
+ )
48
+ expect(standalone).toBeDefined()
49
+ expect(standalone!).toMatch(/redactAuthCodeMessage/)
50
+ expect(standalone!).toMatch(/return/)
51
+ })
52
+
53
+ it('the stale loopback branch falls through to the fail-safe (does not forward)', () => {
54
+ // The stale-path comment must reference the fail-safe, and the fail-safe
55
+ // block must appear AFTER the pending-loop branch and BEFORE the generic
56
+ // auth-code intercept (`pendingReauthFlows`).
57
+ const failSafeIdx = src.indexOf(
58
+ 'Fail-safe redaction (security audit #3084, F2/F3)',
59
+ )
60
+ const pendingLoopIdx = src.indexOf('const pendingLoop = pendingLoopbackFlows.get')
61
+ const reauthIdx = src.indexOf('const pendingReauth = pendingReauthFlows.get')
62
+ expect(failSafeIdx).toBeGreaterThan(-1)
63
+ expect(failSafeIdx).toBeGreaterThan(pendingLoopIdx)
64
+ expect(reauthIdx).toBeGreaterThan(failSafeIdx)
65
+ })
66
+ })
@@ -77,6 +77,63 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
77
77
  expect(truncIdx).toBeGreaterThan(redactIdx) // mask BEFORE the slice
78
78
  })
79
79
 
80
+ it('ask_user: redacts question + option labels at entry, BEFORE the send', () => {
81
+ // F1 — ask_user sent the question + button labels unredacted. The scrub
82
+ // must run at the top of executeAskUser (via redactAskUserFields), before
83
+ // the keyboard is built and the question is sent to Telegram.
84
+ const start = src.indexOf('async function executeAskUser(')
85
+ const redactIdx = src.indexOf('redactAskUserFields(args.question, args.options', start)
86
+ const keyboardIdx = src.indexOf('const keyboard = new InlineKeyboard()', start)
87
+ const sendIdx = src.indexOf('sendRichMessage(args.chatId, richMessage(args.question)', start)
88
+ expect(start).toBeGreaterThan(0)
89
+ expect(redactIdx).toBeGreaterThan(start)
90
+ expect(keyboardIdx).toBeGreaterThan(redactIdx) // mask BEFORE labels become buttons
91
+ expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the question is sent
92
+ })
93
+
94
+ it('send_checklist: redacts title + task text BEFORE rawSendChecklist', () => {
95
+ // F2 — checklist title + task strings were sent unredacted.
96
+ const start = src.indexOf('async function executeSendChecklist(')
97
+ const redactIdx = src.indexOf('redactChecklistFields(', start)
98
+ const sendIdx = src.indexOf('rawSendChecklist({', start)
99
+ expect(start).toBeGreaterThan(0)
100
+ expect(redactIdx).toBeGreaterThan(start)
101
+ expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the send
102
+ })
103
+
104
+ it('update_checklist: redacts title + task text BEFORE rawEditMessageChecklist', () => {
105
+ // F2 sibling — update_checklist shares the identical leak class.
106
+ const start = src.indexOf('async function executeUpdateChecklist(')
107
+ const redactIdx = src.indexOf('redactChecklistFields(', start)
108
+ const editIdx = src.indexOf('rawEditMessageChecklist({', start)
109
+ expect(start).toBeGreaterThan(0)
110
+ expect(redactIdx).toBeGreaterThan(start)
111
+ expect(editIdx).toBeGreaterThan(redactIdx) // mask BEFORE the edit
112
+ })
113
+
114
+ it('pty draft-preview: redacts at the gateway boundary BEFORE the stream', () => {
115
+ // F3 — the PTY-tail partial (assistant reply text extracted from the TUI)
116
+ // forwards straight to the draft-preview stream. Mask at the top of
117
+ // handlePtyPartial, before it hands off to handlePtyPartialPure.
118
+ const start = src.indexOf('function handlePtyPartial(text: string): void {')
119
+ const redactIdx = src.indexOf(`redactOutboundText(text, 'pty_preview')`, start)
120
+ const pureIdx = src.indexOf('handlePtyPartialPure(text, state', start)
121
+ expect(start).toBeGreaterThan(0)
122
+ expect(redactIdx).toBeGreaterThan(start)
123
+ expect(pureIdx).toBeGreaterThan(redactIdx) // mask BEFORE the stream push
124
+ })
125
+
126
+ it('pty activity: redacts at the gateway boundary BEFORE the stream', () => {
127
+ // F3 — the (currently-unwired) PTY-activity lane is masked too so the fix
128
+ // is durable if it is ever re-armed.
129
+ const start = src.indexOf('function handlePtyActivity(text: string): void {')
130
+ const redactIdx = src.indexOf(`redactOutboundText(text, 'pty_activity')`, start)
131
+ const streamIdx = src.indexOf('handleStreamReply(', start)
132
+ expect(start).toBeGreaterThan(0)
133
+ expect(redactIdx).toBeGreaterThan(start)
134
+ expect(streamIdx).toBeGreaterThan(redactIdx) // mask BEFORE the stream
135
+ })
136
+
80
137
  it('does not log the secret value when a mask fires', () => {
81
138
  const idx = src.indexOf('function redactOutboundText(')
82
139
  const body = src.slice(idx, idx + 400)
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Pin the gateway trace verbosity gate (#3025): zero-signal per-poll
3
+ * heartbeats are suppressed by default and the full firehose is restored
4
+ * only when SWITCHROOM_GW_TRACE is truthy. Error lines and real
5
+ * (non-poll / non-idle-tick) activity ALWAYS emit regardless of the flag.
6
+ *
7
+ * DUAL-RUNNER NOTE (vitest + bun): this file runs under BOTH runners, and
8
+ * bun's vitest shim does not honor `vi.resetModules()` — a re-import
9
+ * returns the originally-cached module, so flag variants CANNOT be tested
10
+ * via env + fresh import. Instead the gate exposes pure paths: the env
11
+ * parse is `computeGwTraceVerbose(flag)` and both should-emit functions
12
+ * take an explicit `verbose` argument (defaulting to the module-init
13
+ * read). Tests pass verbosity explicitly.
14
+ */
15
+
16
+ import { describe, it, expect } from 'vitest'
17
+ import {
18
+ computeGwTraceVerbose,
19
+ shouldEmitTgPost,
20
+ shouldEmitShadowTrace,
21
+ } from '../shared/gw-trace-gate.js'
22
+
23
+ describe('computeGwTraceVerbose — env flag parsing', () => {
24
+ it('is OFF when unset', () => {
25
+ expect(computeGwTraceVerbose(undefined)).toBe(false)
26
+ })
27
+
28
+ it('opts IN on the truthy set, case-insensitively (1/true/yes/on)', () => {
29
+ for (const v of ['1', 'true', 'TRUE', 'True', 'yes', 'Yes', 'YES', 'on', 'ON', 'On', ' 1', 'true ']) {
30
+ expect(computeGwTraceVerbose(v)).toBe(true)
31
+ }
32
+ })
33
+
34
+ it('stays OFF for falsey / unrecognized values', () => {
35
+ for (const v of ['', '0', 'false', 'FALSE', 'off', 'No', 'x', 'enabled']) {
36
+ expect(computeGwTraceVerbose(v)).toBe(false)
37
+ }
38
+ })
39
+ })
40
+
41
+ describe('shouldEmitTgPost — default (verbose=false): suppress poll heartbeats', () => {
42
+ it('suppresses status=ok for getUpdates and getMe', () => {
43
+ expect(shouldEmitTgPost('getUpdates', 'ok', false)).toBe(false)
44
+ expect(shouldEmitTgPost('getMe', 'ok', false)).toBe(false)
45
+ })
46
+
47
+ it('ALWAYS emits error lines, even for the poll methods', () => {
48
+ expect(shouldEmitTgPost('getUpdates', 'err', false)).toBe(true)
49
+ expect(shouldEmitTgPost('getMe', 'err', false)).toBe(true)
50
+ })
51
+
52
+ it('keeps status=ok for real outbound methods', () => {
53
+ for (const m of ['sendMessage', 'editMessageText', 'sendChatAction', 'answerCallbackQuery']) {
54
+ expect(shouldEmitTgPost(m, 'ok', false)).toBe(true)
55
+ }
56
+ })
57
+ })
58
+
59
+ describe('shouldEmitShadowTrace — default (verbose=false): suppress idle no-op ticks', () => {
60
+ it('suppresses a tick with no effects in an idle global state', () => {
61
+ expect(shouldEmitShadowTrace('tick', 0, 'bridge_alive_idle', false)).toBe(false)
62
+ expect(shouldEmitShadowTrace('tick', 0, 'bridge_dead', false)).toBe(false)
63
+ })
64
+
65
+ it('emits a tick that produced effects (e.g. a TTL turn expiry)', () => {
66
+ expect(shouldEmitShadowTrace('tick', 1, 'bridge_alive_idle', false)).toBe(true)
67
+ })
68
+
69
+ it('emits a tick while a turn is in flight', () => {
70
+ expect(shouldEmitShadowTrace('tick', 0, 'bridge_alive_in_turn', false)).toBe(true)
71
+ })
72
+
73
+ it('ALWAYS emits non-tick events (real inbound/turn/bridge activity)', () => {
74
+ for (const ev of ['inbound', 'turnStart', 'turnEnd', 'bridgeUp', 'bridgeDown', 'permVerdict']) {
75
+ expect(shouldEmitShadowTrace(ev, 0, 'bridge_alive_idle', false)).toBe(true)
76
+ }
77
+ })
78
+ })
79
+
80
+ describe('verbose=true — full firehose restored', () => {
81
+ it('emits every line, including the two suppressed-by-default families', () => {
82
+ expect(shouldEmitTgPost('getUpdates', 'ok', true)).toBe(true)
83
+ expect(shouldEmitTgPost('getMe', 'ok', true)).toBe(true)
84
+ expect(shouldEmitShadowTrace('tick', 0, 'bridge_alive_idle', true)).toBe(true)
85
+ expect(shouldEmitShadowTrace('tick', 0, 'bridge_dead', true)).toBe(true)
86
+ })
87
+ })
88
+
89
+ describe('default verbose argument wiring', () => {
90
+ it('the defaulted call form works and matches the module-init env read', () => {
91
+ // Env-agnostic assertion: the defaulted form must agree with the
92
+ // explicit form using the parsed CURRENT env — proving the default
93
+ // parameter is wired to the module-init flag, whatever its value in
94
+ // the test process.
95
+ const envVerbose = computeGwTraceVerbose(process.env.SWITCHROOM_GW_TRACE)
96
+ expect(shouldEmitTgPost('getUpdates', 'ok')).toBe(
97
+ shouldEmitTgPost('getUpdates', 'ok', envVerbose),
98
+ )
99
+ expect(shouldEmitShadowTrace('tick', 0, 'bridge_alive_idle')).toBe(
100
+ shouldEmitShadowTrace('tick', 0, 'bridge_alive_idle', envVerbose),
101
+ )
102
+ // Regardless of the flag, an error line always emits via the default form.
103
+ expect(shouldEmitTgPost('getUpdates', 'err')).toBe(true)
104
+ })
105
+ })