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,114 @@
1
+ /**
2
+ * Outcome pins for the photo inbound handler extracted from gateway.ts
3
+ * (switchroom#2996 P6 cluster D). Unlike the metadata-only attachment handlers,
4
+ * photo downloads the largest size from the Telegram CDN into the inbox and
5
+ * returns the on-disk path as the coalesced image. The download closure is
6
+ * driven here against a fake grammy ctx + fetch, pinning: caption-or-fallback
7
+ * envelope, largest-size selection, inbox write, and token redaction on error.
8
+ */
9
+
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
11
+ import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { handlePhotoMessage, type PhotoHandlerDeps } from '../gateway/photo-message-handler.js'
15
+
16
+ const TOKEN = '123:SECRET-TOKEN'
17
+ let inboxDir: string
18
+
19
+ function makeDeps(overrides: Partial<PhotoHandlerDeps> = {}) {
20
+ const logs: string[] = []
21
+ // capture the download closure passed to handleInboundCoalesced so the test
22
+ // can invoke it and assert the returned path.
23
+ let captured: { text: string; download?: () => Promise<string | undefined> } | undefined
24
+ const handleInboundCoalesced = vi.fn(async (_ctx: any, text: string, download: any) => {
25
+ captured = { text, download }
26
+ })
27
+ const deps: PhotoHandlerDeps = {
28
+ handleInboundCoalesced,
29
+ getToken: () => TOKEN,
30
+ inboxDir,
31
+ log: line => logs.push(line),
32
+ ...overrides,
33
+ }
34
+ return { deps, handleInboundCoalesced, logs, getCaptured: () => captured }
35
+ }
36
+
37
+ function ctxWith(photo: unknown[], caption?: string, getFile?: any): any {
38
+ return {
39
+ message: { photo, caption },
40
+ api: { getFile: getFile ?? vi.fn() },
41
+ chat: { id: 42 },
42
+ }
43
+ }
44
+
45
+ beforeEach(() => {
46
+ inboxDir = mkdtempSync(join(tmpdir(), 'photo-inbox-'))
47
+ })
48
+ afterEach(() => {
49
+ rmSync(inboxDir, { recursive: true, force: true })
50
+ vi.restoreAllMocks()
51
+ })
52
+
53
+ describe('handlePhotoMessage', () => {
54
+ it('uses the caption as the envelope, falling back to (photo)', async () => {
55
+ const { deps, handleInboundCoalesced } = makeDeps()
56
+ await handlePhotoMessage(ctxWith([{ file_id: 'p1', file_unique_id: 'u1' }]), deps)
57
+ expect(handleInboundCoalesced.mock.calls[0][1]).toBe('(photo)')
58
+
59
+ const { deps: deps2, handleInboundCoalesced: h2 } = makeDeps()
60
+ await handlePhotoMessage(ctxWith([{ file_id: 'p1', file_unique_id: 'u1' }], 'look'), deps2)
61
+ expect(h2.mock.calls[0][1]).toBe('look')
62
+ })
63
+
64
+ it('downloads the largest size and writes it into the inbox', async () => {
65
+ const getFile = vi.fn(async () => ({ file_path: 'photos/big.jpg' }))
66
+ const ctx = ctxWith(
67
+ [{ file_id: 'small', file_unique_id: 'us' }, { file_id: 'big', file_unique_id: 'ub' }],
68
+ undefined,
69
+ getFile,
70
+ )
71
+ const { deps, getCaptured } = makeDeps()
72
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
73
+ new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
74
+ )
75
+ await handlePhotoMessage(ctx, deps)
76
+ const dlPath = await getCaptured()!.download!()
77
+ // Largest (last) size selected.
78
+ expect(getFile).toHaveBeenCalledWith('big')
79
+ // Token embedded in the CDN URL.
80
+ expect(fetchSpy.mock.calls[0][0]).toContain(`/bot${TOKEN}/photos/big.jpg`)
81
+ expect(dlPath).toBeDefined()
82
+ expect(existsSync(dlPath!)).toBe(true)
83
+ expect(Array.from(readFileSync(dlPath!))).toEqual([1, 2, 3])
84
+ })
85
+
86
+ it('returns undefined and logs HTTP status on a failed download', async () => {
87
+ const getFile = vi.fn(async () => ({ file_path: 'photos/x.jpg' }))
88
+ const { deps, getCaptured, logs } = makeDeps()
89
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 502 }))
90
+ await handlePhotoMessage(ctxWith([{ file_id: 'p', file_unique_id: 'u' }], undefined, getFile), deps)
91
+ const dlPath = await getCaptured()!.download!()
92
+ expect(dlPath).toBeUndefined()
93
+ expect(logs.some(l => l.includes('HTTP 502'))).toBe(true)
94
+ })
95
+
96
+ it('redacts the bot token from a thrown error message', async () => {
97
+ const getFile = vi.fn(async () => ({ file_path: 'photos/x.jpg' }))
98
+ const { deps, getCaptured, logs } = makeDeps()
99
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error(`connect failed to bot${TOKEN}/x`))
100
+ await handlePhotoMessage(ctxWith([{ file_id: 'p', file_unique_id: 'u' }], undefined, getFile), deps)
101
+ const dlPath = await getCaptured()!.download!()
102
+ expect(dlPath).toBeUndefined()
103
+ const line = logs.find(l => l.startsWith('telegram gateway: photo download failed:'))!
104
+ expect(line).toContain('<REDACTED>')
105
+ expect(line).not.toContain(TOKEN)
106
+ })
107
+
108
+ it('returns undefined when the file has no path', async () => {
109
+ const getFile = vi.fn(async () => ({}))
110
+ const { deps, getCaptured } = makeDeps()
111
+ await handlePhotoMessage(ctxWith([{ file_id: 'p', file_unique_id: 'u' }], undefined, getFile), deps)
112
+ expect(await getCaptured()!.download!()).toBeUndefined()
113
+ })
114
+ })
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Outcome pins for the `message:pinned_message` service-message cleanup handler
3
+ * extracted from gateway.ts (switchroom#2996 P6 cluster F). Asserts the
4
+ * chat-scoped ownership guard (only OUR pins get their service message
5
+ * deleted), the reconcile-store race retry, and best-effort logging on a
6
+ * delete failure — against injected pin-state maps + a mock deleteServiceMessage.
7
+ */
8
+
9
+ import { describe, it, expect, vi } from 'vitest'
10
+ import {
11
+ handlePinnedMessage,
12
+ type PinnedMessageHandlerDeps,
13
+ } from '../gateway/pinned-message-handler.js'
14
+
15
+ function makeDeps(over: Partial<PinnedMessageHandlerDeps> = {}) {
16
+ const deleteServiceMessage = vi.fn(async () => {})
17
+ const log = vi.fn()
18
+ const deps: PinnedMessageHandlerDeps = {
19
+ statusPinState: new Map(),
20
+ statusPinChatIds: new Map(),
21
+ deleteServiceMessage,
22
+ log,
23
+ ...over,
24
+ }
25
+ return { deps, deleteServiceMessage, log }
26
+ }
27
+
28
+ function ctxWith(chatId: number, pinnedMessageId: number | undefined, serviceMsgId = 999): any {
29
+ return {
30
+ msg: {
31
+ message_id: serviceMsgId,
32
+ pinned_message: pinnedMessageId == null ? undefined : { message_id: pinnedMessageId },
33
+ },
34
+ chat: { id: chatId },
35
+ }
36
+ }
37
+
38
+ describe('handlePinnedMessage — ownership guard', () => {
39
+ it('deletes the service message when the pin is ours in this chat', async () => {
40
+ const { deps, deleteServiceMessage } = makeDeps({
41
+ statusPinState: new Map([['fg:a', { messageId: 555 }]]),
42
+ statusPinChatIds: new Map([['fg:a', '42']]),
43
+ })
44
+ await handlePinnedMessage(ctxWith(42, 555, 999), deps)
45
+ expect(deleteServiceMessage).toHaveBeenCalledWith('42', 999)
46
+ })
47
+
48
+ it('does NOT delete when the pinned id matches but the tracked chat differs', async () => {
49
+ const { deps, deleteServiceMessage } = makeDeps({
50
+ statusPinState: new Map([['fg:a', { messageId: 555 }]]),
51
+ statusPinChatIds: new Map([['fg:a', '99']]), // tracked in a DIFFERENT chat
52
+ })
53
+ await handlePinnedMessage(ctxWith(42, 555, 999), deps)
54
+ expect(deleteServiceMessage).not.toHaveBeenCalled()
55
+ })
56
+
57
+ it('does nothing when there is no pinned_message id', async () => {
58
+ const { deps, deleteServiceMessage } = makeDeps()
59
+ await handlePinnedMessage(ctxWith(42, undefined), deps)
60
+ expect(deleteServiceMessage).not.toHaveBeenCalled()
61
+ })
62
+
63
+ it('does not delete an untracked (operator-manual) pin', async () => {
64
+ const { deps, deleteServiceMessage } = makeDeps()
65
+ await handlePinnedMessage(ctxWith(42, 555, 999), deps)
66
+ expect(deleteServiceMessage).not.toHaveBeenCalled()
67
+ })
68
+ })
69
+
70
+ describe('handlePinnedMessage — reconcile-store race', () => {
71
+ it('re-checks ownership once after the store catches up', async () => {
72
+ // REAL timers on purpose: this suite also runs under `bun test`, whose
73
+ // vitest shim does not support vi.useFakeTimers — fake timers here left
74
+ // the global clock mocked and hung the NEXT test file in CI (the exact
75
+ // #3354 bun-test failure). The handler waits 250ms before its single
76
+ // re-check; populate the store inside that window and await the real
77
+ // delay.
78
+ const state = new Map<string, { messageId: number }>()
79
+ const chatIds = new Map<string, string>()
80
+ const { deps, deleteServiceMessage } = makeDeps({
81
+ statusPinState: state,
82
+ statusPinChatIds: chatIds,
83
+ })
84
+ const p = handlePinnedMessage(ctxWith(42, 555, 999), deps)
85
+ // Store catches up during the 250ms race window.
86
+ state.set('fg:a', { messageId: 555 })
87
+ chatIds.set('fg:a', '42')
88
+ await p
89
+ expect(deleteServiceMessage).toHaveBeenCalledWith('42', 999)
90
+ })
91
+ })
92
+
93
+ describe('handlePinnedMessage — best-effort delete failure', () => {
94
+ it('logs a concise reason when the delete throws', async () => {
95
+ const { deps, log } = makeDeps({
96
+ statusPinState: new Map([['fg:a', { messageId: 555 }]]),
97
+ statusPinChatIds: new Map([['fg:a', '42']]),
98
+ deleteServiceMessage: vi.fn(async () => {
99
+ throw new Error('not enough rights')
100
+ }),
101
+ })
102
+ await handlePinnedMessage(ctxWith(42, 555, 999), deps)
103
+ expect(log).toHaveBeenCalledTimes(1)
104
+ const line = log.mock.calls[0][0] as string
105
+ expect(line).toContain('could not delete pin service message')
106
+ expect(line).toContain('not enough rights')
107
+ })
108
+ })
@@ -143,6 +143,48 @@ describe("render: inline palette", () => {
143
143
  });
144
144
  });
