switchroom 0.18.31 → 0.18.33

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 (159) hide show
  1. package/dist/agent-scheduler/index.js +4 -2
  2. package/dist/auth-broker/index.js +21 -3
  3. package/dist/cli/notion-write-pretool.mjs +4 -2
  4. package/dist/cli/switchroom.js +1410 -852
  5. package/dist/host-control/main.js +22 -4
  6. package/dist/vault/approvals/kernel-server.js +21 -3
  7. package/dist/vault/broker/server.js +48 -4
  8. package/package.json +4 -3
  9. package/profiles/_base/start.sh.hbs +148 -23
  10. package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
  11. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  12. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  13. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  14. package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
  15. package/telegram-plugin/gateway/boot-card.ts +169 -1
  16. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  17. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  18. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  19. package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
  20. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  21. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  22. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  23. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  24. package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
  25. package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
  26. package/telegram-plugin/gateway/gateway.ts +6403 -13456
  27. package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
  28. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
  29. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  30. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  31. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  32. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  33. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  34. package/telegram-plugin/gateway/model-command.ts +23 -0
  35. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  36. package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
  37. package/telegram-plugin/gateway/obligation-store.ts +37 -1
  38. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  39. package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
  40. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  41. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  42. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  43. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  44. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  45. package/telegram-plugin/gateway/turn-end.ts +606 -0
  46. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  47. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  48. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  49. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  50. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  51. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  52. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  53. package/telegram-plugin/narrative-dedup.ts +24 -1
  54. package/telegram-plugin/narrative-flush.ts +2 -2
  55. package/telegram-plugin/pending-user-notice.ts +59 -13
  56. package/telegram-plugin/render/render.ts +25 -1
  57. package/telegram-plugin/status-no-truncate.ts +13 -0
  58. package/telegram-plugin/subagent-watcher.ts +297 -31
  59. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  60. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  61. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  62. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  63. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  64. package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
  65. package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
  66. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  67. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  68. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  69. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
  70. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  71. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
  72. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  73. package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
  74. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  75. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  76. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  77. package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
  78. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
  79. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
  80. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  81. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
  82. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
  83. package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
  84. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
  85. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
  86. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  87. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
  88. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  89. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  90. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  91. package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
  92. package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
  93. package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
  94. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
  95. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
  96. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  97. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
  98. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  99. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  100. package/telegram-plugin/tests/model-command.test.ts +30 -0
  101. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
  102. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  103. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  104. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  105. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  106. package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
  107. package/telegram-plugin/tests/obligation-store.test.ts +43 -0
  108. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
  109. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
  111. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  112. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
  113. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  114. package/telegram-plugin/tests/render/render.test.ts +42 -0
  115. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
  116. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  117. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  118. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
  119. package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
  120. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  121. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  122. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  123. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  124. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  125. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
  126. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
  127. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  128. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
  129. package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
  130. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
  131. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  132. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
  133. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  134. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  135. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  136. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  137. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  138. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  139. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  140. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  141. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
  142. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  143. package/telegram-plugin/tool-activity-summary.ts +110 -38
  144. package/telegram-plugin/turn-flush-safety.ts +80 -14
  145. package/telegram-plugin/uat/restart-capability.ts +76 -0
  146. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  147. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  148. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  149. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  150. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  151. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  152. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  153. package/telegram-plugin/worker-activity-feed.ts +34 -4
  154. package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
  155. package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
  156. package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
  157. package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
  158. package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
  159. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Import-safety pin for the #2996 P0c move: the remaining module-load side
