switchroom 0.18.32 → 0.19.0

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 (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -0,0 +1,160 @@
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 — now BOTH v2 ON after the flip)
32
+ * - SWITCHROOM_INBOUND_ROUTER_V2=1 + SWITCHROOM_TURN_END_FUNNEL_V2=1
33
+ * - both flags =0 (the escape-hatch legacy v1 posture — kept so the legacy
34
+ * module-eval paths stay boot-smoked until the legacy delete)
35
+ *
36
+ * Bun is the prod + CI gateway runtime (`bun test` is a required check), so
37
+ * spawning it here is CI-native, not CI-hostile.
38
+ *
39
+ * VERIFIED to red on pre-fix main: reverting the getter fix in the working
40
+ * tree makes both parameterizations fail — the child dies on the ReferenceError
41
+ * and never emits the marker (mutation evidence in the PR body).
42
+ */
43
+
44
+ import { describe, it, expect } from 'vitest'
45
+ import { spawn } from 'node:child_process'
46
+ import { mkdtempSync, rmSync } from 'node:fs'
47
+ import { tmpdir } from 'node:os'
48
+ import { join, resolve } from 'node:path'
49
+ import { fileURLToPath } from 'node:url'
50
+ import { dirname } from 'node:path'
51
+
52
+ const __dirname = dirname(fileURLToPath(import.meta.url))
53
+ const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
54
+
55
+ // The marker is emitted synchronously by createIpcServer() at gateway.ts
56
+ // (~10454), which is AFTER the module-load silence-poke wiring (~9653) that
57
+ // held the TDZ read. Reaching it proves module eval cleared the crash site.
58
+ const POST_EVAL_MARKER = 'ipc — listening'
59
+ // A fake bot token: valid Bot API shape (<digits>:<35 chars>) so grammy's
60
+ // constructor accepts it; the 401 only lands later during startGateway().
61
+ const FAKE_BOT_TOKEN = '123456:' + 'A'.repeat(35)
62
+ const BOOT_TIMEOUT_MS = 45_000
63
+
64
+ interface BootResult {
65
+ reachedMarker: boolean
66
+ sawReferenceError: boolean
67
+ stderr: string
68
+ }
69
+
70
+ function bootGateway(extraEnv: Record<string, string>): Promise<BootResult> {
71
+ return new Promise<BootResult>((resolvePromise, rejectPromise) => {
72
+ const stateDir = mkdtempSync(join(tmpdir(), 'gw-boot-smoke-'))
73
+ const child = spawn('bun', [GATEWAY_PATH], {
74
+ env: {
75
+ ...process.env,
76
+ TELEGRAM_STATE_DIR: stateDir,
77
+ SWITCHROOM_GATEWAY_PID_FILE: join(stateDir, 'gateway.pid.json'),
78
+ TELEGRAM_BOT_TOKEN: FAKE_BOT_TOKEN,
79
+ SWITCHROOM_AGENT_NAME: 'boot-smoke',
80
+ // Scrub the two kill switches to a known-OFF baseline so the
81
+ // "unset" posture is genuinely unset even if the CI shell exports
82
+ // them; the "enabled" posture re-sets them via extraEnv below.
83
+ SWITCHROOM_INBOUND_ROUTER_V2: '',
84
+ SWITCHROOM_TURN_END_FUNNEL_V2: '',
85
+ ...extraEnv,
86
+ },
87
+ stdio: ['ignore', 'ignore', 'pipe'],
88
+ })
89
+
90
+ let stderr = ''
91
+ let settled = false
92
+ const finish = (): void => {
93
+ if (settled) return
94
+ settled = true
95
+ clearTimeout(timer)
96
+ try { child.kill('SIGKILL') } catch { /* already gone */ }
97
+ try { rmSync(stateDir, { recursive: true, force: true }) } catch { /* best-effort */ }
98
+ resolvePromise({
99
+ reachedMarker: stderr.includes(POST_EVAL_MARKER),
100
+ sawReferenceError: /uncaughtException:\s*ReferenceError/.test(stderr),
101
+ stderr,
102
+ })
103
+ }
104
+
105
+ const timer = setTimeout(finish, BOOT_TIMEOUT_MS)
106
+
107
+ child.stderr.on('data', (chunk: Buffer) => {
108
+ stderr += chunk.toString()
109
+ // As soon as we see the marker OR a boot-order ReferenceError, we have
110
+ // our verdict — no need to keep the child alive.
111
+ if (stderr.includes(POST_EVAL_MARKER) || /uncaughtException:\s*ReferenceError/.test(stderr)) {
112
+ finish()
113
+ }
114
+ })
115
+ child.on('error', (err) => {
116
+ if (settled) return
117
+ settled = true
118
+ clearTimeout(timer)
119
+ try { rmSync(stateDir, { recursive: true, force: true }) } catch { /* best-effort */ }
120
+ rejectPromise(err)
121
+ })
122
+ // If the process exits before the marker (e.g. the TDZ crash aborts the
123
+ // process), resolve with whatever we captured so the assertion can fail
124
+ // with the real stderr.
125
+ child.on('exit', finish)
126
+ })
127
+ }
128
+
129
+ const POSTURES: Array<{ name: string; env: Record<string, string> }> = [
130
+ // Flags unset is now the DEFAULT-ON posture (both `!== '0'`) after the
131
+ // v0.18.33 canary flip — the v2 router + v2 turn-end funnel are live.
132
+ { name: 'both kill switches unset (prod default — now BOTH v2 ON)', env: {} },
133
+ {
134
+ name: 'router-v2 + turn-end-funnel-v2 explicitly enabled',
135
+ env: { SWITCHROOM_INBOUND_ROUTER_V2: '1', SWITCHROOM_TURN_END_FUNNEL_V2: '1' },
136
+ },
137
+ {
138
+ // Explicit legacy posture: with both flags defaulting ON, the only way to
139
+ // boot-smoke the legacy (v1) module-eval paths is the `=0` escape hatch.
140
+ name: 'both kill switches disabled (=0 escape hatch — legacy v1 paths)',
141
+ env: { SWITCHROOM_INBOUND_ROUTER_V2: '0', SWITCHROOM_TURN_END_FUNNEL_V2: '0' },
142
+ },
143
+ ]
144
+
145
+ describe('gateway boot-smoke (M-1): real bun boot reaches post-module-eval marker with no TDZ ReferenceError', () => {
146
+ it.each(POSTURES)('boots cleanly with %s', async ({ env }) => {
147
+ const result = await bootGateway(env)
148
+ // The load-bearing assertion: no temporal-dead-zone crash during module
149
+ // eval. If this reds, the stderr tail names the offending binding.
150
+ expect(
151
+ result.sawReferenceError,
152
+ `gateway boot emitted a ReferenceError before module eval finished:\n${result.stderr.slice(-2000)}`,
153
+ ).toBe(false)
154
+ // And it actually got past the TDZ site to the IPC-listening marker.
155
+ expect(
156
+ result.reachedMarker,
157
+ `gateway boot never reached "${POST_EVAL_MARKER}"; stderr tail:\n${result.stderr.slice(-2000)}`,
158
+ ).toBe(true)
159
+ }, BOOT_TIMEOUT_MS + 15_000)
160
+ })
@@ -53,15 +53,11 @@ const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
53
53
  */
