switchroom 0.17.6 → 0.17.10

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 (155) hide show
  1. package/dist/agent-scheduler/index.js +38 -4
  2. package/dist/auth-broker/index.js +302 -203
  3. package/dist/cli/notion-write-pretool.mjs +35 -2
  4. package/dist/cli/switchroom.js +1178 -576
  5. package/dist/host-control/main.js +148 -14
  6. package/dist/vault/approvals/kernel-server.js +140 -55
  7. package/dist/vault/broker/server.js +142 -57
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +50 -6
  10. package/profiles/default/CLAUDE.md +116 -0
  11. package/skills/mental-model-curator/SKILL.md +162 -0
  12. package/telegram-plugin/bridge/bridge.ts +80 -1
  13. package/telegram-plugin/bridge/ipc-client.ts +19 -0
  14. package/telegram-plugin/bridge/permission-ledger.ts +61 -0
  15. package/telegram-plugin/consolidation-legibility.ts +279 -0
  16. package/telegram-plugin/dist/bridge/bridge.js +85 -1
  17. package/telegram-plugin/dist/gateway/gateway.js +2565 -610
  18. package/telegram-plugin/dist/server.js +86 -2
  19. package/telegram-plugin/feed-heartbeat-climb.ts +206 -0
  20. package/telegram-plugin/gateway/activity-card-store.ts +293 -0
  21. package/telegram-plugin/gateway/gateway.ts +1376 -82
  22. package/telegram-plugin/gateway/inbound-spool.ts +22 -0
  23. package/telegram-plugin/gateway/mental-model-propose-card.ts +69 -0
  24. package/telegram-plugin/gateway/mental-model-propose-diff.ts +171 -0
  25. package/telegram-plugin/gateway/mental-model-propose-inbound-builders.ts +147 -0
  26. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +201 -0
  27. package/telegram-plugin/gateway/missed-approvals-card.ts +161 -0
  28. package/telegram-plugin/gateway/missed-approvals-store.ts +167 -0
  29. package/telegram-plugin/gateway/permission-rearm.ts +115 -0
  30. package/telegram-plugin/gateway/scoped-grant-store.ts +89 -0
  31. package/telegram-plugin/memory-legibility.ts +217 -0
  32. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  33. package/telegram-plugin/scoped-approval.ts +59 -0
  34. package/telegram-plugin/silent-end.ts +78 -0
  35. package/telegram-plugin/subagent-watcher.ts +60 -6
  36. package/telegram-plugin/tests/activity-card-store.test.ts +436 -0
  37. package/telegram-plugin/tests/activity-card-wiring.test.ts +88 -0
  38. package/telegram-plugin/tests/consolidation-legibility.test.ts +224 -0
  39. package/telegram-plugin/tests/emission-authority-facade.test.ts +25 -10
  40. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +33 -9
  41. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  42. package/telegram-plugin/tests/inbound-spool.test.ts +105 -0
  43. package/telegram-plugin/tests/memory-legibility.test.ts +216 -0
  44. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +67 -0
  45. package/telegram-plugin/tests/mental-model-propose-card.test.ts +56 -0
  46. package/telegram-plugin/tests/mental-model-propose-diff.test.ts +201 -0
  47. package/telegram-plugin/tests/mental-model-propose-inbound-builders.test.ts +68 -0
  48. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +157 -0
  49. package/telegram-plugin/tests/missed-approvals-card.test.ts +145 -0
  50. package/telegram-plugin/tests/missed-approvals-store.test.ts +147 -0
  51. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +89 -0
  52. package/telegram-plugin/tests/permission-ledger.test.ts +166 -0
  53. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +1 -1
  54. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +175 -0
  55. package/telegram-plugin/tests/permission-rearm.test.ts +126 -0
  56. package/telegram-plugin/tests/scoped-grant-persist.test.ts +223 -0
  57. package/telegram-plugin/tests/silent-end-transport.test.ts +290 -0
  58. package/telegram-plugin/tests/silent-turn-climb-transport.test.ts +337 -0
  59. package/telegram-plugin/tests/subagent-watcher.test.ts +139 -0
  60. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +103 -0
  61. package/telegram-plugin/uat/assertions.ts +88 -4
  62. package/telegram-plugin/uat/feed-matcher.test.ts +69 -0
  63. package/telegram-plugin/uat/scenarios/fuzz-liveness-climb-dm.test.ts +155 -0
  64. package/telegram-plugin/uat/scenarios/jtbd-directive-capture-nudge-dm.test.ts +185 -0
  65. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-channel.test.ts +192 -0
  66. package/telegram-plugin/uat/scenarios/jtbd-liveness-climb-dm.test.ts +220 -0
  67. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +137 -0
  68. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +148 -0
  69. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-channel.test.ts +66 -0
  70. package/telegram-plugin/uat/scenarios/jtbd-memory-legibility-dm.test.ts +61 -0
  71. package/telegram-plugin/uat/scenarios/silent-end-recovery-channel.test.ts +136 -0
  72. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +24 -2
  73. package/telegram-plugin/worktree-watch-cwds.ts +60 -0
  74. package/vendor/hindsight-memory/hooks/hooks.json +9 -0
  75. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  76. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  77. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  78. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  79. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  80. package/vendor/hindsight-memory/scripts/directive_verify.py +445 -0
  81. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  82. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  83. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  84. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  85. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  86. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  87. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  88. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  89. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/lib/config.py +37 -0
  94. package/vendor/hindsight-memory/scripts/lib/directives.py +88 -0
  95. package/vendor/hindsight-memory/scripts/lib/switchroom_envelope.py +77 -0
  96. package/vendor/hindsight-memory/scripts/recall.py +153 -4
  97. package/vendor/hindsight-memory/scripts/retain.py +17 -0
  98. package/vendor/hindsight-memory/scripts/setup_hooks.py +9 -0
  99. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/test_directive_capture_nudge.py +185 -0
  126. package/vendor/hindsight-memory/scripts/tests/test_directive_verify.py +516 -0
  127. package/vendor/hindsight-memory/scripts/tests/test_directives.py +49 -0
  128. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +66 -1
  129. package/vendor/hindsight-memory/scripts/tests/test_switchroom_envelope.py +69 -0
  130. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  131. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +49 -2
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Unit tests for the bridge-side outstanding permission-request ledger
3
+ * (#2861 / #2859).
4
+ *
5
+ * The ledger is the primitive that closes R2 (bridge dropped permission
6
+ * requests while the gateway IPC was down). bridge.ts wires it as:
7
+ * - permission_request notification → ledger.add(params) (BEFORE the
8
+ * connect check), then send if connected else buffer.
9
+ * - onPermission (verdict delivered) → ledger.delete(requestId).
10
+ * - onConnect (IPC (re)connect) → re-send ledger.all().
11
+ *
12
+ * bridge.ts has top-level side effects and isn't unit-importable, so here we
13
+ * (a) unit-test the ledger primitive directly and (b) drive a faithful
14
+ * re-implementation of the three wiring points against a fake IPC to prove the
15
+ * buffer / re-send / delete behaviours. The source wiring itself is pinned in
16
+ * permission-rearm-wiring.test.ts.
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest'
20
+ import {
21
+ createOutstandingPermissionLedger,
22
+ type OutstandingPermissionParams,
23
+ } from '../bridge/permission-ledger.js'
24
+
25
+ function params(requestId: string, tool = 'mcp__brevo__post'): OutstandingPermissionParams {
26
+ return {
27
+ request_id: requestId,
28
+ tool_name: tool,
29
+ description: `${tool} description`,
30
+ input_preview: '{"foo":"bar"}',
31
+ }
32
+ }
33
+
34
+ describe('createOutstandingPermissionLedger', () => {
35
+ it('starts empty', () => {
36
+ const led = createOutstandingPermissionLedger()
37
+ expect(led.size).toBe(0)
38
+ expect(led.all()).toEqual([])
39
+ })
40
+
41
+ it('add + all + has', () => {
42
+ const led = createOutstandingPermissionLedger()
43
+ led.add(params('aaaaa'))
44
+ led.add(params('bbbbb'))
45
+ expect(led.size).toBe(2)
46
+ expect(led.has('aaaaa')).toBe(true)
47
+ expect(led.has('zzzzz')).toBe(false)
48
+ expect(led.all().map(p => p.request_id)).toEqual(['aaaaa', 'bbbbb'])
49
+ })
50
+
51
+ it('add is idempotent on request_id (latest params win)', () => {
52
+ const led = createOutstandingPermissionLedger()
53
+ led.add(params('aaaaa', 'mcp__brevo__post'))
54
+ led.add(params('aaaaa', 'mcp__brevo__patch'))
55
+ expect(led.size).toBe(1)
56
+ expect(led.all()[0].tool_name).toBe('mcp__brevo__patch')
57
+ })
58
+
59
+ it('delete removes the entry (verdict delivered)', () => {
60
+ const led = createOutstandingPermissionLedger()
61
+ led.add(params('aaaaa'))
62
+ led.delete('aaaaa')
63
+ expect(led.size).toBe(0)
64
+ expect(led.has('aaaaa')).toBe(false)
65
+ })
66
+
67
+ it('delete of an unknown id is a no-op', () => {
68
+ const led = createOutstandingPermissionLedger()
69
+ led.add(params('aaaaa'))
70
+ led.delete('nope')
71
+ expect(led.size).toBe(1)
72
+ })
73
+ })
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Faithful re-implementation of the three bridge wiring points. Mirrors
77
+ // bridge.ts exactly so the buffer / re-send / delete contract is exercised
78
+ // without importing the side-effecting module.
79
+ // ---------------------------------------------------------------------------
80
+
81
+ interface FakeIpc {
82
+ connected: boolean
83
+ sent: Array<{ requestId: string }>
84
+ }
85
+
86
+ function makeHarness(rearmEnabled = true) {
87
+ const ledger = createOutstandingPermissionLedger()
88
+ const ipc: FakeIpc = { connected: false, sent: [] }
89
+
90
+ // Mirrors the permission_request notification handler.
91
+ function onPermissionRequest(p: OutstandingPermissionParams): void {
92
+ if (rearmEnabled) ledger.add(p)
93
+ if (!ipc.connected) {
94
+ // BUFFER (not drop) when re-arm enabled.
95
+ return
96
+ }
97
+ ipc.sent.push({ requestId: p.request_id })
98
+ }
99
+
100
+ // Mirrors onPermission (verdict delivered).
101
+ function onVerdict(requestId: string): void {
102
+ ledger.delete(requestId)
103
+ }
104
+
105
+ // Mirrors flushOutstandingPermissionRequests on onConnect.
106
+ function onConnect(): void {
107
+ ipc.connected = true
108
+ if (!rearmEnabled) return
109
+ for (const p of ledger.all()) ipc.sent.push({ requestId: p.request_id })
110
+ }
111
+
112
+ function disconnect(): void {
113
+ ipc.connected = false
114
+ }
115
+
116
+ return { ledger, ipc, onPermissionRequest, onVerdict, onConnect, disconnect }
117
+ }
118
+
119
+ describe('bridge permission-request wiring (buffer / re-send / delete)', () => {
120
+ it('buffers when disconnected — no drop, request retained', () => {
121
+ const h = makeHarness()
122
+ h.onPermissionRequest(params('aaaaa'))
123
+ // Nothing sent (gateway down) but the ledger retained it.
124
+ expect(h.ipc.sent).toEqual([])
125
+ expect(h.ledger.has('aaaaa')).toBe(true)
126
+ })
127
+
128
+ it('re-sends every outstanding request on (re)connect', () => {
129
+ const h = makeHarness()
130
+ h.onPermissionRequest(params('aaaaa'))
131
+ h.onPermissionRequest(params('bbbbb'))
132
+ expect(h.ipc.sent).toEqual([]) // still down
133
+
134
+ h.onConnect() // gateway comes up
135
+ expect(h.ipc.sent.map(s => s.requestId).sort()).toEqual(['aaaaa', 'bbbbb'])
136
+ })
137
+
138
+ it('re-sends again after a disconnect/reconnect cycle (gateway restart)', () => {
139
+ const h = makeHarness()
140
+ h.onConnect() // connected
141
+ h.onPermissionRequest(params('aaaaa')) // sent live
142
+ expect(h.ipc.sent.map(s => s.requestId)).toEqual(['aaaaa'])
143
+
144
+ h.disconnect() // gateway restarts
145
+ h.onConnect() // reconnect → re-send outstanding
146
+ expect(h.ipc.sent.map(s => s.requestId)).toEqual(['aaaaa', 'aaaaa'])
147
+ })
148
+
149
+ it('a delivered verdict removes the entry — no re-send after resolution', () => {
150
+ const h = makeHarness()
151
+ h.onPermissionRequest(params('aaaaa'))
152
+ h.onVerdict('aaaaa') // operator tapped, verdict delivered
153
+ expect(h.ledger.has('aaaaa')).toBe(false)
154
+
155
+ h.onConnect() // reconnect
156
+ expect(h.ipc.sent).toEqual([]) // nothing re-sent
157
+ })
158
+
159
+ it('kill switch: disabled → drop-when-disconnected, no ledger, no re-send', () => {
160
+ const h = makeHarness(false)
161
+ h.onPermissionRequest(params('aaaaa')) // disconnected → dropped, not recorded
162
+ expect(h.ledger.size).toBe(0)
163
+ h.onConnect()
164
+ expect(h.ipc.sent).toEqual([]) // nothing to re-send
165
+ })
166
+ })
@@ -58,7 +58,7 @@ describe('no-repeat-on-timeout wiring', () => {
58
58
  })
59
59
 
60
60
  it('onPermissionRequest short-circuits a recent-timeout duplicate before posting a card', () => {
61
- const fn = slice(GATEWAY, 'onPermissionRequest(', 4000)
61
+ const fn = slice(GATEWAY, 'onPermissionRequest(', 6000)
62
62
  const dupIdx = fn.indexOf('isRecentTimeoutDuplicate(')
63
63
  const cardIdx = fn.indexOf('pendingPermissions.set(requestId')
64
64
  expect(dupIdx).toBeGreaterThan(-1)
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Source-text wiring pins for the approval-card re-arm feature (#2861 / #2859).
3
+ *
4
+ * gateway.ts and bridge.ts have top-level side effects and aren't
5
+ * unit-importable; the decision logic is unit-tested in permission-rearm.test.ts
6
+ * and permission-ledger.test.ts. These pins lock the wiring that connects those
7
+ * primitives so the two failure modes can't silently regress:
8
+ * R1 — gateway restart orphans pending approvals (boot-sweep stripped them).
9
+ * R2 — bridge dropped permission requests while the gateway IPC was down.
10
+ */
11
+
12
+ import { describe, it, expect } from 'vitest'
13
+ import { readFileSync } from 'node:fs'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { dirname, resolve } from 'node:path'
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url))
18
+ const read = (p: string) => readFileSync(resolve(__dirname, '..', p), 'utf8')
19
+ const GATEWAY = read('gateway/gateway.ts')
20
+ const BRIDGE = read('bridge/bridge.ts')
21
+ const IPC_CLIENT = read('bridge/ipc-client.ts')
22
+
23
+ function slice(src: string, header: string, span = 2400): string {
24
+ const start = src.indexOf(header)
25
+ expect(start, `expected to find ${header}`).toBeGreaterThan(-1)
26
+ return src.slice(start, start + span)
27
+ }
28
+
29
+ // ─── Bridge: outstanding-request ledger (R2) ──────────────────────────────
30
+
31
+ describe('bridge ledger wiring', () => {
32
+ it('imports and instantiates the outstanding-permission ledger', () => {
33
+ expect(BRIDGE).toMatch(/from '\.\/permission-ledger\.js'/)
34
+ expect(BRIDGE).toMatch(/createOutstandingPermissionLedger\(\)/)
35
+ })
36
+
37
+ it('records every permission_request in the ledger BEFORE the connect check', () => {
38
+ const handler = slice(BRIDGE, "method: z.literal('notifications/claude/channel/permission_request')", 2600)
39
+ const addIdx = handler.indexOf('outstandingPermissions.add(')
40
+ const connIdx = handler.indexOf('!ipc.isConnected()')
41
+ expect(addIdx, 'ledger.add must be present in the handler').toBeGreaterThan(-1)
42
+ expect(connIdx, 'connect check must be present').toBeGreaterThan(-1)
43
+ expect(addIdx, 'ledger.add must precede the connect check so a disconnected request is retained')
44
+ .toBeLessThan(connIdx)
45
+ })
46
+
47
+ it('the early-return no longer DROPS unconditionally — it buffers on reconnect', () => {
48
+ const handler = slice(BRIDGE, "method: z.literal('notifications/claude/channel/permission_request')", 2600)
49
+ // The old drop path was a bare `if (!ipc || !ipc.isConnected()) { log; return }`
50
+ // with no ledger/buffer. Pin the new buffer semantics instead.
51
+ expect(handler).toMatch(/buffered.*re-send on reconnect/i)
52
+ // Structural anti-regression: the legacy standalone drop message must only
53
+ // appear behind the kill-switch fallback, never as the sole behavior.
54
+ expect(handler).toMatch(/permissionRearmEnabled/)
55
+ })
56
+
57
+ it('deletes from the ledger when a verdict is delivered (onPermission)', () => {
58
+ const onPerm = slice(BRIDGE, 'function onPermission(msg: PermissionEvent)', 900)
59
+ expect(onPerm).toMatch(/outstandingPermissions\.delete\(msg\.requestId\)/)
60
+ })
61
+
62
+ it('flushes outstanding requests on every IPC (re)connect', () => {
63
+ expect(BRIDGE).toMatch(/function flushOutstandingPermissionRequests/)
64
+ expect(BRIDGE).toMatch(/onConnect:\s*flushOutstandingPermissionRequests/)
65
+ const flush = slice(BRIDGE, 'function flushOutstandingPermissionRequests', 900)
66
+ expect(flush).toMatch(/outstandingPermissions\.all\(\)/)
67
+ expect(flush).toMatch(/sendPermissionRequest/)
68
+ })
69
+
70
+ it('honours the SWITCHROOM_PERMISSION_REARM kill switch', () => {
71
+ expect(BRIDGE).toMatch(/SWITCHROOM_PERMISSION_REARM\s*!==\s*'0'/)
72
+ })
73
+ })
74
+
75
+ // ─── IPC client: onConnect hook ───────────────────────────────────────────
76
+
77
+ describe('ipc-client onConnect hook', () => {
78
+ it('exposes an onConnect option', () => {
79
+ expect(IPC_CLIENT).toMatch(/onConnect\?:\s*\(\)\s*=>\s*void/)
80
+ })
81
+ it('invokes onConnect from the socket open() handler (after register)', () => {
82
+ const open = slice(IPC_CLIENT, 'open(sock)', 900)
83
+ const regIdx = open.indexOf('sendRegister()')
84
+ const connIdx = open.indexOf('onConnect(')
85
+ expect(connIdx, 'open() must call onConnect').toBeGreaterThan(-1)
86
+ expect(regIdx).toBeLessThan(connIdx)
87
+ })
88
+ })
89
+
90
+ // ─── Gateway: dedupe + re-arm (R1) ────────────────────────────────────────
91
+
92
+ describe('gateway re-arm wiring', () => {
93
+ it('imports the pure re-arm helpers', () => {
94
+ expect(GATEWAY).toMatch(/from '\.\/permission-rearm\.js'/)
95
+ expect(GATEWAY).toMatch(/classifyPermissionRequest/)
96
+ expect(GATEWAY).toMatch(/computeBootSweepStripTargets/)
97
+ })
98
+
99
+ it('onPermissionRequest dedupes / re-arms before posting a card', () => {
100
+ const fn = slice(GATEWAY, 'onPermissionRequest(_client: IpcClient, msg: PermissionRequestForward)', 5200)
101
+ expect(fn).toMatch(/isPermissionRearmEnabled\(\)/)
102
+ expect(fn).toMatch(/classifyPermissionRequest/)
103
+ // duplicate → return (no second card); rearm → restore from store.
104
+ expect(fn).toMatch(/disposition === 'duplicate'/)
105
+ expect(fn).toMatch(/disposition === 'rearm'/)
106
+ expect(fn).toMatch(/rearmPermissionFromStore\(/)
107
+ // The dedup/re-arm block must sit before the card-post (formatPermissionCardBody).
108
+ const rearmIdx = fn.indexOf('classifyPermissionRequest')
109
+ const postIdx = fn.indexOf('formatPermissionCardBody')
110
+ expect(postIdx).toBeGreaterThan(-1)
111
+ expect(rearmIdx).toBeLessThan(postIdx)
112
+ })
113
+
114
+ it('re-arm preserves the ORIGINAL startedAt (TTL clock, not Date.now())', () => {
115
+ const fn = slice(GATEWAY, 'function rearmPermissionFromStore', 1800)
116
+ // startedAt is derived from the persisted entries, never re-stamped.
117
+ expect(fn).toMatch(/c\.startedAt/)
118
+ expect(fn).toMatch(/startedAt,/)
119
+ expect(fn).not.toMatch(/startedAt:\s*Date\.now\(\)/)
120
+ expect(fn).toMatch(/pendingPermissions\.set\(requestId/)
121
+ })
122
+
123
+ it('re-arm re-attaches the keyboard on the EXISTING message(s) via editMessageText', () => {
124
+ const fn = slice(GATEWAY, 'function rearmPermissionFromStore', 1800)
125
+ expect(fn).toMatch(/buildPermissionActionRow\(requestId/)
126
+ expect(fn).toMatch(/editMessageText\(/)
127
+ // Re-arm edits existing messages; it must NOT post a brand-new card.
128
+ expect(fn).not.toMatch(/sendRichMessage/)
129
+ })
130
+
131
+ it('never auto-answers — no dispatchPermissionVerdict inside re-arm (no-self-escalation)', () => {
132
+ const fn = slice(GATEWAY, 'function rearmPermissionFromStore', 1800)
133
+ expect(fn).not.toMatch(/dispatchPermissionVerdict/)
134
+ })
135
+ })
136
+
137
+ // ─── Gateway: grace-period boot-sweep (R1) ────────────────────────────────
138
+
139
+ describe('gateway boot-sweep grace period', () => {
140
+ it('defers the strip when re-arm is enabled, keeps immediate strip as the kill-switch path', () => {
141
+ const sweep = slice(GATEWAY, 'Boot-sweep for permission cards from a prior gateway session', 4200)
142
+ expect(sweep).toMatch(/if \(!isPermissionRearmEnabled\(\)\)/)
143
+ // legacy immediate path still strips everything + clears the store
144
+ expect(sweep).toMatch(/permCardStore\.clear\(\)/)
145
+ // grace path: setTimeout + grace-ms knob + live-pending filter
146
+ expect(sweep).toMatch(/setTimeout\(/)
147
+ expect(sweep).toMatch(/permissionRearmGraceMs\(\)/)
148
+ // Preserve anything currently live in pendingPermissions — covers both
149
+ // re-armed AND fresh-during-grace cards (reviewer-caught blocker fix).
150
+ expect(sweep).toMatch(/new Set\(pendingPermissions\.keys\(\)\)/)
151
+ expect(sweep).toMatch(/computeBootSweepStripTargets\(remaining, liveRequestIds\)/)
152
+ })
153
+
154
+ it('the grace path removes only stripped request_ids, not the whole store', () => {
155
+ const sweep = slice(GATEWAY, 'Boot-sweep for permission cards from a prior gateway session', 4200)
156
+ // Per-request removal (preserves re-armed live entries) inside the deferred block.
157
+ expect(sweep).toMatch(/distinctRequestIds\(toStrip\)/)
158
+ expect(sweep).toMatch(/permCardStore\.remove\(id\)/)
159
+ })
160
+ })
161
+
162
+ // ─── Gateway: re-arm re-holds the #2840 inbound gate ──────────────────────
163
+
164
+ describe('re-armed pending card re-holds the inbound gate (#2840)', () => {
165
+ it('turnInFlightForGate counts pending approvals', () => {
166
+ const gate = slice(GATEWAY, 'function turnInFlightForGate()', 1200)
167
+ expect(gate).toMatch(/pendingPermissions\.size > 0/)
168
+ })
169
+ it('re-arm restores a pendingPermissions entry → gate held automatically', () => {
170
+ // The gate reads pendingPermissions.size; rearmPermissionFromStore sets an
171
+ // entry, so a re-armed card holds the gate with no extra wiring.
172
+ const fn = slice(GATEWAY, 'function rearmPermissionFromStore', 1800)
173
+ expect(fn).toMatch(/pendingPermissions\.set\(requestId/)
174
+ })
175
+ })
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Unit tests for the pure permission-card re-arm helpers (#2861 / #2859).
3
+ *
4
+ * These cover the discriminable decision logic extracted from gateway.ts
5
+ * (which has top-level side effects and isn't unit-importable):
6
+ * - classifyPermissionRequest — duplicate / rearm / fresh
7
+ * - computeBootSweepStripTargets — grace-period strip filtering
8
+ * - isPermissionRearmEnabled / permissionRearmGraceMs — kill switch + knob
9
+ * - distinctRequestIds
10
+ *
11
+ * The gateway/bridge WIRING that consumes these is pinned separately in
12
+ * permission-rearm-wiring.test.ts.
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest'
16
+ import {
17
+ classifyPermissionRequest,
18
+ computeBootSweepStripTargets,
19
+ isPermissionRearmEnabled,
20
+ permissionRearmGraceMs,
21
+ distinctRequestIds,
22
+ PERMISSION_REARM_GRACE_MS,
23
+ } from '../gateway/permission-rearm.js'
24
+ import type { PersistedPermCard } from '../gateway/permission-card-store.js'
25
+
26
+ function card(requestId: string, messageId: number): PersistedPermCard {
27
+ return {
28
+ requestId,
29
+ chatId: '-100123',
30
+ messageId,
31
+ startedAt: 1_000,
32
+ toolName: 'mcp__brevo__post',
33
+ cardText: 'Marko wants to post to Brevo',
34
+ }
35
+ }
36
+
37
+ describe('classifyPermissionRequest', () => {
38
+ it('already-live request → duplicate (ignore, no second card)', () => {
39
+ expect(classifyPermissionRequest({ hasPending: true, persistedCount: 0 })).toBe('duplicate')
40
+ // hasPending wins even if the store also has it
41
+ expect(classifyPermissionRequest({ hasPending: true, persistedCount: 2 })).toBe('duplicate')
42
+ })
43
+
44
+ it('gone from memory but persisted → rearm', () => {
45
+ expect(classifyPermissionRequest({ hasPending: false, persistedCount: 1 })).toBe('rearm')
46
+ expect(classifyPermissionRequest({ hasPending: false, persistedCount: 3 })).toBe('rearm')
47
+ })
48
+
49
+ it('unknown request → fresh (post a new card)', () => {
50
+ expect(classifyPermissionRequest({ hasPending: false, persistedCount: 0 })).toBe('fresh')
51
+ })
52
+ })
53
+
54
+ describe('computeBootSweepStripTargets', () => {
55
+ it('strips only entries whose request_id is NOT currently live', () => {
56
+ const stale = [card('aaaaa', 10), card('bbbbb', 11), card('ccccc', 12)]
57
+ const live = new Set(['bbbbb']) // bbbbb re-armed / still pending
58
+ const targets = computeBootSweepStripTargets(stale, live)
59
+ expect(targets.map(t => t.requestId)).toEqual(['aaaaa', 'ccccc'])
60
+ })
61
+
62
+ it('a re-armed (live) entry survives the deadline (empty strip)', () => {
63
+ const stale = [card('aaaaa', 10)]
64
+ const live = new Set(['aaaaa'])
65
+ expect(computeBootSweepStripTargets(stale, live)).toEqual([])
66
+ })
67
+
68
+ it('a dead-session entry (no live pending) is stripped at the deadline', () => {
69
+ const stale = [card('aaaaa', 10)]
70
+ const live = new Set<string>()
71
+ expect(computeBootSweepStripTargets(stale, live).map(t => t.requestId)).toEqual(['aaaaa'])
72
+ })
73
+
74
+ it('a FRESH card born during the grace window (live but not in the boot set) survives', () => {
75
+ // Regression for the reviewer-caught blocker: a new approval posted after
76
+ // boot writes its own persisted row, so it appears in `remaining` at the
77
+ // deadline. It's live in pendingPermissions, so it must NOT be stripped —
78
+ // otherwise a legitimately-suspended turn gets re-wedged.
79
+ const remaining = [card('dead0', 10), card('fresh', 20)]
80
+ const live = new Set(['fresh']) // 'fresh' posted during grace, still pending
81
+ const targets = computeBootSweepStripTargets(remaining, live)
82
+ expect(targets.map(t => t.requestId)).toEqual(['dead0'])
83
+ })
84
+
85
+ it('keeps ALL fan-out messages for a dead-session request', () => {
86
+ // Same request_id, two operator surfaces → two persisted rows.
87
+ const stale = [card('aaaaa', 10), card('aaaaa', 20)]
88
+ const targets = computeBootSweepStripTargets(stale, new Set())
89
+ expect(targets).toHaveLength(2)
90
+ expect(targets.map(t => t.messageId).sort()).toEqual([10, 20])
91
+ })
92
+ })
93
+
94
+ describe('distinctRequestIds', () => {
95
+ it('dedupes request_ids across fan-out rows', () => {
96
+ const cards = [card('aaaaa', 10), card('aaaaa', 20), card('bbbbb', 30)]
97
+ expect(distinctRequestIds(cards).sort()).toEqual(['aaaaa', 'bbbbb'])
98
+ })
99
+ })
100
+
101
+ describe('isPermissionRearmEnabled (kill switch)', () => {
102
+ it('enabled by default (unset)', () => {
103
+ expect(isPermissionRearmEnabled({})).toBe(true)
104
+ })
105
+ it('enabled for any value other than "0"', () => {
106
+ expect(isPermissionRearmEnabled({ SWITCHROOM_PERMISSION_REARM: '1' })).toBe(true)
107
+ expect(isPermissionRearmEnabled({ SWITCHROOM_PERMISSION_REARM: 'on' })).toBe(true)
108
+ })
109
+ it('disabled only for the exact "0" kill value', () => {
110
+ expect(isPermissionRearmEnabled({ SWITCHROOM_PERMISSION_REARM: '0' })).toBe(false)
111
+ })
112
+ })
113
+
114
+ describe('permissionRearmGraceMs', () => {
115
+ it('defaults to PERMISSION_REARM_GRACE_MS when unset', () => {
116
+ expect(permissionRearmGraceMs({})).toBe(PERMISSION_REARM_GRACE_MS)
117
+ })
118
+ it('honours a finite non-negative override (test collapse)', () => {
119
+ expect(permissionRearmGraceMs({ SWITCHROOM_PERMISSION_REARM_GRACE_MS: '0' })).toBe(0)
120
+ expect(permissionRearmGraceMs({ SWITCHROOM_PERMISSION_REARM_GRACE_MS: '250' })).toBe(250)
121
+ })
122
+ it('falls back to the default on garbage / negative', () => {
123
+ expect(permissionRearmGraceMs({ SWITCHROOM_PERMISSION_REARM_GRACE_MS: 'nope' })).toBe(PERMISSION_REARM_GRACE_MS)
124
+ expect(permissionRearmGraceMs({ SWITCHROOM_PERMISSION_REARM_GRACE_MS: '-5' })).toBe(PERMISSION_REARM_GRACE_MS)
125
+ })
126
+ })