switchroom 0.19.18 → 0.19.22

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 (76) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +56 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +5242 -2239
  7. package/dist/host-control/main.js +12241 -11375
  8. package/dist/vault/approvals/kernel-server.js +113 -7
  9. package/dist/vault/broker/server.js +259 -76
  10. package/package.json +6 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/skills/switchroom-release/SKILL.md +103 -20
  13. package/telegram-plugin/bridge/bridge.ts +14 -0
  14. package/telegram-plugin/card-format.ts +92 -3
  15. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
  17. package/telegram-plugin/dist/server.js +13 -0
  18. package/telegram-plugin/edit-flood-fuse.ts +477 -0
  19. package/telegram-plugin/format.ts +19 -7
  20. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  21. package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
  22. package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
  23. package/telegram-plugin/gateway/gateway.ts +66 -56
  24. package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
  25. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  26. package/telegram-plugin/gateway/narrative-lane.ts +49 -3
  27. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  28. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  29. package/telegram-plugin/gateway/status-pin-api.ts +145 -0
  30. package/telegram-plugin/gateway/store-file.ts +244 -0
  31. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
  32. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  33. package/telegram-plugin/retry-api-call.ts +15 -2
  34. package/telegram-plugin/send-gate.ts +1 -1
  35. package/telegram-plugin/status-no-truncate.ts +64 -1
  36. package/telegram-plugin/status-pin-driver.ts +50 -27
  37. package/telegram-plugin/status-pin.ts +43 -5
  38. package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
  39. package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
  40. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
  41. package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
  42. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  43. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  44. package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
  45. package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
  46. package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
  47. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
  48. package/telegram-plugin/tests/status-pin.test.ts +106 -5
  49. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  50. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
  51. package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
  52. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  53. package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
  54. package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
  55. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
  56. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
  57. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  58. package/telegram-plugin/tool-activity-summary.ts +85 -13
  59. package/telegram-plugin/worker-activity-feed.ts +56 -2
  60. package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
  61. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  62. package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
  63. package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
  64. package/vendor/hindsight-memory/scripts/recall.py +74 -5
  65. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  66. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  67. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
  68. package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
  69. package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
  70. package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
  71. package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
  72. package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
  73. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
  74. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  75. package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
  76. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Boot pin sweep WIRING fence (#3664 Defect A).
3
+ *
4
+ * The behaviour lives in `boot-sweep-gate.test.ts`; this file pins the gateway
5
+ * WIRING, because the regression was a wiring regression: `caa4d7568` (#3310)
6
+ * moved the `lockedBot` assignment into `initGatewayBot()` and left the sweep's
7
+ * kick-off at module-eval time. `tsc` stayed green (the `!` definite-assignment
8
+ * assertion on `lockedBot` suppresses the error) and no test noticed, so every
9
+ * boot-cleanup unpin threw `undefined is not an object` for a month.
10
+ *
11
+ * These are STRUCTURAL assertions (the gateway module can't be instantiated
12
+ * in-process — same pattern as silence-liveness-wiring.test.ts) that fail if a
13
+ * future refactor re-introduces a module-eval-time dispatch of the sweep.
14
+ */
15
+ import { describe, it, expect } from 'vitest'
16
+ import { readFileSync } from 'node:fs'
17
+ import { resolve } from 'node:path'
18
+
19
+ const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
20
+
21
+ describe('boot pin sweep wiring (#3664)', () => {
22
+ it('never dispatches runBootPinCleanupAndDmSweep() directly — only through the gate', () => {
23
+ // The original bug shape, verbatim: a fire-and-forget call at module-eval
24
+ // time. Any direct invocation (voided or awaited) bypasses the bot-ready
25
+ // half of the precondition.
26
+ const directCalls =
27
+ gatewaySrc.match(/(?<!function\s)(?<![.\w])runBootPinCleanupAndDmSweep\s*\(/g) ?? []
28
+ expect(directCalls).toEqual([])
29
+ })
30
+
31
+ it('passes the sweep to the gate as a reference, and arms it at both mutex sites', () => {
32
+ expect(gatewaySrc).toContain('createBootSweepGate({ run: runBootPinCleanupAndDmSweep')
33
+ // Both startup-lock outcomes (mutex acquired, and the non-atomic
34
+ // writePidFile fallback) arm — neither may dispatch on its own.
35
+ const armSites = gatewaySrc.match(/bootPinSweepGate\.arm\(\)/g) ?? []
36
+ expect(armSites.length).toBe(2)
37
+ })
38
+
39
+ it('signals botReady() only from initGatewayBot, AFTER lockedBot is assigned', () => {
40
+ const readySites = gatewaySrc.match(/bootPinSweepGate\.botReady\(\)/g) ?? []
41
+ expect(readySites.length).toBe(1)
42
+
43
+ const initStart = gatewaySrc.indexOf('async function initGatewayBot()')
44
+ const assignIdx = gatewaySrc.indexOf('lockedBot = chatLock.wrapBot(')
45
+ const readyIdx = gatewaySrc.indexOf('bootPinSweepGate.botReady()')
46
+ expect(initStart).toBeGreaterThan(-1)
47
+ expect(assignIdx).toBeGreaterThan(initStart)
48
+ expect(readyIdx).toBeGreaterThan(assignIdx)
49
+ })
50
+
51
+ it('runs the sweep steps through runBootPinSweepSteps, not a bare await chain', () => {
52
+ // Salvage S2. The BEHAVIOUR (a throwing step must not strand the steps
53
+ // behind it, and must never strand the DM-eligibility flip) is proven in
54
+ // boot-sweep-gate.test.ts. This fence is what makes that proof apply to the
55
+ // gateway: the reapers must reach the runner as injected steps, and nothing
56
+ // may reintroduce the bare sequential chain that skipped the isolation.
57
+ expect(gatewaySrc).toContain('return runBootPinSweepSteps({')
58
+ const stepFns = ['statusPinBootCleanup', 'activityCardBootReaper', 'queuedCardBootReaper']
59
+ for (const stepFn of stepFns) {
60
+ // Passed BY REFERENCE as a step, never invoked inline in the sweep.
61
+ expect(gatewaySrc).toContain(`: ${stepFn},`)
62
+ const inlineAwaits = gatewaySrc.match(new RegExp(`await\\s+${stepFn}\\s*\\(`, 'g')) ?? []
63
+ expect(inlineAwaits).toEqual([])
64
+ }
65
+ // The DM-eligibility flip is what a throw used to strand, so it must live
66
+ // INSIDE the enableDmSweep step callback — not as a statement sequenced
67
+ // after the reapers, where one rejection skips it for the whole session.
68
+ const lines = gatewaySrc.split('\n')
69
+ const flipIdxs = lines
70
+ .map((l, i) => ({ l, i }))
71
+ .filter(({ l }) => /^\s*dmPinSweepEligible = true\s*$/.test(l))
72
+ .map(({ i }) => i)
73
+ expect(flipIdxs.length).toBe(1)
74
+ expect(lines[flipIdxs[0] - 1]).toContain('enableDmSweep: () => {')
75
+ })
76
+
77
+ it('routes the pin API through the asserting seam, never a raw lockedBot.api pin', () => {
78
+ // Salvage S1/S3: `statusPinApi()` must build on `createStatusPinApi`, which
79
+ // asserts the bot exists and converts a send-gate SHED into a throw. A
80
+ // hand-rolled `robustApiCall(() => lockedBot.api.pinChatMessage(...))`
81
+ // anywhere in the gateway would bypass both invariants.
82
+ expect(gatewaySrc).toContain('return createStatusPinApi(')
83
+ // Every `status-pin.*` verb is now issued from status-pin-api.ts. A raw
84
+ // `robustApiCall(() => lockedBot.api.unpinChatMessage(…), { verb:
85
+ // 'status-pin.unpin' })` reappearing here would be a driver call that
86
+ // skips assertBotReady + assertLanded.
87
+ const statusPinVerbs = gatewaySrc.match(/verb:\s*['"`]status-pin\.(?:un)?pin['"`]/g) ?? []
88
+ expect(statusPinVerbs).toEqual([])
89
+ })
90
+
91
+ it('arms only from inside the startup-mutex block, never at bare module scope', () => {
92
+ // Every arm() site must be indented (i.e. nested inside the
93
+ // `if (isGatewayMain) { … }` startup-lock block), never a top-level
94
+ // statement that would run unconditionally on import.
95
+ for (const line of gatewaySrc.split('\n')) {
96
+ if (line.includes('bootPinSweepGate.arm()')) {
97
+ expect(line.startsWith(' ')).toBe(true)
98
+ }
99
+ }
100
+ })
101
+ })
@@ -0,0 +1,293 @@
1
+ /**
2
+ * boot-sweep-gate — the deterministic ordering fence for #3664 Defect A.
3
+ *
4
+ * The regression these assertions exist to catch: the boot orphan sweep was
5
+ * dispatched the moment the gateway won the startup mutex, which happens at
6
+ * module-eval time — thousands of lines before `initGatewayBot()` assigns
7
+ * `lockedBot`. Every boot-cleanup unpin therefore threw
8
+ * `undefined is not an object (evaluating 'lockedBot.api')` before reaching
9
+ * Telegram (14/14 fleet-wide failures since #3310, zero clean successes).
10
+ *
11
+ * The behavioural assertion is "arming ALONE must not dispatch the sweep" —
12
+ * a test that would have failed on the original bug shape.
13
+ */
14
+ import { describe, it, expect } from 'vitest'
15
+ import {
16
+ createBootSweepGate,
17
+ runBootPinSweepSteps,
18
+ type BootPinSweepSteps,
19
+ } from '../gateway/boot-sweep-gate.js'
20
+
21
+ /** A fake sweep that records whether it ran and what the bot handle looked
22
+ * like at that moment — the thing the original bug got wrong. */
23
+ function fakeSweep() {
24
+ const observed: (string | undefined)[] = []
25
+ let bot: string | undefined
26
+ return {
27
+ run: async () => {
28
+ observed.push(bot)
29
+ },
30
+ setBot: (b: string) => {
31
+ bot = b
32
+ },
33
+ get observed() {
34
+ return observed
35
+ },
36
+ }
37
+ }
38
+
39
+ describe('createBootSweepGate (#3664 Defect A)', () => {
40
+ it('does NOT dispatch on arm() alone — the bot handle does not exist yet', async () => {
41
+ const sweep = fakeSweep()
42
+ const gate = createBootSweepGate({ run: sweep.run })
43
+
44
+ gate.arm()
45
+ await Promise.resolve()
46
+
47
+ expect(gate.hasRun()).toBe(false)
48
+ expect(sweep.observed).toEqual([])
49
+ })
50
+
51
+ it('does NOT dispatch on botReady() alone — this boot may not own the pin state', async () => {
52
+ const sweep = fakeSweep()
53
+ const gate = createBootSweepGate({ run: sweep.run })
54
+
55
+ gate.botReady()
56
+ await Promise.resolve()
57
+
58
+ expect(gate.hasRun()).toBe(false)
59
+ expect(sweep.observed).toEqual([])
60
+ })
61
+
62
+ it('dispatches once BOTH signals arrive, and the bot handle is live when it does', async () => {
63
+ const sweep = fakeSweep()
64
+ const gate = createBootSweepGate({ run: sweep.run })
65
+
66
+ gate.arm() // startup mutex won (module-eval time)
67
+ await Promise.resolve()
68
+ expect(sweep.observed).toEqual([])
69
+
70
+ sweep.setBot('lockedBot') // initGatewayBot() assigns lockedBot …
71
+ gate.botReady() // … and signals the gate
72
+ await Promise.resolve()
73
+
74
+ // The sweep ran, and it saw a live bot handle — never `undefined`.
75
+ expect(sweep.observed).toEqual(['lockedBot'])
76
+ expect(gate.hasRun()).toBe(true)
77
+ })
78
+
79
+ it('is order-independent: botReady() before arm() still dispatches exactly once', async () => {
80
+ const sweep = fakeSweep()
81
+ const gate = createBootSweepGate({ run: sweep.run })
82
+
83
+ sweep.setBot('lockedBot')
84
+ gate.botReady()
85
+ await Promise.resolve()
86
+ expect(sweep.observed).toEqual([])
87
+
88
+ gate.arm()
89
+ await Promise.resolve()
90
+ expect(sweep.observed).toEqual(['lockedBot'])
91
+ })
92
+
93
+ it('dispatches at most once however many times the signals repeat', async () => {
94
+ const sweep = fakeSweep()
95
+ const gate = createBootSweepGate({ run: sweep.run })
96
+
97
+ sweep.setBot('lockedBot')
98
+ gate.arm()
99
+ gate.botReady()
100
+ gate.arm() // e.g. the mutex path AND the mutex-fallback path
101
+ gate.botReady()
102
+ await Promise.resolve()
103
+
104
+ expect(sweep.observed).toEqual(['lockedBot'])
105
+ })
106
+
107
+ it('routes a rejecting sweep to onError instead of leaking an unhandled rejection', async () => {
108
+ const rejections: unknown[] = []
109
+ const onUnhandled = (err: unknown) => rejections.push(err)
110
+ process.on('unhandledRejection', onUnhandled)
111
+ try {
112
+ const errs: unknown[] = []
113
+ const gate = createBootSweepGate({
114
+ run: async () => {
115
+ throw new Error('boom')
116
+ },
117
+ onError: (err) => errs.push(err),
118
+ })
119
+
120
+ gate.arm()
121
+ gate.botReady()
122
+ await new Promise((r) => setTimeout(r, 10))
123
+
124
+ expect((errs[0] as Error).message).toBe('boom')
125
+ expect(rejections).toEqual([])
126
+ } finally {
127
+ process.off('unhandledRejection', onUnhandled)
128
+ }
129
+ })
130
+ })
131
+
132
+ /**
133
+ * Per-step isolation (#3664 salvage S2).
134
+ *
135
+ * The steps used to be a bare sequential `await` chain in gateway.ts. A throw
136
+ * from either card reaper skipped `dmPinSweepEligible = true`, which authorises
137
+ * the DM unpin-all path for the WHOLE SESSION — so one throwing reaper silently
138
+ * disabled DM stale-pin cleanup, including every later lazy first-inbound
139
+ * sweep. That became newly reachable the moment #3664 made the sweep actually
140
+ * run for the first time.
141
+ *
142
+ * The outcome asserted is "was the DM path enabled and were the DM chats
143
+ * swept", not "was a wrapper called".
144
+ */
145
+ describe('runBootPinSweepSteps (#3664 salvage S2)', () => {
146
+ function deps(over: Partial<BootPinSweepSteps> = {}) {
147
+ const order: string[] = []
148
+ const logs: string[] = []
149
+ const base: BootPinSweepSteps = {
150
+ scanDmChatIds: () => {
151
+ order.push('scan')
152
+ return ['12345']
153
+ },
154
+ statusPinCleanup: async () => {
155
+ order.push('status-pins')
156
+ },
157
+ activityCardReaper: async () => {
158
+ order.push('activity-cards')
159
+ },
160
+ queuedCardReaper: async () => {
161
+ order.push('queued-cards')
162
+ },
163
+ enableDmSweep: () => order.push('enable-dm'),
164
+ sweepDm: async (id) => {
165
+ order.push(`dm:${id}`)
166
+ },
167
+ log: (l) => logs.push(l),
168
+ }
169
+ return { order, logs, deps: { ...base, ...over } }
170
+ }
171
+
172
+ it('runs the store scan first, then the reapers, then enables + runs the DM sweep', async () => {
173
+ const d = deps()
174
+ await runBootPinSweepSteps(d.deps)
175
+ expect(d.order).toEqual([
176
+ 'scan',
177
+ 'status-pins',
178
+ 'activity-cards',
179
+ 'queued-cards',
180
+ 'enable-dm',
181
+ 'dm:12345',
182
+ ])
183
+ })
184
+
185
+ it('a throwing reaper does not strand the steps behind it', async () => {
186
+ const d = deps({
187
+ activityCardReaper: async () => {
188
+ throw new Error('boom')
189
+ },
190
+ })
191
+ await runBootPinSweepSteps(d.deps)
192
+ // The DM path is still enabled and the recorded DM chat is still swept.
193
+ expect(d.order).toEqual(['scan', 'status-pins', 'queued-cards', 'enable-dm', 'dm:12345'])
194
+ expect(d.logs.join('')).toContain("step 'activity-card-reaper' failed: boom")
195
+ })
196
+
197
+ it('enables the DM sweep even when EVERY earlier step throws', async () => {
198
+ const boom = async () => {
199
+ throw new Error('boom')
200
+ }
201
+ const d = deps({
202
+ statusPinCleanup: boom,
203
+ activityCardReaper: boom,
204
+ queuedCardReaper: boom,
205
+ })
206
+ await runBootPinSweepSteps(d.deps)
207
+ expect(d.order).toEqual(['scan', 'enable-dm', 'dm:12345'])
208
+ })
209
+
210
+ it('a failing store scan still lets the reapers and the DM enable run', async () => {
211
+ const d = deps({
212
+ scanDmChatIds: () => {
213
+ throw new Error('ENOENT')
214
+ },
215
+ })
216
+ await runBootPinSweepSteps(d.deps)
217
+ // No DM ids to sweep, but nothing behind the scan is stranded.
218
+ expect(d.order).toEqual(['status-pins', 'activity-cards', 'queued-cards', 'enable-dm'])
219
+ expect(d.logs.join('')).toContain("step 'dm-chat-scan' failed: ENOENT")
220
+ })
221
+
222
+ it('one failing DM chat does not block the next one', async () => {
223
+ const swept: string[] = []
224
+ const d = deps({
225
+ scanDmChatIds: () => ['1', '2'],
226
+ sweepDm: async (id) => {
227
+ if (id === '1') throw new Error('kicked')
228
+ swept.push(id)
229
+ },
230
+ })
231
+ await runBootPinSweepSteps(d.deps)
232
+ expect(swept).toEqual(['2'])
233
+ })
234
+
235
+ it('logs a non-Error throw honestly instead of "undefined"', async () => {
236
+ const d = deps({
237
+ queuedCardReaper: async () => {
238
+ // A non-Error throw: `(err as Error).message` would log "undefined".
239
+ throw 'not an Error'
240
+ },
241
+ })
242
+ await runBootPinSweepSteps(d.deps)
243
+ expect(d.logs.join('')).toContain("step 'queued-card-reaper' failed: not an Error")
244
+ expect(d.logs.join('')).not.toContain('failed: undefined')
245
+ })
246
+
247
+ it('survives a log sink that itself throws', async () => {
248
+ // stderr can be gone on a dying process; the one function whose job is
249
+ // "a step must not take the sweep down" must not take the sweep down.
250
+ const order: string[] = []
251
+ await expect(
252
+ runBootPinSweepSteps({
253
+ scanDmChatIds: () => [],
254
+ statusPinCleanup: async () => {
255
+ throw new Error('boom')
256
+ },
257
+ activityCardReaper: async () => {
258
+ order.push('activity-cards')
259
+ },
260
+ queuedCardReaper: async () => {
261
+ order.push('queued-cards')
262
+ },
263
+ enableDmSweep: () => order.push('enable-dm'),
264
+ sweepDm: async () => {},
265
+ log: () => {
266
+ throw new Error('EPIPE')
267
+ },
268
+ }),
269
+ ).resolves.toBeUndefined()
270
+ expect(order).toEqual(['activity-cards', 'queued-cards', 'enable-dm'])
271
+ })
272
+
273
+ it('never rejects — the caller fire-and-forgets it', async () => {
274
+ const boom = async () => {
275
+ throw new Error('boom')
276
+ }
277
+ await expect(
278
+ runBootPinSweepSteps({
279
+ scanDmChatIds: () => {
280
+ throw new Error('boom')
281
+ },
282
+ statusPinCleanup: boom,
283
+ activityCardReaper: boom,
284
+ queuedCardReaper: boom,
285
+ enableDmSweep: () => {
286
+ throw new Error('boom')
287
+ },
288
+ sweepDm: boom,
289
+ log: () => {},
290
+ }),
291
+ ).resolves.toBeUndefined()
292
+ })
293
+ })
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { buildEffectiveToolSchemas } from '../bridge/tool-filter.js'
5
+
6
+ /**
7
+ * Half-wiring guard: every tool the gateway is willing to EXECUTE must be
8
+ * ADVERTISED by the bridge's MCP surface.
9
+ *
10
+ * The failure this pins (observed live 2026-07-25): `progress_update` was in
11
+ * the gateway's `ALLOWED_TOOLS` allowlist and had a full `executeProgressUpdate`
12
+ * implementation, but it was never carried over into `bridge/bridge.ts`'s
13
+ * `TOOL_SCHEMAS` when the legacy `server.ts` monolith was deleted (5496f792,
14
+ * Wave 3 F4). The monolith had registered it (7206817a); the bridge never did.
15
+ * Result: the tool was documented in every sub-agent's system prompt, executable
16
+ * by the gateway, and completely unreachable — a sub-agent calling it got
17
+ * "No such tool available". Nothing failed loudly; the capability just silently
18
+ * did not exist for ~14 months.
19
+ *
20
+ * Why structural (source text, not imports): `gateway.ts` has heavy import-time
21
+ * side effects (socket bind, bot construction) and `bridge.ts` exits the process
22
+ * at import when no gateway socket is present, so neither can be imported into a
23
+ * unit test. Both lists are plain string literals in source, so parsing them is
24
+ * exact and cheap. If either declaration is refactored out of the shape these
25
+ * regexes expect, the test FAILS (it never silently degrades to a no-op) —
26
+ * update the parser, don't delete the assertion.
27
+ */
28
+
29
+ const PLUGIN_DIR = join(import.meta.dirname, '..')
30
+
31
+ function readSource(rel: string): string {
32
+ return readFileSync(join(PLUGIN_DIR, rel), 'utf8')
33
+ }
34
+
35
+ /** Tool names in the gateway's IPC execution allowlist (`ALLOWED_TOOLS`). */
36
+ function gatewayAllowedTools(): string[] {
37
+ const src = readSource('gateway/gateway.ts')
38
+ const m = src.match(/const ALLOWED_TOOLS = new Set\(\[([\s\S]*?)\]\)/)
39
+ if (m == null) throw new Error('could not locate ALLOWED_TOOLS in gateway/gateway.ts')
40
+ return [...m[1].matchAll(/'([a-z0-9_]+)'/g)].map((x) => x[1])
41
+ }
42
+
43
+ /** Tool names advertised by the bridge's MCP `tools/list` (`TOOL_SCHEMAS`). */
44
+ function bridgeToolSchemaNames(): string[] {
45
+ const src = readSource('bridge/bridge.ts')
46
+ const m = src.match(/const TOOL_SCHEMAS = \[([\s\S]*?)\n\]\n/)
47
+ if (m == null) throw new Error('could not locate TOOL_SCHEMAS in bridge/bridge.ts')
48
+ return [...m[1].matchAll(/^ {4}name: '([a-z0-9_]+)',$/gm)].map((x) => x[1])
49
+ }
50
+
51
+ /**
52
+ * Tools deliberately executable-but-unadvertised. EMPTY on purpose.
53
+ *
54
+ * Adding a name here is a decision to make a capability unreachable by the
55
+ * model — it needs a comment naming the reason, not just an entry. The whole
56
+ * point of this file is that the omission is explicit and reviewed rather than
57
+ * an accident of a refactor.
58
+ */
59
+ const INTENTIONALLY_UNADVERTISED: ReadonlySet<string> = new Set([])
60
+
61
+ describe('bridge MCP tool surface ↔ gateway ALLOWED_TOOLS parity', () => {
62
+ it('parses both declarations (guards against a silently no-op test)', () => {
63
+ expect(gatewayAllowedTools().length).toBeGreaterThan(15)
64
+ expect(bridgeToolSchemaNames().length).toBeGreaterThan(15)
65
+ })
66
+
67
+ it('advertises every gateway-executable tool', () => {
68
+ const advertised = new Set(bridgeToolSchemaNames())
69
+ const missing = gatewayAllowedTools().filter(
70
+ (t) => !advertised.has(t) && !INTENTIONALLY_UNADVERTISED.has(t),
71
+ )
72
+ expect(missing).toEqual([])
73
+ })
74
+
75
+ it('advertises no tool the gateway would refuse to execute', () => {
76
+ const allowed = new Set(gatewayAllowedTools())
77
+ const orphans = bridgeToolSchemaNames().filter((t) => !allowed.has(t))
78
+ expect(orphans).toEqual([])
79
+ })
80
+
81
+ it('registers progress_update — the tool this guard was written for', () => {
82
+ expect(bridgeToolSchemaNames()).toContain('progress_update')
83
+ expect(gatewayAllowedTools()).toContain('progress_update')
84
+ })
85
+
86
+ it('keeps progress_update reachable through the tool-surface filter', () => {
87
+ // The Linear gate (tool-filter.ts) must not strip it, with or without a
88
+ // Linear connection: an unregistered-in-effect tool is the same outage.
89
+ const schemas = bridgeToolSchemaNames().map((name) => ({ name }))
90
+ for (const linearEnabled of [false, true]) {
91
+ const names = buildEffectiveToolSchemas(schemas, { linearEnabled }).map((t) => t.name)
92
+ expect(names).toContain('progress_update')
93
+ }
94
+ })
95
+ })