54
54
  const GOLDEN_REGISTRATIONS: readonly string[] = [
55
55
  'use',
56
- 'command:start',
57
- 'command:help',
58
- 'command:status',
59
- 'command:agents',
56
+ 'helper:registerStartInfoCommands',
60
57
  'command:inject',
61
58
  'command:compact',
62
59
  'command:clear',
63
- 'command:model',
64
- 'command:effort',
60
+ 'helper:registerModelEffortCommands',
65
61
  'command:agentstart',
66
62
  'command:agentstop',
67
63
  'command:stop',
@@ -122,7 +118,7 @@ const GOLDEN_REGISTRATIONS: readonly string[] = [
122
118
  * every `message:*` handler (grammY no-double-handling; see
123
119
  * catch-all-unhandled-message.test.ts) and before `message_reaction`.
124
120
  */
125
- const HELPER_REGISTRARS = new Set(['registerOpsInfoCommands', 'installUnhandledMessageCatchAll'])
121
+ const HELPER_REGISTRARS = new Set(['registerOpsInfoCommands', 'registerStartInfoCommands', 'registerModelEffortCommands', 'installUnhandledMessageCatchAll'])
126
122
 
127
123
  const sourceFile = ts.createSourceFile(
128
124
  GATEWAY_PATH,
@@ -3,42 +3,50 @@ import { readFileSync } from 'node:fs'
3
3
 
4
4
  /**
5
5
  * Structural integration test for the loopback-paste fail-safe redaction in
6
- * the gateway's inbound handler (security audit #3084, F2/F3).
6
+ * the inbound intercept gauntlet (security audit #3084, F2/F3).
7
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.
8
+ * Why structural: the decision predicate (shouldConsumeLoopbackPaste, incl.
11
9
  * the malformed-paste fail-safe) is exhaustively unit-tested in
12
10
  * `auth-loopback-relay.test.ts`, and the redaction primitive
13
11
  * (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.
12
+ * here is that the intercept path actually WIRES a fail-safe so a code-bearing
13
+ * paste that is malformed, or arrives just after the intercept TTL, or has no
14
+ * active flow, is redacted + dropped rather than forwarded to the agent.
17
15
  *
18
- * The invariants:
16
+ * P7 PR-6 (#2996): the intercept body (pending-flow branch + fail-safe tail)
17
+ * moved verbatim into gateway/inbound-interceptors.ts
18
+ * (`interceptLoopbackRelay`); gateway.ts keeps the ordered delegation. The
19
+ * invariants are unchanged — only the file that owns each literal moved:
19
20
  * 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.
21
+ * through to the agent — it flows into the fail-safe below it.
22
+ * 2. A fail-safe `if (shouldConsumeLoopbackPaste(p.text))` block exists that
23
+ * redacts (deps.redactAuthCode → redactAuthCodeMessage) and returns
24
+ * handled, sitting AFTER the pending-flow branch; the gateway invokes the
25
+ * loopback intercept BEFORE the generic auth-code / reauth intercept.
25
26
  */
26
27
  describe('gateway loopback-paste fail-safe redaction — structural wiring', () => {
27
- const src = readFileSync(
28
+ const gatewaySrc = readFileSync(
28
29
  new URL('../gateway/gateway.ts', import.meta.url),
29
30
  'utf8',
30
31
  )
32
+ const interceptorsSrc = readFileSync(
33
+ new URL('../gateway/inbound-interceptors.ts', import.meta.url),
34
+ 'utf8',
35
+ )
31
36
 
32
- it('imports the consume predicate + redaction helper', () => {
33
- expect(src).toMatch(/shouldConsumeLoopbackPaste/)
34
- expect(src).toMatch(/redactAuthCodeMessage/)
37
+ it('wires the consume predicate + redaction helper', () => {
38
+ // The intercept module owns the predicate; the gateway binds the
39
+ // redaction closure (deps.redactAuthCode → redactAuthCodeMessage).
40
+ expect(interceptorsSrc).toMatch(/shouldConsumeLoopbackPaste/)
41
+ expect(gatewaySrc).toMatch(/redactAuthCodeMessage/)
42
+ expect(gatewaySrc).toMatch(/interceptLoopbackRelay\(/)
35
43
  })
36
44
 
37
45
  it('has a fail-safe redact-and-return block guarded by shouldConsumeLoopbackPaste', () => {
38
- // Isolate the fail-safe block: `if (shouldConsumeLoopbackPaste(text)) { ... }`
46
+ // Isolate the fail-safe block: `if (shouldConsumeLoopbackPaste(p.text)) { ... }`
39
47
  // 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,
48
+ const failSafe = interceptorsSrc.match(
49
+ /if \(shouldConsumeLoopbackPaste\(p\.text\)\) \{[\s\S]{0,700}?\n {2}\}/g,
42
50
  )
43
51
  expect(failSafe).not.toBeNull()
44
52
  // The standalone fail-safe (the one NOT preceded by `pendingLoop &&`).
@@ -46,21 +54,28 @@ describe('gateway loopback-paste fail-safe redaction — structural wiring', ()
46
54
  (b) => !b.startsWith('if (pendingLoop'),
47
55
  )
48
56
  expect(standalone).toBeDefined()
49
- expect(standalone!).toMatch(/redactAuthCodeMessage/)
50
- expect(standalone!).toMatch(/return/)
57
+ expect(standalone!).toMatch(/deps\.redactAuthCode/)
58
+ expect(standalone!).toMatch(/return \{ handled: true \}/)
51
59
  })
52
60
 
53
61
  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(
62
+ // The fail-safe block must appear AFTER the pending-loop branch inside the
63
+ // intercept module; and the gateway must invoke the loopback intercept
64
+ // BEFORE the generic auth-code intercept (`pendingReauthFlows`).
65
+ const failSafeIdx = interceptorsSrc.indexOf(
58
66
  'Fail-safe redaction (security audit #3084, F2/F3)',
59
67
  )
60
- const pendingLoopIdx = src.indexOf('const pendingLoop = pendingLoopbackFlows.get')
61
- const reauthIdx = src.indexOf('const pendingReauth = pendingReauthFlows.get')
68
+ const pendingLoopIdx = interceptorsSrc.indexOf(
69
+ 'const pendingLoop = pendingLoopbackFlows.get',
70
+ )
62
71
  expect(failSafeIdx).toBeGreaterThan(-1)
63
72
  expect(failSafeIdx).toBeGreaterThan(pendingLoopIdx)
64
- expect(reauthIdx).toBeGreaterThan(failSafeIdx)
73
+
74
+ const loopbackCallIdx = gatewaySrc.indexOf('interceptLoopbackRelay(')
75
+ // P7 PR-7: the reauth intercept body also moved; its gateway call site is
76
+ // the ordering anchor.
77
+ const reauthIdx = gatewaySrc.indexOf('interceptReauth(')
78
+ expect(loopbackCallIdx).toBeGreaterThan(-1)
79
+ expect(reauthIdx).toBeGreaterThan(loopbackCallIdx)
65
80
  })
66
81
  })
@@ -29,6 +29,12 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
29
29
  new URL('../gateway/outbound-send-path.ts', import.meta.url),
30
30
  'utf8',
31
31
  )
32
+ // #2996 P4-A: the turn-flush backstop scrub lives in handleSessionEvent,
33
+ // which moved VERBATIM to stream-render.ts.
34
+ const streamSrc = readFileSync(
35
+ new URL('../gateway/stream-render.ts', import.meta.url),
36
+ 'utf8',
37
+ )
32
38
 
33
39
  it('imports the shared redactor', () => {
34
40
  expect(src).toMatch(/import \{ redact \} from '\.\.\/secret-detect\/redact\.js'/)
@@ -66,8 +72,8 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
66
72
  it('turn-flush backstop: scrubs the model terminal prose before send', () => {
67
73
  // Turn-flush delivers the model's answer when it skipped reply/stream_reply
68
74
  // — arbitrary agent free-text that hits the wire + stderr preview.
69
- const redactIdx = src.indexOf(`redactOutboundText(capturedText, 'turn_flush')`)
70
- const scrubSiteIdx = src.indexOf(`site: 'turn_flush'`)
75
+ const redactIdx = streamSrc.indexOf(`redactOutboundText(capturedText, 'turn_flush')`)
76
+ const scrubSiteIdx = streamSrc.indexOf(`site: 'turn_flush'`)
71
77
  expect(redactIdx).toBeGreaterThan(0)
72
78
  expect(scrubSiteIdx).toBeGreaterThan(redactIdx) // mask BEFORE the voice scrub + send
73
79
  })
@@ -15,6 +15,10 @@ import { readFileSync } from 'node:fs'
15
15
  */
16
16
  describe('request_secret — gateway wiring', () => {
17
17
  const gw = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
18
+ // executeRequestSecret moved verbatim to card-tool-handlers.ts (#2996 P5-tail);
19
+ // the handler-body pins below read that module. The dispatch case + the
20
+ // secure-capture path (captureProvidedSecret / handleInbound) stay in gateway.ts.
21
+ const cardTool = readFileSync(new URL('../gateway/card-tool-handlers.ts', import.meta.url), 'utf8')
18
22
  const bridge = readFileSync(new URL('../bridge/bridge.ts', import.meta.url), 'utf8')
19
23
 
20
24
  it('declares the MCP tool with required {chat_id,key} and NO value arg', () => {
@@ -30,7 +34,7 @@ describe('request_secret — gateway wiring', () => {
30
34
 
31
35
  it('is allow-listed and dispatched', () => {
32
36
  expect(gw).toMatch(/'request_secret',\n/)
33
- expect(gw).toMatch(/case 'request_secret':\s*\n\s*return executeRequestSecret\(args\)/)
37
+ expect(gw).toMatch(/case 'request_secret':\s*\n\s*return cardToolHandlers\.executeRequestSecret\(args\)/)
34
38
  })
35
39
 
36
40
  it('routes the vsp: callback', () => {
@@ -71,8 +75,8 @@ describe('request_secret — gateway wiring', () => {
71
75
  })
72
76
 
73
77
  it('dedupes to one open request per (chat,key)', () => {
74
- const idx = gw.indexOf('async function executeRequestSecret(')
75
- const body = gw.slice(idx, idx + 1800)
78
+ const idx = cardTool.indexOf('async function executeRequestSecret(')
79
+ const body = cardTool.slice(idx, idx + 1800)
76
80
  expect(body).toMatch(/p\.chat_id === chat_id && p\.key === key/)
77
81
  })
78
82
  })
@@ -26,10 +26,12 @@ import { readFileSync } from 'node:fs'
26
26
  * rather than live.
27
27
  */
28
28
  describe('gateway secret-detect intercept — structural wiring', () => {
29
- const src = readFileSync(
30
- new URL('../gateway/gateway.ts', import.meta.url),
31
- 'utf8',
32
- )
29
+ const src =
30
+ readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8') +
31
+ '\n' +
32
+ // P7 PR-8 (#2996): the vault pending-op intercept (incl. the deferred-
33
+ // secret passphrase branch) moved into gateway/inbound-interceptors.ts.
34
+ readFileSync(new URL('../gateway/inbound-interceptors.ts', import.meta.url), 'utf8')
33
35
 
34
36
  it('imports the pipeline + staging modules', () => {
35
37
  expect(src).toMatch(/from '\.\.\/secret-detect\/pipeline\.js'/)
@@ -54,7 +56,9 @@ describe('gateway secret-detect intercept — structural wiring', () => {
54
56
  })
55
57
 
56
58
  it('runPipeline call sits BEFORE recordInbound() and ipcServer.broadcast(inboundMsg)', () => {
57
- const pipelineIdx = src.indexOf('runPipeline({')
59
+ // P7 PR-9: runPipeline lives in interceptSecretDetectPipeline; the
60
+ // gateway CALL SITE is the ordering anchor against the sinks.
61
+ const pipelineIdx = src.indexOf('await interceptSecretDetectPipeline(')
58
62
  const recordIdx = src.indexOf('recordInbound(', pipelineIdx)
59
63
  const broadcastIdx = src.indexOf('ipcServer.broadcast(inboundMsg)', pipelineIdx)
60
64
  expect(pipelineIdx).toBeGreaterThan(0)
@@ -74,7 +78,7 @@ describe('gateway secret-detect intercept — structural wiring', () => {
74
78
  // swallowed. See
75
79
  // tests/secret-detect-delete-must-surface-failures.test.ts for
76
80
  // the full contract pin.
77
- expect(tail).toMatch(/deleteSensitiveMessage\(chat_id, msgId, 'detected secret'\)/)
81
+ expect(tail).toMatch(/deleteSensitiveMessage\((?:p\.)?chat_id, (?:p\.)?msgId, 'detected secret'\)/)
78
82
  // Tells the user what was captured (masked).
79
83
  expect(tail).toMatch(/captured \$\{pipeRes\.stored\.length\} secret/)
80
84
  // Surfaces the masked form (s.masked is computed via maskToken in the pipeline).
@@ -102,7 +106,7 @@ describe('gateway secret-detect intercept — structural wiring', () => {
102
106
  // migrated to deleteSensitiveMessage so failures surface to the
103
107
  // operator via in-chat warning. See
104
108
  // tests/secret-detect-delete-must-surface-failures.test.ts.
105
- expect(tail).toMatch(/deleteSensitiveMessage\(chat_id, msgId, 'detected secret'\)/)
109
+ expect(tail).toMatch(/deleteSensitiveMessage\((?:p\.)?chat_id, (?:p\.)?msgId, 'detected secret'\)/)
106
110
  // 4. The new inline keyboard helper is used in lieu of the legacy
107
111
  // plain-text "run /vault list" warning.
108
112
  expect(tail).toMatch(/buildDeferredSecretKeyboard\(/)
@@ -134,9 +138,15 @@ describe('gateway secret-detect intercept — structural wiring', () => {
134
138
  // pre-staging body, not a precise offset — it has grown before (#3020's
135
139
  // stop-keyword block pushed it past the old 30k) and may grow again;
136
140
  // bump it when new early-return branches land above the staging block.
137
- const handleInboundIdx = src.indexOf('async function handleInbound(')
138
- const tail = src.slice(handleInboundIdx, handleInboundIdx + 50000)
141
+ // P7 PR-9: the parser moved to interceptSecretStagingCommand
142
+ // (inbound-interceptors.ts), still invoked from handleInbound's
143
+ // inbound-text path (same allowFrom gating) — anchor on the function.
144
+ const stagingFnIdx = src.indexOf('export async function interceptSecretStagingCommand(')
145
+ expect(stagingFnIdx).toBeGreaterThan(0)
146
+ const tail = src.slice(stagingFnIdx, stagingFnIdx + 6000)
139
147
  expect(tail).toMatch(/\(stash\|ignore\|rename\|forget\)/)
140
- expect(tail).toMatch(/secretStaging\.latestForChat\(chat_id\)/)
148
+ expect(tail).toMatch(/secretStaging\.latestForChat\((?:p\.)?chat_id\)/)
149
+ // And handleInbound must actually delegate to it.
150
+ expect(src).toMatch(/await interceptSecretStagingCommand\(/)
141
151
  })
142
152
  })
@@ -122,9 +122,15 @@ describe('gateway: the live callback dispatcher routes every switch tap to the h
122
122
  })
123
123
 
124
124
  it('the typed dispatcher relays the handler reply directly (no recordTypedModelSwitch)', () => {
125
- const idx = GATEWAY_SRC.indexOf('const reply = await handleModelCommand(parsed, deps)')
125
+ // #2996 P6: the typed /model handler moved to bot-commands-model-effort.ts
126
+ // (registerModelEffortCommands); scrape the extracted module instead.
127
+ const MODULE_SRC = readFileSync(
128
+ resolve(__dirname, '..', 'gateway', 'bot-commands-model-effort.ts'),
129
+ 'utf8',
130
+ )
131
+ const idx = MODULE_SRC.indexOf('const reply = await handleModelCommand(parsed, cmdDeps)')
126
132
  expect(idx).toBeGreaterThan(0)
127
- const win = GATEWAY_SRC.slice(idx, idx + 400)
133
+ const win = MODULE_SRC.slice(idx, idx + 400)
128
134
  expect(win).not.toContain('recordTypedModelSwitch')
129
135
  expect(win).toContain('switchroomReply(ctx, reply.text')
130
136
  })
@@ -28,6 +28,10 @@ import { dirname, resolve } from 'node:path'
28
28
 
29
29
  const __dirname = dirname(fileURLToPath(import.meta.url))
30
30
  const GATEWAY_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf8')
31
+ // P8 PR-D: the isSteering classification body moved verbatim to
32
+ // turn-start-surfaces.ts; the gateway call site (armTurnStartSurfaces) is the
33
+ // ordering anchor now.
34
+ const TURN_START_SRC = readFileSync(resolve(__dirname, '..', 'gateway', 'turn-start-surfaces.ts'), 'utf8')
31
35
 
32
36
  /** Byte offset of the sole `shadowEmit({ kind: 'inbound' ... })` call. */
33
37
  function inboundEmitOffset(): number {
@@ -50,21 +54,28 @@ describe('gateway: inbound machine-emit is deferred past the intercepts', () =>
50
54
  const emitIdx = inboundEmitOffset()
51
55
  // The permission-reply intercept is one of the early-return paths that
52
56
  // must NOT drive the machine into a turn.
53
- const permIdx = GATEWAY_SRC.indexOf('const permMatch = PERMISSION_REPLY_RE.exec(text)')
57
+ // P7 PR-4: the intercept body moved to inbound-interceptors.ts; the
58
+ // gateway call site is the ordering anchor now.
59
+ const permIdx = GATEWAY_SRC.indexOf('interceptPermissionReply({ text, chat_id, msgId }')
54
60
  expect(permIdx).toBeGreaterThan(0)
55
61
  expect(emitIdx).toBeGreaterThan(permIdx)
56
62
  })
57
63
 
58
64
  it('emits AFTER the `/auth add` paste-back intercept', () => {
59
65
  const emitIdx = inboundEmitOffset()
60
- const authIdx = GATEWAY_SRC.indexOf('pendingAuthAddFlows.get(interceptKey)')
66
+ // P7 PR-5: the intercept body moved to inbound-interceptors.ts; the
67
+ // gateway call site is the ordering anchor now.
68
+ const authIdx = GATEWAY_SRC.indexOf('interceptAuthAdd(')
61
69
  expect(authIdx).toBeGreaterThan(0)
62
70
  expect(emitIdx).toBeGreaterThan(authIdx)
63
71
  })
64
72
 
65
73
  it('emits AFTER isSteering is classified, and passes the real value (not a hardcoded false)', () => {
66
74
  const emitIdx = inboundEmitOffset()
67
- const steerIdx = GATEWAY_SRC.indexOf('isSteering = priorTurnInFlight && isSteerPrefix')
75
+ // P8 PR-D: classification lives in turn-start-surfaces.ts; the gateway
76
+ // ordering anchor is the armTurnStartSurfaces call that computes it.
77
+ expect(TURN_START_SRC).toContain('isSteering = priorTurnInFlight && isSteerPrefix')
78
+ const steerIdx = GATEWAY_SRC.indexOf('turnStartSurfaces().armTurnStartSurfaces({')
68
79
  expect(steerIdx).toBeGreaterThan(0)
69
80
  expect(emitIdx).toBeGreaterThan(steerIdx)
70
81
  // The emit's msg object must forward the live `isSteering` binding, so a
@@ -39,30 +39,62 @@ const SRC = readFileSync(
39
39
  'utf8',
40
40
  )
41
41
 
42
+ // The media-envelope handlers were extracted out of gateway.ts into their own
43
+ // module (switchroom#2996 P6 cluster A). gateway.ts keeps the `bot.on(…)`
44
+ // registration lines (their order is a load-bearing invariant) but each now
45
+ // delegates to a `handle<Kind>Message(ctx, mediaEnvelopeDeps)` function that
46
+ // lives here. The structural assertions below still need to see the real
47
+ // handler body, so we resolve a delegation to the extracted source.
48
+ const MEDIA_SRC = readFileSync(
49
+ new URL('../gateway/media-message-handlers.ts', import.meta.url),
50
+ 'utf8',
51
+ )
52
+
42
53
  // ─── Helpers ─────────────────────────────────────────────────────────────
43
54
 
44
55
  /**
45
- * Extract the body of a `bot.on('message:<kind>', …)` handler. Returns
46
- * the substring from the `bot.on(` line up to the matching closing
47
- * `})` at the outer scope. Good enough for grepping not a full
48
- * AST parse.
56
+ * Return the substring of `src` starting at `header` up to the matching
57
+ * closing brace of the block it opens. Naive depth count of {/} from the
58
+ * first `{` good enough for grepping, not a full AST parse.
49
59
  */
50
- function handlerBody(kind: string): string {
51
- const needle = `bot.on('message:${kind}'`
52
- const start = SRC.indexOf(needle)
53
- expect(start, `handler bot.on('message:${kind}') not found`).toBeGreaterThan(0)
54
- // Find the matching close — naive depth count of {/} from the first `{`.
55
- const firstBrace = SRC.indexOf('{', start)
60
+ function braceBlock(src: string, header: string, label: string): string {
61
+ const start = src.indexOf(header)
62
+ expect(start, `${label} not found`).toBeGreaterThan(0)
63
+ const firstBrace = src.indexOf('{', start)
56
64
  let depth = 0
57
- for (let i = firstBrace; i < SRC.length; i++) {
58
- const c = SRC[i]
65
+ for (let i = firstBrace; i < src.length; i++) {
66
+ const c = src[i]
59
67
  if (c === '{') depth++
60
68
  else if (c === '}') {
61
69
  depth--
62
- if (depth === 0) return SRC.slice(start, i + 1)
70
+ if (depth === 0) return src.slice(start, i + 1)
63
71
  }
64
72
  }
65
- throw new Error(`could not find end of handler ${kind}`)
73
+ throw new Error(`could not find end of ${label}`)
74
+ }
75
+
76
+ /**
77
+ * Extract the body of a `bot.on('message:<kind>', …)` handler. If the
78
+ * registration delegates to an extracted `handle<Kind>Message(ctx,
79
+ * mediaEnvelopeDeps)` function, the returned body is that function's source
80
+ * (from media-message-handlers.ts). Otherwise it is the inline gateway.ts
81
+ * handler block. Either way callers see the real handler logic.
82
+ */
83
+ function handlerBody(kind: string): string {
84
+ const needle = `bot.on('message:${kind}'`
85
+ const start = SRC.indexOf(needle)
86
+ expect(start, `handler bot.on('message:${kind}') not found`).toBeGreaterThan(0)
87
+ const lineEnd = SRC.indexOf('\n', start)
88
+ const regLine = SRC.slice(start, lineEnd < 0 ? SRC.length : lineEnd)
89
+ const delegate = regLine.match(/(handle\w+Message)\(ctx,\s*mediaEnvelopeDeps\)/)
90
+ if (delegate) {
91
+ return braceBlock(
92
+ MEDIA_SRC,
93
+ `export async function ${delegate[1]}(`,
94
+ `extracted handler ${delegate[1]}`,
95
+ )
96
+ }
97
+ return braceBlock(SRC, needle, `handler ${kind}`)
66
98
  }
67
99
 
68
100
  // ─── Registration completeness ───────────────────────────────────────────
@@ -108,7 +140,9 @@ describe('inbound message-type handlers: forwarding decisions', () => {
108
140
  it(`${kind} forwards via handleInbound and logs to stderr`, () => {
109
141
  const body = handlerBody(kind)
110
142
  expect(body).toMatch(/handleInbound\(ctx,/)
111
- expect(body).toMatch(/process\.stderr\.write/)
143
+ // Logs the event — directly (inline handlers) or via the injected
144
+ // `deps.log` stderr sink (extracted media-envelope handlers).
145
+ expect(body).toMatch(/process\.stderr\.write|deps\.log\(/)
112
146
  // Must not divert to the ack-only path — that would silently
113
147
  // hide the payload from the agent.
114
148
  expect(body).not.toMatch(/handleAckOnly\(/)
@@ -148,7 +182,9 @@ describe('inbound message-type handlers: ack-only decisions', () => {
148
182
  it(`${kind} uses handleAckOnly (no forward to agent)`, () => {
149
183
  const body = handlerBody(kind)
150
184
  expect(body).toMatch(/handleAckOnly\(/)
151
- expect(body).toMatch(/process\.stderr\.write/)
185
+ // Logs the event — directly (inline handlers) or via the injected
186
+ // `deps.log` stderr sink (extracted media-envelope handlers).
187
+ expect(body).toMatch(/process\.stderr\.write|deps\.log\(/)
152
188
  // Ack-only must NOT call handleInbound — the whole point is
153
189
  // we don't bother the agent for these.
154
190
  expect(body).not.toMatch(/handleInbound\(/)