145
145
 
146
+ describe("render: line-leading issue refs are not headings (#3304 regression)", () => {
147
+ it("multi-line message whose first line starts with #3304 stays a paragraph", () => {
148
+ const md = "#3304 is merged. The regression suite is green.\n\nNext up: the follow-up PR.";
149
+ const doc = parse(md);
150
+ // Spec-correct parse: `#` without a following space is NOT an ATX heading.
151
+ expect(doc.blocks.map((b) => b.type)).toEqual(["paragraph", "paragraph"]);
152
+
153
+ const out = render(doc);
154
+ // The line-leading `#` must be escaped so Telegram's non-spec rich parser
155
+ // cannot promote the line to a heading...
156
+ expect(out.startsWith("\\#3304 is merged.")).toBe(true);
157
+ expect(out).not.toMatch(/^#{1,6} /m);
158
+ // ...and the escape resolves back to the literal "#3304" on re-parse
159
+ // (what the reader sees), with no heading node anywhere.
160
+ const reparsed = parse(out);
161
+ expect(reparsed.blocks.map((b) => b.type)).toEqual(["paragraph", "paragraph"]);
162
+ expect((reparsed.blocks[0] as any).children[0].text).toContain("#3304 is merged.");
163
+ });
164
+
165
+ it("issue ref at line start mid-message is escaped too", () => {
166
+ const md = "Shipped today:\n#123 closes the loop on retries.";
167
+ const out = render(parse(md));
168
+ expect(out).toContain("\n\\#123 closes the loop");
169
+ const reparsed = parse(out);
170
+ expect(reparsed.blocks.every((b) => b.type !== "heading")).toBe(true);
171
+ });
172
+
173
+ it("a genuine `# Heading` still renders as a heading", () => {
174
+ const md = "# Release notes\n\n#456 fixed the flake.";
175
+ const out = render(parse(md));
176
+ expect(out.startsWith("# Release notes")).toBe(true);
177
+ expect(out).toContain("\\#456 fixed the flake.");
178
+ const reparsed = parse(out);
179
+ expect(reparsed.blocks[0].type).toBe("heading");
180
+ });
181
+
182
+ it("mid-line `#` is left alone", () => {
183
+ const md = "merged in PR #789 yesterday";
184
+ expect(render(parse(md))).toBe(md);
185
+ });
186
+ });
187
+
146
188
  describe("render: block palette", () => {
147
189
  it("heading levels 1-6", () => {
148
190
  for (let level = 1; level <= 6; level++) {
@@ -31,10 +31,14 @@ import { describe, it, expect } from "vitest";
31
31
  import { readFileSync } from "node:fs";
32
32
  import { resolve } from "node:path";
33
33
 
34
- const gatewaySrc = readFileSync(
35
- resolve(__dirname, "..", "gateway", "gateway.ts"),
36
- "utf-8",
37
- );
34
+ const gatewaySrc =
35
+ readFileSync(resolve(__dirname, "..", "gateway", "gateway.ts"), "utf-8") +
36
+ "\n" +
37
+ // P7 PR-9 (#2996): the secret-detect pipeline intercept moved verbatim into
38
+ // gateway/inbound-interceptors.ts (interceptSecretDetectPipeline); the
39
+ // FAIL-CLOSED block anchor now resolves there. deleteSensitiveMessage is
40
+ // injected via deps but every branch still routes through it.
41
+ readFileSync(resolve(__dirname, "..", "gateway", "inbound-interceptors.ts"), "utf-8");
38
42
 
39
43
  /** Slice the gateway source between two anchor strings. */
40
44
  function sliceBetween(src: string, from: string, to: string): string {
@@ -19,10 +19,20 @@ import { readFileSync } from 'node:fs'
19
19
  * bytes directly.
20
20
  */
21
21
  describe('secret-detect pipeline fail-closed contract (gateway)', () => {
22
- const src = readFileSync(
22
+ // P7 PR-9 (#2996): the pipeline intercept (incl. the fail-closed catch)
23
+ // moved verbatim into gateway/inbound-interceptors.ts
24
+ // (interceptSecretDetectPipeline). The catch now returns `{ handled: true }`
25
+ // and the gateway call site early-returns on `handled` BEFORE the sinks —
26
+ // the contract is unchanged, split across the seam.
27
+ const gatewaySrc = readFileSync(
23
28
  new URL('../gateway/gateway.ts', import.meta.url),
24
29
  'utf8',
25
30
  )
31
+ const interceptorsSrc = readFileSync(
32
+ new URL('../gateway/inbound-interceptors.ts', import.meta.url),
33
+ 'utf8',
34
+ )
35
+ const src = gatewaySrc + '\n' + interceptorsSrc
26
36
 
27
37
  it('has exactly one [secret-detect] pipeline error catch marker', () => {
28
38
  const matches = src.match(/\[secret-detect\] pipeline error/g) ?? []
@@ -30,25 +40,23 @@ describe('secret-detect pipeline fail-closed contract (gateway)', () => {
30
40
  })
31
41
 
32
42
  it('the catch block around runPipeline returns (does not fall through to recordInbound / broadcast)', () => {
33
- const idx = src.indexOf('[secret-detect] pipeline error')
43
+ // Interceptor side: after the error marker, the catch must return
44
+ // `{ handled: true }` (drop) — never fall out of the catch body.
45
+ const idx = interceptorsSrc.indexOf('[secret-detect] pipeline error')
34
46
  expect(idx).toBeGreaterThan(0)
35
-
36
- // The fail-closed `return` must appear BEFORE the next recordInbound()
37
- // and ipcServer.broadcast() calls. We scan forward from the error-log
38
- // marker for the first top-level `return` statement (indented by 4
39
- // spaces, matching the handleInbound body style).
40
- const tail = src.slice(idx)
41
- const returnMatch = tail.match(/\n {4}return\b/)
42
- expect(returnMatch).not.toBeNull()
43
- const returnOffset = returnMatch!.index!
44
- const recordOffset = tail.indexOf('recordInbound(')
45
- const broadcastOffset = tail.indexOf('ipcServer.broadcast(inboundMsg)')
46
- // Both sinks are further down the function body; the fail-closed
47
- // return must come first.
48
- expect(recordOffset).toBeGreaterThan(0)
49
- expect(broadcastOffset).toBeGreaterThan(0)
50
- expect(returnOffset).toBeLessThan(recordOffset)
51
- expect(returnOffset).toBeLessThan(broadcastOffset)
47
+ const tail = interceptorsSrc.slice(idx, idx + 2000)
48
+ expect(tail).toMatch(/\n\s*return \{ handled: true \}/)
49
+ // Gateway side: the delegation early-returns on `handled` BEFORE both
50
+ // sinks, so a dropped message can never reach them.
51
+ const callIdx = gatewaySrc.indexOf('await interceptSecretDetectPipeline(')
52
+ expect(callIdx).toBeGreaterThan(0)
53
+ const afterCall = gatewaySrc.slice(callIdx)
54
+ const handledReturn = afterCall.indexOf('if (secretOutcome.handled) return')
55
+ const recordOffset = afterCall.indexOf('recordInbound(')
56
+ const broadcastOffset = afterCall.indexOf('ipcServer.broadcast(inboundMsg)')
57
+ expect(handledReturn).toBeGreaterThan(0)
58
+ expect(recordOffset).toBeGreaterThan(handledReturn)
59
+ expect(broadcastOffset).toBeGreaterThan(handledReturn)
52
60
  })
53
61
 
54
62
  it('catch body warns the user that the message was dropped', () => {
@@ -58,15 +66,17 @@ describe('secret-detect pipeline fail-closed contract (gateway)', () => {
58
66
  })
59
67
 
60
68
  it('no path exists where recordInbound or broadcast is called with the raw effectiveText after a pipeline throw', () => {
61
- // Structural guarantee: between the catch marker and the next sink
62
- // there must be a `return` otherwise a thrown pipeline error could
63
- // flow through.
64
- const catchIdx = src.indexOf('[secret-detect] pipeline error')
65
- const afterCatch = src.slice(catchIdx)
66
- const recordIdx = afterCatch.indexOf('recordInbound(')
67
- expect(recordIdx).toBeGreaterThan(0)
68
- const gap = afterCatch.slice(0, recordIdx)
69
- expect(gap).toMatch(/\n\s*return\b/)
69
+ // Structural guarantee across the seam: the interceptor catch returns
70
+ // handled:true, and the gateway adopts the scrubbed effectiveText only
71
+ // on the handled:false path — there is no branch that reaches the sinks
72
+ // after a throw. Pin: the catch body ends in `return { handled: true }`
73
+ // with no intervening effectiveText pass-through.
74
+ const catchIdx = interceptorsSrc.indexOf('[secret-detect] pipeline error')
75
+ expect(catchIdx).toBeGreaterThan(0)
76
+ const catchTail = interceptorsSrc.slice(catchIdx, catchIdx + 2000)
77
+ const returnIdx = catchTail.search(/\n\s*return \{ handled: true \}/)
78
+ expect(returnIdx).toBeGreaterThan(0)
79
+ expect(catchTail.slice(0, returnIdx)).not.toMatch(/handled: false/)
70
80
  })
71
81
 
72
82
  it('server.ts no longer contains the secret-detect intercept', () => {
@@ -98,10 +98,12 @@ describe('anthropic_oauth_code pattern — Channel A', () => {
98
98
  // ─── Channel B — context rule structural tests ────────────────────────────────
99
99
 
100
100
  describe('auth-flow context rule — Channel B (structural wiring in gateway.ts)', () => {
101
- const src = readFileSync(
102
- new URL('../gateway/gateway.ts', import.meta.url),
103
- 'utf8',
104
- )
101
+ const src =
102
+ readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8') +
103
+ '\n' +
104
+ // P7 PR-9 (#2996): the secret-detect pipeline intercept moved verbatim
105
+ // into gateway/inbound-interceptors.ts (interceptSecretDetectPipeline).
106
+ readFileSync(new URL('../gateway/inbound-interceptors.ts', import.meta.url), 'utf8')
105
107
 
106
108
  it('declares awaitingAuthCodeAt map and AUTH_CODE_CONTEXT_TTL_MS constant', () => {
107
109
  expect(src).toMatch(/const awaitingAuthCodeAt = new Map<string, number>/)
@@ -121,7 +123,7 @@ describe('auth-flow context rule — Channel B (structural wiring in gateway.ts)
121
123
 
122
124
  it('clears awaitingAuthCodeAt (delete) in the inbound handler when the flag is active', () => {
123
125
  // The inbound handler must call delete after reading the flag
124
- expect(src).toMatch(/awaitingAuthCodeAt\.delete\(chat_id\)/)
126
+ expect(src).toMatch(/awaitingAuthCodeAt\.delete\((?:p\.)?chat_id\)/)
125
127
  })
126
128
 
127
129
  it('checks isAuthFlowContext in the secret-detect block (passphrase path)', () => {
@@ -155,7 +157,9 @@ describe('auth-flow context rule — Channel B (structural wiring in gateway.ts)
155
157
  })
156
158
 
157
159
  it('auth-flow context rule sits BEFORE recordInbound() and broadcast()', () => {
158
- const contextIdx = src.indexOf('isAuthFlowContext')
160
+ // P7 PR-9: the context rule lives inside interceptSecretDetectPipeline;
161
+ // its gateway CALL SITE is the ordering anchor against the sinks.
162
+ const contextIdx = src.indexOf('await interceptSecretDetectPipeline(')
159
163
  const recordIdx = src.indexOf('recordInbound(', contextIdx)
160
164
  const broadcastIdx = src.indexOf('ipcServer.broadcast(inboundMsg)', contextIdx)
161
165
  expect(contextIdx).toBeGreaterThan(0)
@@ -191,8 +195,12 @@ describe('anthropic_oauth_code pattern — URL false-positive regression', () =>
191
195
  // Test 2: Blocker 1 sequencing — structural check that deleteMessage is called
192
196
  // inside the pendingReauthFlows intercept path, before setMessageReaction.
193
197
  describe('pendingReauthFlows intercept — deleteMessage sequencing (Blocker 1)', () => {
198
+ // P7 PR-7 (#2996): the reauth intercept body moved verbatim into
199
+ // gateway/inbound-interceptors.ts (interceptReauth); the redaction is the
200
+ // injected deps.redactAuthCode closure over redactAuthCodeMessage +
201
+ // redactAuthCodeApi (bound in gateway.ts). Same pins, new file owner.
194
202
  const src = readFileSync(
195
- new URL('../gateway/gateway.ts', import.meta.url),
203
+ new URL('../gateway/inbound-interceptors.ts', import.meta.url),
196
204
  'utf8',
197
205
  )
198
206
 
@@ -202,7 +210,7 @@ describe('pendingReauthFlows intercept — deleteMessage sequencing (Blocker 1)'
202
210
  // `setMessageReaction(...)` literals, but #488 consolidated all 6
203
211
  // auth-code paste paths through `redactAuthCodeMessage`. The pin
204
212
  // is now: the intercept block must call the helper.
205
- const interceptIdx = src.indexOf('// Auth-code intercept')
213
+ const interceptIdx = src.indexOf('export async function interceptReauth(')
206
214
  expect(interceptIdx).toBeGreaterThan(0)
207
215
 
208
216
  // Find the end of this intercept block — the next blank line after
@@ -215,7 +223,7 @@ describe('pendingReauthFlows intercept — deleteMessage sequencing (Blocker 1)'
215
223
  // (optionally cast, #623), now the gated `redactAuthCodeApi` adapter whose
216
224
  // reaction routes through the send gate + flood breaker (#3155). `msgId`
217
225
  // may be narrowed (`msgId ?? null`).
218
- expect(window).toMatch(/redactAuthCodeMessage\((?:bot\.api|redactAuthCodeApi)(?:\s+as\s+\w+)?,\s*chat_id,\s*msgId(?:\s*\?\?\s*null)?(?:,\s*[^)]+)?\)/)
226
+ expect(window).toMatch(/deps\.redactAuthCode\(p\.chat_id,\s*p\.msgId(?:\s*\?\?\s*null)?\)/)
219
227
  })
220
228
 
221
229
  it('redaction lands AFTER the success/error reply renders', () => {
@@ -224,10 +232,10 @@ describe('pendingReauthFlows intercept — deleteMessage sequencing (Blocker 1)'
224
232
  // even if their original message disappears mid-render. Same
225
233
  // ordering the helper preserves — fire-and-forget redaction
226
234
  // happens after `await switchroomReply(...)`.
227
- const interceptIdx = src.indexOf('// Auth-code intercept')
235
+ const interceptIdx = src.indexOf('export async function interceptReauth(')
228
236
  const window = src.slice(interceptIdx, interceptIdx + 2000)
229
- const replyIdx = window.indexOf('switchroomReply(ctx,')
230
- const redactIdx = window.indexOf('redactAuthCodeMessage(')
237
+ const replyIdx = window.indexOf('deps.switchroomReply(')
238
+ const redactIdx = window.indexOf('deps.redactAuthCode(')
231
239
  expect(replyIdx).toBeGreaterThan(0)
232
240
  expect(redactIdx).toBeGreaterThan(0)
233
241
  expect(replyIdx).toBeLessThan(redactIdx)
@@ -237,10 +245,12 @@ describe('pendingReauthFlows intercept — deleteMessage sequencing (Blocker 1)'
237
245
  // Test 3: Flag consumption — awaitingAuthCodeAt is NOT cleared on a non-detection
238
246
  // inbound (a stray "ok" within the 5-min window should not disarm Channel B).
239
247
  describe('awaitingAuthCodeAt flag — consumption only on actual detection', () => {
240
- const src = readFileSync(
241
- new URL('../gateway/gateway.ts', import.meta.url),
242
- 'utf8',
243
- )
248
+ const src =
249
+ readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8') +
250
+ '\n' +
251
+ // P7 PR-9 (#2996): the secret-detect pipeline intercept moved verbatim
252
+ // into gateway/inbound-interceptors.ts (interceptSecretDetectPipeline).
253
+ readFileSync(new URL('../gateway/inbound-interceptors.ts', import.meta.url), 'utf8')
244
254
 
245
255
  it('delete(chat_id) does NOT appear at the top of the isAuthFlowContext block (no early consume)', () => {
246
256
  // The old bug: delete fired unconditionally inside `if (isAuthFlowContext)`.
@@ -261,7 +271,7 @@ describe('awaitingAuthCodeAt flag — consumption only on actual detection', ()
261
271
  expect(fallbackIdx).toBeGreaterThan(0)
262
272
  // Within 400 chars after the comment, delete must appear
263
273
  const window = src.slice(fallbackIdx, fallbackIdx + 400)
264
- expect(window).toMatch(/awaitingAuthCodeAt\.delete\(chat_id\)/)
274
+ expect(window).toMatch(/(?:deps\.)?awaitingAuthCodeAt\.delete\((?:p\.)?chat_id\)/)
265
275
  })
266
276
 
267
277
  it('awaitingAuthCodeAt.delete appears inside the no-passphrase hasHigh branch', () => {
@@ -272,7 +282,7 @@ describe('awaitingAuthCodeAt flag — consumption only on actual detection', ()
272
282
  const window = src.slice(noPpIdx, noPpIdx + 1200)
273
283
  // Conditional consume: only fires if isAuthFlowContext
274
284
  expect(window).toMatch(/if \(isAuthFlowContext\)/)
275
- expect(window).toMatch(/awaitingAuthCodeAt\.delete\(chat_id\)/)
285
+ expect(window).toMatch(/(?:deps\.)?awaitingAuthCodeAt\.delete\((?:p\.)?chat_id\)/)
276
286
  })
277
287
  })
278
288
 
@@ -24,6 +24,15 @@ import { readFileSync } from 'node:fs'
24
24
  import { resolve } from 'node:path'
25
25
 
26
26
  const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
27
+ // #2996 P4-A: the model-driven reset sites (tool_label append, answer-stream
28
+ // onMetric) live inside handleSessionEvent, which moved VERBATIM to
29
+ // stream-render.ts. There the live-turn guard reads the injected accessor
30
+ // (`getCurrentTurn() === turn`) rather than the `currentTurn` module global.
31
+ const streamSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'stream-render.ts'), 'utf-8')
32
+ // #2996 P4-B: drainActivitySummary / feedHeartbeatTick moved VERBATIM into
33
+ // narrative-lane.ts (factory scope, bodies indented +2; gateway keeps thin
34
+ // same-name wrappers) — the heartbeat-safety windows read the module source.
35
+ const laneSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'narrative-lane.ts'), 'utf-8')
27
36
 
28
37
  function between(src: string, startMarker: string, endMarker: string): string {
29
38
  const after = src.split(startMarker)[1] ?? ''
@@ -32,13 +41,13 @@ function between(src: string, startMarker: string, endMarker: string): string {
32
41
 
33
42
  describe('silence-poke production-liveness — heartbeat safety', () => {
34
43
  it('drainActivitySummary must NOT reset the silence clock (the framework heartbeat drains here)', () => {
35
- const body = between(gatewaySrc, 'async function drainActivitySummary', '\nfunction feedHeartbeatTick')
44
+ const body = between(laneSrc, 'async function drainActivitySummary', '\n function feedHeartbeatTick')
36
45
  expect(body.length).toBeGreaterThan(100) // sanity: the slice found the function body
37
46
  expect(body).not.toMatch(/noteProduction/)
38
47
  })
39
48
 
40
49
  it('feedHeartbeatTick itself must NOT reset the silence clock (model-independent re-render)', () => {
41
- const body = between(gatewaySrc, 'function feedHeartbeatTick(): void {', '\n}')
50
+ const body = between(laneSrc, 'function feedHeartbeatTick(): void {', '\n }')
42
51
  expect(body.length).toBeGreaterThan(50)
43
52
  expect(body).not.toMatch(/noteProduction/)
44
53
  })
@@ -47,18 +56,18 @@ describe('silence-poke production-liveness — heartbeat safety', () => {
47
56
  // appendActivityLabel returns a fresh render only when the model emits a NEW
48
57
  // labelled step — the genuine liveness signal the heartbeat can never forge.
49
58
  const block = between(
50
- gatewaySrc,
59
+ streamSrc,
51
60
  'const rendered = appendActivityLabel(turn.mirrorLines, ev.label)',
52
61
  '\n return',
53
62
  )
54
63
  expect(block).toMatch(/silencePoke\.noteProduction/)
55
- expect(block).toMatch(/currentTurn === turn/)
64
+ expect(block).toMatch(/getCurrentTurn\(\) === turn/)
56
65
  })
57
66
 
58
67
  it('the answer-stream draft onMetric reset is model-driven and gated on the live turn', () => {
59
- const block = between(gatewaySrc, 'onMetric: (metricEv) => {', '\n },')
68
+ const block = between(streamSrc, 'onMetric: (metricEv) => {', '\n },')
60
69
  expect(block).toMatch(/silencePoke\.noteProduction/)
61
- expect(block).toMatch(/currentTurn === turn/)
70
+ expect(block).toMatch(/getCurrentTurn\(\) === turn/)
62
71
  })
63
72
 
64
73
  it('production-liveness is behind the default-ON SWITCHROOM_SILENCE_LIVENESS_PRODUCTION kill switch', () => {
@@ -74,12 +83,17 @@ describe('silence-poke production-liveness — heartbeat safety', () => {
74
83
  // Structural guard so a refactor can't silently re-silence the re-ping
75
84
  // (the gateway IIFE can't be instantiated in-process — same pattern as the
76
85
  // heartbeat-safety assertions above).
77
- const block = between(gatewaySrc, 'onFrameworkFallback: async (ctx) => {', '\nfunction trackRedeliveredInbound')
86
+ // #2996 P8 PR-C3: the fallback body moved verbatim to liveness-wiring.ts;
87
+ // the send itself is the injected `sendSilenceText(chat, thread, text,
88
+ // silent)` closure (gateway keeps the bot-api touch), so the gate now
89
+ // rides the closure's `silent` argument at the callsite.
90
+ const livenessSrc = readFileSync(resolve(__dirname, '..', 'gateway', 'liveness-wiring.ts'), 'utf-8')
91
+ const block = between(livenessSrc, 'onFrameworkFallback: async (ctx) => {', '\n }\n}')
78
92
  expect(block.length).toBeGreaterThan(100) // sanity: slice found the handler body
79
93
  // The signal is derived once, hoisted above the update-status branch so it's
80
94
  // in scope at the send site.
81
95
  expect(block).toMatch(/const blockedOnApproval = activeStatusReactions/)
82
96
  // The send gates on it rather than hard-coding silent.
83
- expect(block).toMatch(/disable_notification: blockedOnApproval \? false : true/)
97
+ expect(block).toMatch(/sendSilenceText\(ctx\.chatId, ctx\.threadId \?\? null, text, blockedOnApproval \? false : true\)/)
84
98
  })
85
99
  })