3
+ * effects of gateway.ts are gated behind `isGatewayMain` so importing the
4
+ * module performs NO boot side effects, and the boot sequence is the guarded
5
+ * `startGateway()` at the bottom of the file.
6
+ *
7
+ * WHY this asserts on the parsed SOURCE, not a real `import()`:
8
+ * the repo's oracle for "pin gateway structure without booting it" is
9
+ * source-parsing via `ts.createSourceFile` — the same pattern as
10
+ * gateway-bot-construction-deferral.test.ts (P0b) and
11
+ * gateway-handler-registration-wiring.test.ts (P0a). (Since P0e, #3318, a
12
+ * hermetic `await import()` of gateway.ts does complete cleanly; upgrading
13
+ * this oracle to a real runtime import is possible but deliberately out of
14
+ * scope here — the source parse stays the pinned pattern.) This test follows
15
+ * it and asserts OUTCOMES:
16
+ *
17
+ * 1. `isGatewayMain` is a module-scope const whose value is the argv[1]
18
+ * main-check (true only when this module is the process entrypoint).
19
+ * 2. The boot sequence is `async function startGateway()`, invoked EXACTLY
20
+ * once, and that sole invocation is guarded by `isGatewayMain`. The old
21
+ * top-level boot IIFE (`void (async () => …)()`) is gone.
22
+ * 3. No UNGUARDED module-scope side effect remains: every top-level
23
+ * `setInterval` / `setTimeout` / `<x>.startTimer(…)` / `mkdirSync` /
24
+ * `runHistoryReaperNow` / `openTurnsDb` / `initHistory` /
25
+ * `acquireStartupLock` / `startWebhookIngestServer` /
26
+ * `createPollHealthCheck` / `loadObligations` /
27
+ * `installPosthogErrorHandlers` / `sweepActiveReactions` /
28
+ * `process.on(…)` call, and every module-scope `await`, sits inside an
29
+ * `isGatewayMain` guard (an `if (isGatewayMain …)` or an
30
+ * `isGatewayMain ? … : …` ternary) — or inside a function body.
31
+ *
32
+ * A regression that re-hoists a timer, the startup-lock await, the boot IIFE,
33
+ * or the turn-registry open back to unguarded module scope fails (3).
34
+ *
35
+ * The process signal/error handlers (`process.on('SIGTERM'|'SIGINT'|
36
+ * 'beforeExit'|'unhandledRejection'|'uncaughtException')`,
37
+ * `installPosthogErrorHandlers()`) and the startup reaction sweep
38
+ * (`sweepActiveReactions`) — the "P0d" inventory from the P0c handback — were
39
+ * gated behind `isGatewayMain` by P0e (#3318, `ab34bb7b`) too; #2996 P0d was
40
+ * verified already-delivered and closed with no PR (plan Amendment 11). They
41
+ * ARE in the sentinel set below, so a regression that un-gates any of them
42
+ * back to bare module scope fails (3) as well.
43
+ */
44
+
45
+ import { describe, it, expect } from 'vitest'
46
+ import { readFileSync } from 'node:fs'
47
+ import { fileURLToPath } from 'node:url'
48
+ import { dirname, resolve } from 'node:path'
49
+ import ts from 'typescript'
50
+
51
+ const __dirname = dirname(fileURLToPath(import.meta.url))
52
+ const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
53
+ const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
54
+
55
+ const sourceFile = ts.createSourceFile(
56
+ GATEWAY_PATH,
57
+ GATEWAY_SRC,
58
+ ts.ScriptTarget.Latest,
59
+ true,
60
+ ts.ScriptKind.TS,
61
+ )
62
+
63
+ function isFunctionLike(n: ts.Node): boolean {
64
+ return (
65
+ ts.isFunctionDeclaration(n) ||
66
+ ts.isFunctionExpression(n) ||
67
+ ts.isArrowFunction(n) ||
68
+ ts.isMethodDeclaration(n) ||
69
+ ts.isConstructorDeclaration(n) ||
70
+ ts.isGetAccessorDeclaration(n) ||
71
+ ts.isSetAccessorDeclaration(n)
72
+ )
73
+ }
74
+
75
+ function lineOf(n: ts.Node): number {
76
+ return sourceFile.getLineAndCharacterOfPosition(n.getStart(sourceFile)).line + 1
77
+ }
78
+
79
+ // The side-effect sentinels. Each of these, at module scope, MUST be
80
+ // isGatewayMain-guarded: the first block since P0c, the last two — the "P0d"
81
+ // process-wiring inventory, gated by P0e (#3318) — since P0e.
82
+ const CALL_SENTINELS = new Set([
83
+ 'setInterval',
84
+ 'setTimeout',
85
+ 'mkdirSync',
86
+ 'runHistoryReaperNow',
87
+ 'openTurnsDb',
88
+ 'initHistory',
89
+ 'acquireStartupLock',
90
+ 'startWebhookIngestServer',
91
+ 'createPollHealthCheck',
92
+ 'loadObligations',
93
+ 'installPosthogErrorHandlers',
94
+ 'sweepActiveReactions',
95
+ ])
96
+
97
+ function sentinelName(n: ts.Node): string | null {
98
+ if (ts.isCallExpression(n)) {
99
+ const e = n.expression
100
+ if (ts.isIdentifier(e) && CALL_SENTINELS.has(e.text)) return e.text
101
+ // `<obj>.startTimer(...)` — the silencePoke / pendingProgress timers.
102
+ if (ts.isPropertyAccessExpression(e) && e.name.text === 'startTimer') {
103
+ return `${e.expression.getText(sourceFile)}.startTimer`
104
+ }
105
+ // `process.on(...)` — the P0d signal/error-handler registrations
106
+ // (SIGTERM / SIGINT / beforeExit / unhandledRejection /
107
+ // uncaughtException), gated by P0e. Several route through shutdown() →
108
+ // process.exit, so an unguarded registration makes exit reachable from a
109
+ // bare import.
110
+ if (
111
+ ts.isPropertyAccessExpression(e) &&
112
+ e.name.text === 'on' &&
113
+ ts.isIdentifier(e.expression) &&
114
+ e.expression.text === 'process'
115
+ ) {
116
+ return 'process.on'
117
+ }
118
+ }
119
+ if (n.kind === ts.SyntaxKind.AwaitExpression) return 'await'
120
+ return null
121
+ }
122
+
123
+ function guardsOnMain(cond: ts.Expression): boolean {
124
+ return cond.getText(sourceFile).includes('isGatewayMain')
125
+ }
126
+
127
+ /**
128
+ * Walk the module top level (never descending into function bodies) collecting
129
+ * unguarded sentinels. `guarded` becomes true beneath an isGatewayMain
130
+ * if/ternary branch.
131
+ */
132
+ function collectUnguarded(): string[] {
133
+ const findings: string[] = []
134
+ function walk(node: ts.Node, guarded: boolean): void {
135
+ if (isFunctionLike(node)) return
136
+ if (ts.isIfStatement(node) && guardsOnMain(node.expression)) {
137
+ walk(node.thenStatement, true)
138
+ if (node.elseStatement) walk(node.elseStatement, guarded)
139
+ return
140
+ }
141
+ if (ts.isConditionalExpression(node) && guardsOnMain(node.condition)) {
142
+ walk(node.whenTrue, true)
143
+ walk(node.whenFalse, guarded)
144
+ return
145
+ }
146
+ const s = sentinelName(node)
147
+ if (s && !guarded) findings.push(`${s}@${lineOf(node)}`)
148
+ ts.forEachChild(node, c => walk(c, guarded))
149
+ }
150
+ for (const stmt of sourceFile.statements) walk(stmt, false)
151
+ return findings
152
+ }
153
+
154
+ describe('gateway #2996 P0c: module-load boot side effects are gated behind isGatewayMain', () => {
155
+ it('declares `isGatewayMain` as a module-scope const with the argv[1] main-check', () => {
156
+ let found = false
157
+ for (const stmt of sourceFile.statements) {
158
+ if (!ts.isVariableStatement(stmt)) continue
159
+ for (const decl of stmt.declarationList.declarations) {
160
+ if (ts.isIdentifier(decl.name) && decl.name.text === 'isGatewayMain') {
161
+ found = true
162
+ const init = decl.initializer?.getText(sourceFile) ?? ''
163
+ // Guards on argv[1] ending in the source or the built bundle name.
164
+ expect(init).toContain('process.argv[1]')
165
+ expect(init).toContain("endsWith('gateway.ts')")
166
+ expect(init).toContain("endsWith('gateway.js')")
167
+ }
168
+ }
169
+ }
170
+ expect(found).toBe(true)
171
+ })
172
+
173
+ it('defines an async `startGateway()` boot function and NO leftover boot IIFE', () => {
174
+ let startGatewayFn: ts.FunctionDeclaration | undefined
175
+ for (const stmt of sourceFile.statements) {
176
+ if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === 'startGateway') {
177
+ startGatewayFn = stmt
178
+ }
179
+ }
180
+ expect(startGatewayFn).toBeDefined()
181
+ const isAsync = (startGatewayFn!.modifiers ?? []).some(
182
+ m => m.kind === ts.SyntaxKind.AsyncKeyword,
183
+ )
184
+ expect(isAsync).toBe(true)
185
+
186
+ // The old top-level boot IIFE `void (async () => {…})()` must be gone: no
187
+ // module-scope immediately-invoked async arrow remains.
188
+ let bootIifes = 0
189
+ for (const stmt of sourceFile.statements) {
190
+ if (!ts.isExpressionStatement(stmt)) continue
191
+ let expr = stmt.expression
192
+ if (ts.isVoidExpression(expr)) expr = expr.expression
193
+ if (
194
+ ts.isCallExpression(expr) &&
195
+ (ts.isArrowFunction(expr.expression) || ts.isFunctionExpression(expr.expression))
196
+ ) {
197
+ const inner = expr.expression
198
+ const asyncArrow =
199
+ (inner.modifiers ?? []).some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
200
+ if (asyncArrow) bootIifes++
201
+ }
202
+ }
203
+ expect(bootIifes).toBe(0)
204
+ })
205
+
206
+ it('invokes startGateway() EXACTLY once, guarded by isGatewayMain', () => {
207
+ let total = 0
208
+ let guardedCalls = 0
209
+ function walk(node: ts.Node, guarded: boolean): void {
210
+ if (isFunctionLike(node)) return // only the module-scope invocation
211
+ if (ts.isIfStatement(node) && guardsOnMain(node.expression)) {
212
+ walk(node.thenStatement, true)
213
+ if (node.elseStatement) walk(node.elseStatement, guarded)
214
+ return
215
+ }
216
+ if (
217
+ ts.isCallExpression(node) &&
218
+ ts.isIdentifier(node.expression) &&
219
+ node.expression.text === 'startGateway'
220
+ ) {
221
+ total++
222
+ if (guarded) guardedCalls++
223
+ }
224
+ ts.forEachChild(node, c => walk(c, guarded))
225
+ }
226
+ for (const stmt of sourceFile.statements) walk(stmt, false)
227
+ expect(total).toBe(1)
228
+ expect(guardedCalls).toBe(1)
229
+ })
230
+
231
+ it('leaves NO unguarded module-scope timer / boot-I/O / startup-lock side effect', () => {
232
+ // If this fails it prints exactly which sentinel (e.g. `setInterval@1234`,
233
+ // `await@9750`, `openTurnsDb@1700`) escaped the isGatewayMain guard.
234
+ expect(collectUnguarded()).toEqual([])
235
+ })
236
+
237
+ it('gates every module-scope timer handle (the 4 reaper/tick consts)', () => {
238
+ // pendingStateReaper / midSessionCardReaper / _deliveryMachineTick /
239
+ // _deliveryConfirmSweep are `const X = isGatewayMain ? setInterval(…) :
240
+ // undefined` — their setInterval must be inside an isGatewayMain ternary.
241
+ const timerConsts = new Set([
242
+ 'pendingStateReaper',
243
+ 'midSessionCardReaper',
244
+ '_deliveryMachineTick',
245
+ '_deliveryConfirmSweep',
246
+ ])
247
+ const seen = new Set<string>()
248
+ for (const stmt of sourceFile.statements) {
249
+ if (!ts.isVariableStatement(stmt)) continue
250
+ for (const decl of stmt.declarationList.declarations) {
251
+ if (
252
+ ts.isIdentifier(decl.name) &&
253
+ timerConsts.has(decl.name.text) &&
254
+ decl.initializer
255
+ ) {
256
+ seen.add(decl.name.text)
257
+ const init = decl.initializer
258
+ // Must be a conditional whose condition mentions isGatewayMain and
259
+ // whose truthy branch is the setInterval call.
260
+ expect(ts.isConditionalExpression(init)).toBe(true)
261
+ if (ts.isConditionalExpression(init)) {
262
+ expect(guardsOnMain(init.condition)).toBe(true)
263
+ expect(init.whenTrue.getText(sourceFile)).toContain('setInterval')
264
+ }
265
+ }
266
+ }
267
+ }
268
+ expect([...seen].sort()).toEqual([...timerConsts].sort())
269
+ })
270
+ })
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Runtime boot-smoke pin (finding M-1, full-stack adversarial review).
3
+ *
4
+ * The source-parse pin `gateway-boot-side-effect-gating.test.ts` proves the
5
+ * module-load side effects are `isGatewayMain`-guarded, but it NEVER actually
6
+ * boots the gateway — so it is structurally blind to a temporal-dead-zone
7
+ * ReferenceError inside a guarded builder. That exact class shipped on main:
8
+ * PR-C3 (#3392) made `gatewayLivenessWiringDeps()` eagerly read
9
+ * `pendingInboundBuffer` (a `const` declared ~200 lines LATER than the
10
+ * module-load `silencePoke.startTimer(...)` call that invokes the builder),
11
+ * crash-looping every real gateway boot with
12
+ * `uncaughtException: ReferenceError: Cannot access 'pendingInboundBuffer'
13
+ * before initialization`
14
+ * right after the boot lock — while CI stayed green because `isGatewayMain`
15
+ * is false under vitest (argv[1] is the runner).
16
+ *
17
+ * This test closes that blind spot the only way a source parse can't: it
18
+ * SPAWNS `bun telegram-plugin/gateway/gateway.ts` as the process entrypoint
19
+ * (so `isGatewayMain` is TRUE and every guarded module-load side effect
20
+ * actually runs), in a hermetic TELEGRAM_STATE_DIR with a fake bot token, and
21
+ * asserts the gateway reaches a POST-module-eval stderr marker
22
+ * (`ipc — listening`, emitted when `createIpcServer` binds its UDS at
23
+ * gateway.ts ~10454, well past the TDZ site) with NO
24
+ * `uncaughtException: ReferenceError` on the way. The 401-Unauthorized the
25
+ * fake token later triggers happens INSIDE `startGateway()` — after module
26
+ * eval — so it never precedes the marker and is not a boot-order failure.
27
+ *
28
+ * Parameterized over the two kill-switch postures so a future eager-read
29
+ * introduced on either the router-v2 or turn-end-funnel-v2 path is also
30
+ * caught:
31
+ * - both flags unset (production default today)
32
+ * - SWITCHROOM_INBOUND_ROUTER_V2=1 + SWITCHROOM_TURN_END_FUNNEL_V2=1
33
+ *
34
+ * Bun is the prod + CI gateway runtime (`bun test` is a required check), so
35
+ * spawning it here is CI-native, not CI-hostile.
36
+ *
37
+ * VERIFIED to red on pre-fix main: reverting the getter fix in the working
38
+ * tree makes both parameterizations fail — the child dies on the ReferenceError
39
+ * and never emits the marker (mutation evidence in the PR body).
40
+ */
41
+
42
+ import { describe, it, expect } from 'vitest'
43
+ import { spawn } from 'node:child_process'
44
+ import { mkdtempSync, rmSync } from 'node:fs'
45
+ import { tmpdir } from 'node:os'
46
+ import { join, resolve } from 'node:path'
47
+ import { fileURLToPath } from 'node:url'
48
+ import { dirname } from 'node:path'
49
+
50
+ const __dirname = dirname(fileURLToPath(import.meta.url))
51
+ const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
52
+
53
+ // The marker is emitted synchronously by createIpcServer() at gateway.ts
54
+ // (~10454), which is AFTER the module-load silence-poke wiring (~9653) that
55
+ // held the TDZ read. Reaching it proves module eval cleared the crash site.
56
+ const POST_EVAL_MARKER = 'ipc — listening'
57
+ // A fake bot token: valid Bot API shape (<digits>:<35 chars>) so grammy's
58
+ // constructor accepts it; the 401 only lands later during startGateway().
59
+ const FAKE_BOT_TOKEN = '123456:' + 'A'.repeat(35)
60
+ const BOOT_TIMEOUT_MS = 45_000
61
+
62
+ interface BootResult {
63
+ reachedMarker: boolean
64
+ sawReferenceError: boolean
65
+ stderr: string
66
+ }
67
+
68
+ function bootGateway(extraEnv: Record<string, string>): Promise<BootResult> {
69
+ return new Promise<BootResult>((resolvePromise, rejectPromise) => {
70
+ const stateDir = mkdtempSync(join(tmpdir(), 'gw-boot-smoke-'))
71
+ const child = spawn('bun', [GATEWAY_PATH], {
72
+ env: {
73
+ ...process.env,
74
+ TELEGRAM_STATE_DIR: stateDir,
75
+ SWITCHROOM_GATEWAY_PID_FILE: join(stateDir, 'gateway.pid.json'),
76
+ TELEGRAM_BOT_TOKEN: FAKE_BOT_TOKEN,
77
+ SWITCHROOM_AGENT_NAME: 'boot-smoke',
78
+ // Scrub the two kill switches to a known-OFF baseline so the
79
+ // "unset" posture is genuinely unset even if the CI shell exports
80
+ // them; the "enabled" posture re-sets them via extraEnv below.
81
+ SWITCHROOM_INBOUND_ROUTER_V2: '',
82
+ SWITCHROOM_TURN_END_FUNNEL_V2: '',
83
+ ...extraEnv,
84
+ },
85
+ stdio: ['ignore', 'ignore', 'pipe'],
86
+ })
87
+
88
+ let stderr = ''
89
+ let settled = false
90
+ const finish = (): void => {
91
+ if (settled) return
92
+ settled = true
93
+ clearTimeout(timer)
94
+ try { child.kill('SIGKILL') } catch { /* already gone */ }
95
+ try { rmSync(stateDir, { recursive: true, force: true }) } catch { /* best-effort */ }
96
+ resolvePromise({
97
+ reachedMarker: stderr.includes(POST_EVAL_MARKER),
98
+ sawReferenceError: /uncaughtException:\s*ReferenceError/.test(stderr),
99
+ stderr,
100
+ })
101
+ }
102
+
103
+ const timer = setTimeout(finish, BOOT_TIMEOUT_MS)
104
+
105
+ child.stderr.on('data', (chunk: Buffer) => {
106
+ stderr += chunk.toString()
107
+ // As soon as we see the marker OR a boot-order ReferenceError, we have
108
+ // our verdict — no need to keep the child alive.
109
+ if (stderr.includes(POST_EVAL_MARKER) || /uncaughtException:\s*ReferenceError/.test(stderr)) {
110
+ finish()
111
+ }
112
+ })
113
+ child.on('error', (err) => {
114
+ if (settled) return
115
+ settled = true
116
+ clearTimeout(timer)
117
+ try { rmSync(stateDir, { recursive: true, force: true }) } catch { /* best-effort */ }
118
+ rejectPromise(err)
119
+ })
120
+ // If the process exits before the marker (e.g. the TDZ crash aborts the
121
+ // process), resolve with whatever we captured so the assertion can fail
122
+ // with the real stderr.
123
+ child.on('exit', finish)
124
+ })
125
+ }
126
+
127
+ const POSTURES: Array<{ name: string; env: Record<string, string> }> = [
128
+ { name: 'both kill switches unset (prod default)', env: {} },
129
+ {
130
+ name: 'router-v2 + turn-end-funnel-v2 enabled',
131
+ env: { SWITCHROOM_INBOUND_ROUTER_V2: '1', SWITCHROOM_TURN_END_FUNNEL_V2: '1' },
132
+ },
133
+ ]
134
+
135
+ describe('gateway boot-smoke (M-1): real bun boot reaches post-module-eval marker with no TDZ ReferenceError', () => {
136
+ it.each(POSTURES)('boots cleanly with %s', async ({ env }) => {
137
+ const result = await bootGateway(env)
138
+ // The load-bearing assertion: no temporal-dead-zone crash during module
139
+ // eval. If this reds, the stderr tail names the offending binding.
140
+ expect(
141
+ result.sawReferenceError,
142
+ `gateway boot emitted a ReferenceError before module eval finished:\n${result.stderr.slice(-2000)}`,
143
+ ).toBe(false)
144
+ // And it actually got past the TDZ site to the IPC-listening marker.
145
+ expect(
146
+ result.reachedMarker,
147
+ `gateway boot never reached "${POST_EVAL_MARKER}"; stderr tail:\n${result.stderr.slice(-2000)}`,
148
+ ).toBe(true)
149
+ }, BOOT_TIMEOUT_MS + 15_000)
150
+ })
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Import-safety pin for the #2996 P0b move: token materialization + `new Bot`
3
+ * construction are DEFERRED out of module import into the boot function
4
+ * `initGatewayBot()`.
5
+ *
6
+ * WHY this asserts on the parsed SOURCE, not a real `import()`:
7
+ * gateway.ts is NOT import-safe under vitest yet — the boot IIFE (`void (async
8
+ * () => { … })()`) still fires at module evaluation and P0b deliberately does
9
+ * NOT touch it (that guard is P0c/P0d scope). So a real `await import()` would
10
+ * still trigger the boot path. The oracle for "how the repo pins gateway
11
+ * structure without booting it" is source-parsing via `ts.createSourceFile`
12
+ * (see gateway-handler-registration-wiring.test.ts /
13
+ * gateway-pending-command-wiring.test.ts). This test follows that pattern and
14
+ * asserts OUTCOMES about where construction lives:
15
+ *
16
+ * 1. Module import (top-level, outside any function body) performs NO
17
+ * `new Bot(...)`, NO dynamic import of the materialize-bot-token helper,
18
+ * NO `materializeBotToken(...)` call, and has NO reachable `process.exit`
19
+ * and NO top-level `await`. i.e. importing gateway.ts constructs no bot,
20
+ * reads no token, and cannot exit the process.
21
+ * 2. `bot` is a deferred module-scope `let` (definite-assignment), NOT a
22
+ * `const bot = new Bot(...)`.
23
+ * 3. The boot function `initGatewayBot()` DOES all of the above: it is an
24
+ * async function whose body contains the `new Bot(...)`, the materialize
25
+ * dynamic import, the `materializeBotToken(...)` call, and the single
26
+ * `registerGatewayHandlers(bot, …)` wiring call.
27
+ *
28
+ * A count/handler-theater test would not catch a regression that re-hoists
29
+ * `new Bot` or the token read back to module scope; these node-location
30
+ * assertions do.
31
+ */
32
+
33
+ import { describe, it, expect } from 'vitest'
34
+ import { readFileSync } from 'node:fs'
35
+ import { fileURLToPath } from 'node:url'
36
+ import { dirname, resolve } from 'node:path'
37
+ import ts from 'typescript'
38
+
39
+ const __dirname = dirname(fileURLToPath(import.meta.url))
40
+ const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
41
+ const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
42
+
43
+ const sourceFile = ts.createSourceFile(
44
+ GATEWAY_PATH,
45
+ GATEWAY_SRC,
46
+ ts.ScriptTarget.Latest,
47
+ true,
48
+ ts.ScriptKind.TS,
49
+ )
50
+
51
+ const MATERIALIZE_MODULE_HINT = 'materialize-bot-token'
52
+
53
+ function isFunctionLike(n: ts.Node): boolean {
54
+ return (
55
+ ts.isFunctionDeclaration(n) ||
56
+ ts.isFunctionExpression(n) ||
57
+ ts.isArrowFunction(n) ||
58
+ ts.isMethodDeclaration(n) ||
59
+ ts.isConstructorDeclaration(n) ||
60
+ ts.isGetAccessorDeclaration(n) ||
61
+ ts.isSetAccessorDeclaration(n)
62
+ )
63
+ }
64
+
65
+ /**
66
+ * Walk every node that executes at MODULE SCOPE — i.e. descend through the
67
+ * source file but STOP at any function-like boundary (function/arrow/method
68
+ * bodies run later, when called, not at import). A `new Bot` or token read
69
+ * found here would fire at import; the whole point of P0b is that none do.
70
+ */
71
+ function walkModuleScope(visitor: (n: ts.Node) => void): void {
72
+ const recur = (node: ts.Node): void => {
73
+ ts.forEachChild(node, child => {
74
+ if (isFunctionLike(child)) return // do not descend into function bodies
75
+ visitor(child)
76
+ recur(child)
77
+ })
78
+ }
79
+ recur(sourceFile)
80
+ }
81
+
82
+ function isNewBot(n: ts.Node): boolean {
83
+ return ts.isNewExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'Bot'
84
+ }
85
+
86
+ function isMaterializeDynamicImport(n: ts.Node): boolean {
87
+ // `import('…materialize-bot-token…')`
88
+ if (!ts.isCallExpression(n)) return false
89
+ if (n.expression.kind !== ts.SyntaxKind.ImportKeyword) return false
90
+ const arg = n.arguments[0]
91
+ return arg != null && ts.isStringLiteralLike(arg) && arg.text.includes(MATERIALIZE_MODULE_HINT)
92
+ }
93
+
94
+ function isMaterializeBotTokenCall(n: ts.Node): boolean {
95
+ return ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'materializeBotToken'
96
+ }
97
+
98
+ function isProcessExitCall(n: ts.Node): boolean {
99
+ return (
100
+ ts.isCallExpression(n) &&
101
+ ts.isPropertyAccessExpression(n.expression) &&
102
+ ts.isIdentifier(n.expression.expression) &&
103
+ n.expression.expression.text === 'process' &&
104
+ n.expression.name.text === 'exit'
105
+ )
106
+ }
107
+
108
+ function findFunction(name: string): ts.FunctionDeclaration | undefined {
109
+ for (const s of sourceFile.statements) {
110
+ if (ts.isFunctionDeclaration(s) && s.name?.text === name) return s
111
+ }
112
+ return undefined
113
+ }
114
+
115
+ /** Count nodes matching a predicate anywhere within a subtree. */
116
+ function countIn(root: ts.Node, pred: (n: ts.Node) => boolean): number {
117
+ let count = 0
118
+ const visit = (node: ts.Node): void => {
119
+ if (pred(node)) count++
120
+ ts.forEachChild(node, visit)
121
+ }
122
+ visit(root)
123
+ return count
124
+ }
125
+
126
+ describe('gateway #2996 P0b: token materialization + new Bot are deferred to boot, import is construction-clean', () => {
127
+ it('constructs NO Bot at module import (no top-level `new Bot`)', () => {
128
+ let count = 0
129
+ walkModuleScope(n => {
130
+ if (isNewBot(n)) count++
131
+ })
132
+ expect(count).toBe(0)
133
+ })
134
+
135
+ it('reads NO token at module import (no top-level materialize import or materializeBotToken call)', () => {
136
+ let dynImports = 0
137
+ let materializeCalls = 0
138
+ walkModuleScope(n => {
139
+ if (isMaterializeDynamicImport(n)) dynImports++
140
+ if (isMaterializeBotTokenCall(n)) materializeCalls++
141
+ })
142
+ expect(dynImports).toBe(0)
143
+ expect(materializeCalls).toBe(0)
144
+ })
145
+
146
+ it('performs NO token-refusal exit at module import (no top-level process.exit for token materialization)', () => {
147
+ // NOTE: the pre-existing startup-lock mutex (#…) still has one top-level
148
+ // process.exit + top-level await — that is P0c/P0d scope, deliberately NOT
149
+ // touched here. This asserts specifically that the TOKEN-materialization
150
+ // exit paths (exit(1) on materialize failure / missing token) are gone from
151
+ // module scope: there is at most the single startup-lock exit, and none of
152
+ // them sit in a materialize/`new Bot` context (covered by the tests above).
153
+ let exits = 0
154
+ walkModuleScope(n => {
155
+ if (isProcessExitCall(n)) exits++
156
+ })
157
+ // The two token-materialization exit(1) sites moved into initGatewayBot;
158
+ // only the pre-existing startup-lock exit remains at module scope.
159
+ expect(exits).toBeLessThanOrEqual(1)
160
+ })
161
+
162
+ it('leaves NO module-scope reference to `bot` or `lockedBot` outside their declarations (all consumers deferred)', () => {
163
+ // Completeness pin: after P0b, the ONLY module-scope occurrences of `bot` /
164
+ // `lockedBot` are their `let` declarations. Every consumer — the api-tap /
165
+ // heartbeat wiring, checklist probes, chatLock.wrapBot, the /approvals
166
+ // registration, the callback-query factory, and registerGatewayHandlers —
167
+ // must live inside a function (initGatewayBot or a post-boot handler). A
168
+ // regression that re-hoists any consumer to import scope fails here.
169
+ const declLines = new Set<number>()
170
+ for (const stmt of sourceFile.statements) {
171
+ if (!ts.isVariableStatement(stmt)) continue
172
+ for (const decl of stmt.declarationList.declarations) {
173
+ if (ts.isIdentifier(decl.name) && (decl.name.text === 'bot' || decl.name.text === 'lockedBot')) {
174
+ declLines.add(sourceFile.getLineAndCharacterOfPosition(decl.name.getStart(sourceFile)).line)
175
+ }
176
+ }
177
+ }
178
+ const stray: string[] = []
179
+ walkModuleScope(n => {
180
+ if (ts.isIdentifier(n) && (n.text === 'bot' || n.text === 'lockedBot')) {
181
+ const line = sourceFile.getLineAndCharacterOfPosition(n.getStart(sourceFile)).line
182
+ if (!declLines.has(line)) stray.push(`${n.text}@${line + 1}`)
183
+ }
184
+ })
185
+ expect(stray).toEqual([])
186
+ })
187
+
188
+ it('declares `bot` as a deferred module-scope `let`, not `const bot = new Bot(...)`', () => {
189
+ let botDecls = 0
190
+ let botHasInitializer = false
191
+ for (const stmt of sourceFile.statements) {
192
+ if (!ts.isVariableStatement(stmt)) continue
193
+ for (const decl of stmt.declarationList.declarations) {
194
+ if (ts.isIdentifier(decl.name) && decl.name.text === 'bot') {
195
+ botDecls++
196
+ if (decl.initializer != null) botHasInitializer = true
197
+ // `let`, not `const`
198
+ expect(stmt.declarationList.flags & ts.NodeFlags.Const).toBe(0)
199
+ }
200
+ }
201
+ }
202
+ expect(botDecls).toBe(1)
203
+ expect(botHasInitializer).toBe(false)
204
+ })
205
+
206
+ it('defines an async initGatewayBot() boot function', () => {
207
+ const fn = findFunction('initGatewayBot')
208
+ expect(fn).toBeDefined()
209
+ const isAsync = (fn!.modifiers ?? []).some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
210
+ expect(isAsync).toBe(true)
211
+ })
212
+
213
+ it('initGatewayBot() performs the construction: new Bot, token materialization, and the registration wiring call', () => {
214
+ const fn = findFunction('initGatewayBot')
215
+ expect(fn?.body).toBeDefined()
216
+ const body = fn!.body!
217
+
218
+ // Exactly one `new Bot(...)` — the single constructed instance.
219
+ expect(countIn(body, isNewBot)).toBe(1)
220
+
221
+ // Token materialization lives here (dynamic import + the resolve call).
222
+ expect(countIn(body, isMaterializeDynamicImport)).toBe(1)
223
+ expect(countIn(body, isMaterializeBotTokenCall)).toBe(1)
224
+
225
+ // The two P0a-missed import-time bot consumers also moved here (P0b): the
226
+ // callback-query factory and the /approvals registration.
227
+ const isNamedCall = (name: string) => (n: ts.Node) =>
228
+ ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === name
229
+ expect(countIn(body, isNamedCall('createCallbackQueryHandlers'))).toBe(1)
230
+ expect(countIn(body, isNamedCall('registerApprovalsCommands'))).toBe(1)
231
+
232
+ // The refuse-to-boot exits (process.exit(1)) live here, not at import.
233
+ expect(countIn(body, isProcessExitCall)).toBeGreaterThanOrEqual(1)
234
+
235
+ // The single registerGatewayHandlers(bot, …) wiring call moved here (P0b).
236
+ let regCalls = 0
237
+ const visit = (node: ts.Node): void => {
238
+ if (
239
+ ts.isCallExpression(node) &&
240
+ ts.isIdentifier(node.expression) &&
241
+ node.expression.text === 'registerGatewayHandlers'
242
+ ) {
243
+ regCalls++
244
+ expect(node.arguments[0]?.getText(sourceFile)).toBe('bot')
245
+ }
246
+ ts.forEachChild(node, visit)
247
+ }
248
+ visit(body)
249
+ expect(regCalls).toBe(1)
250
+ })
251
+ })