switchroom 0.18.3 → 0.18.7

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 (156) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +3 -1
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +386 -128
  6. package/dist/host-control/main.js +4 -2
  7. package/dist/vault/approvals/kernel-server.js +3 -1
  8. package/dist/vault/broker/server.js +38 -8
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +35 -16
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-release/SKILL.md +78 -0
  14. package/telegram-plugin/auth-snapshot-format.ts +15 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  16. package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
  17. package/telegram-plugin/dist/server.js +24 -0
  18. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  19. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  20. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  21. package/telegram-plugin/gateway/gateway.ts +1331 -151
  22. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  23. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  24. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  25. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  27. package/telegram-plugin/gateway/model-command.ts +212 -51
  28. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  29. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  30. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  31. package/telegram-plugin/gateway/resolve-person.ts +304 -0
  32. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  33. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  34. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
  35. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  36. package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
  37. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  38. package/telegram-plugin/model-label.ts +69 -0
  39. package/telegram-plugin/operator-events.ts +45 -0
  40. package/telegram-plugin/pending-work-progress.ts +42 -7
  41. package/telegram-plugin/permission-diff.ts +128 -0
  42. package/telegram-plugin/quota-bar-format.ts +360 -0
  43. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  44. package/telegram-plugin/registry/subagents.test.ts +90 -0
  45. package/telegram-plugin/session-tail.ts +28 -0
  46. package/telegram-plugin/silent-end.ts +49 -4
  47. package/telegram-plugin/subagent-watcher.ts +249 -46
  48. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  49. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  50. package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
  51. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  52. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  53. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  54. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  55. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  56. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  57. package/telegram-plugin/tests/model-command.test.ts +202 -42
  58. package/telegram-plugin/tests/model-label.test.ts +64 -0
  59. package/telegram-plugin/tests/operator-events.test.ts +17 -0
  60. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  61. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  62. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  63. package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
  64. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  65. package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
  66. package/telegram-plugin/tests/resolve-person.test.ts +290 -0
  67. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  68. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  69. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  70. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
  71. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
  72. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  73. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  74. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  75. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  76. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  77. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  78. package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
  79. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  80. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  81. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
  82. package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
  83. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  84. package/telegram-plugin/tool-activity-summary.ts +22 -2
  85. package/telegram-plugin/typing-wrap.ts +72 -25
  86. package/telegram-plugin/worker-activity-feed.ts +229 -15
  87. package/profiles/default/CLAUDE.md +0 -116
  88. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
  89. package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
  90. package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
  91. package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
  92. package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
  93. package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
  94. package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
  96. package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
  97. package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
  98. package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
  99. package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
  100. package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
  101. package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
  102. package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
  103. package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
  104. package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
  105. package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
  106. package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
  108. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
  109. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
  110. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
  111. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
  112. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
  113. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
  114. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
  115. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
  116. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
  117. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
  118. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
  119. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
  120. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
  121. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
  122. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
  123. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
  124. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
  125. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
  126. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
  127. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
  128. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
  129. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
  130. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
  131. package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
  132. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  133. package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
  134. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
  135. package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
  136. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
  137. package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
  138. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
  139. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
  140. package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
  141. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
  142. package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
  143. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  144. package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
  145. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
  146. package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
  147. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
  148. package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
  149. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  150. package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
  151. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
  152. package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
  153. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
  154. package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
  155. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
  156. package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Source-text wiring pins for the agent-initiated approval-card durability fix.
3
+ *
4
+ * gateway.ts has top-level side effects and isn't unit-importable; the pure
5
+ * primitives are unit-tested in pending-card-store.test.ts and
6
+ * approval-timeout-inbound-builders.test.ts. These pins lock the wiring that
7
+ * connects them so the two failure modes can't silently regress:
8
+ *
9
+ * Defect A — pending card state was in-memory only; a gateway restart lost it
10
+ * and a later tap hit the "Card expired" tombstone (agent parked forever).
11
+ * Defect B — no wake on TTL expiry; an unanswered card left the agent parked
12
+ * forever (the sweeps were lazy and never woke the agent).
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest'
16
+ import { readFileSync } from 'node:fs'
17
+ import { fileURLToPath } from 'node:url'
18
+ import { dirname, resolve } from 'node:path'
19
+
20
+ const __dirname = dirname(fileURLToPath(import.meta.url))
21
+ const read = (p: string) => readFileSync(resolve(__dirname, '..', p), 'utf8')
22
+ const GATEWAY = read('gateway/gateway.ts')
23
+
24
+ function slice(src: string, header: string, span = 3000): string {
25
+ const start = src.indexOf(header)
26
+ expect(start, `expected to find ${header}`).toBeGreaterThan(-1)
27
+ return src.slice(start, start + span)
28
+ }
29
+
30
+ // ─── Store instantiation ──────────────────────────────────────────────────
31
+
32
+ describe('pending-card-store wiring', () => {
33
+ it('imports and instantiates the durable pending-card store', () => {
34
+ expect(GATEWAY).toMatch(/from '\.\/pending-card-store\.js'/)
35
+ expect(GATEWAY).toMatch(/const pendingCardStore = createPendingCardStore\(STATE_DIR\)/)
36
+ })
37
+
38
+ it('imports the four timeout inbound builders', () => {
39
+ expect(GATEWAY).toMatch(/from '\.\/approval-timeout-inbound-builders\.js'/)
40
+ for (const b of [
41
+ 'buildVaultAccessTimeoutInbound',
42
+ 'buildVaultSaveTimeoutInbound',
43
+ 'buildSecretRequestTimeoutInbound',
44
+ 'buildMentalModelProposeTimeoutInbound',
45
+ ]) {
46
+ expect(GATEWAY).toContain(b)
47
+ }
48
+ })
49
+ })
50
+
51
+ // ─── Defect A part 1: persistence at staging ──────────────────────────────
52
+
53
+ describe('staging persists card metadata (Defect A)', () => {
54
+ it('vault_request_access stages into the durable store', () => {
55
+ const fn = slice(GATEWAY, 'async function executeVaultRequestAccess', 6000)
56
+ expect(fn).toMatch(/pendingCardStore\.add\(\{[\s\S]*family: 'vault_request_access'/)
57
+ })
58
+
59
+ it('request_secret stages into the durable store', () => {
60
+ const fn = slice(GATEWAY, 'async function executeRequestSecret', 3000)
61
+ expect(fn).toMatch(/pendingCardStore\.add\(\{[\s\S]*family: 'request_secret'/)
62
+ })
63
+
64
+ it('mental_model_propose stages into the durable store', () => {
65
+ const fn = slice(GATEWAY, 'async function executeMentalModelPropose', 6000)
66
+ expect(fn).toMatch(/pendingCardStore\.add\(\{[\s\S]*family: 'mental_model_propose'/)
67
+ })
68
+
69
+ it('vault_request_save stages metadata but NEVER the secret value (hygiene)', () => {
70
+ const fn = slice(GATEWAY, 'async function executeVaultRequestSave', 6000)
71
+ const addBlock = slice(fn, "pendingCardStore.add({", 700)
72
+ expect(addBlock).toMatch(/family: 'vault_request_save'/)
73
+ // The staged secret `value` must NOT be copied into the persisted record.
74
+ expect(addBlock).not.toMatch(/value:/)
75
+ })
76
+ })
77
+
78
+ // ─── Defect A part 2: boot restore ────────────────────────────────────────
79
+
80
+ describe('boot restore (Defect A)', () => {
81
+ it('defines restorePendingApprovalCards and calls it at boot', () => {
82
+ expect(GATEWAY).toMatch(/function restorePendingApprovalCards\(\)/)
83
+ expect(GATEWAY).toMatch(/restorePendingApprovalCards\(\)/g)
84
+ })
85
+
86
+ it('restored vault_request_save has no value + is flagged restoredWithoutValue', () => {
87
+ const fn = slice(GATEWAY, 'function restorePendingApprovalCards', 4000)
88
+ expect(fn).toMatch(/value: '',/)
89
+ expect(fn).toMatch(/restoredWithoutValue: true/)
90
+ })
91
+
92
+ it('a Save tap on a restored (valueless) card degrades gracefully instead of writing empty', () => {
93
+ const fn = slice(GATEWAY, 'async function handleVaultRequestSaveCallback', 9000)
94
+ expect(fn).toMatch(/pending\.restoredWithoutValue \|\| pending\.value\.length === 0/)
95
+ expect(fn).toMatch(/lost to a gateway restart/)
96
+ expect(fn).toMatch(/buildVaultSaveFailedInbound/)
97
+ })
98
+ })
99
+
100
+ // ─── Defect A part 3: removal on resolution ───────────────────────────────
101
+
102
+ describe('resolution clears the durable store (Defect A)', () => {
103
+ it('vault access approve/deny remove from the store', () => {
104
+ // deny path
105
+ const deny = slice(GATEWAY, 'async function handleVaultRequestAccessCallback', 4000)
106
+ expect(deny).toMatch(/pendingCardStore\.remove\(stageId\)/)
107
+ // approve path (performVaultAccessApproval) removes on every terminal branch
108
+ const approve = slice(GATEWAY, 'async function performVaultAccessApproval', 9000)
109
+ expect(approve).toMatch(/pendingCardStore\.remove\(stageId\)/)
110
+ })
111
+
112
+ it('vault save resolution paths remove from the store', () => {
113
+ const fn = slice(GATEWAY, 'async function handleVaultRequestSaveCallback', 12000)
114
+ // discard / write-fail / success / passphrase-missing all clear the store.
115
+ const count = (fn.match(/pendingCardStore\.remove\(stageId\)/g) ?? []).length
116
+ expect(count).toBeGreaterThanOrEqual(3)
117
+ })
118
+
119
+ it('secret request provide/decline remove from the store', () => {
120
+ // capture (provide → value arrives) removes via armed.stageId
121
+ expect(GATEWAY).toMatch(/pendingCardStore\.remove\(armed\.stageId\)/)
122
+ const decline = slice(GATEWAY, 'async function handleSecretRequestCallback', 3000)
123
+ expect(decline).toMatch(/pendingCardStore\.remove\(stageId\)/)
124
+ })
125
+
126
+ it('mental model resolve removes from the store', () => {
127
+ const fn = slice(GATEWAY, 'async function handleMentalModelProposeCallback', 4000)
128
+ expect(fn).toMatch(/pendingCardStore\.remove\(stageId\)/)
129
+ })
130
+ })
131
+
132
+ // ─── Defect B: TTL expiry wakes the parked agent ──────────────────────────
133
+
134
+ describe('TTL expiry wakes the parked agent (Defect B)', () => {
135
+ it('the periodic reaper runs the approval-card expiry sweep inside try/catch', () => {
136
+ const reaper = slice(GATEWAY, 'const pendingStateReaper = setInterval', 12000)
137
+ // The sweep call must be guarded like the sibling sweepStaleTurnActiveMarker:
138
+ // an escaped throw inside this setInterval callback reaches uncaughtException
139
+ // and takes the whole gateway down.
140
+ expect(reaper).toMatch(/try\s*\{\s*sweepExpiredApprovalCards\(now\)\s*\}\s*catch/)
141
+ })
142
+
143
+ it('sweepExpiredApprovalCards runs all four family sweeps', () => {
144
+ const fn = slice(GATEWAY, 'function sweepExpiredApprovalCards', 600)
145
+ expect(fn).toMatch(/sweepPendingVaultRequestAccesses\(now\)/)
146
+ expect(fn).toMatch(/sweepPendingVaultRequestSaves\(now\)/)
147
+ expect(fn).toMatch(/sweepPendingMentalModelProposes\(now\)/)
148
+ expect(fn).toMatch(/sweepSecretRequests\(now\)/)
149
+ })
150
+
151
+ it('each expire* helper routes through the guarded expirePendingCard core', () => {
152
+ for (const [fnName, builder] of [
153
+ ['function expireVaultAccessCard', 'buildVaultAccessTimeoutInbound'],
154
+ ['function expireVaultSaveCard', 'buildVaultSaveTimeoutInbound'],
155
+ ['function expireSecretRequestCard', 'buildSecretRequestTimeoutInbound'],
156
+ ['function expireMentalModelProposeCard', 'buildMentalModelProposeTimeoutInbound'],
157
+ ] as const) {
158
+ const fn = slice(GATEWAY, fnName, 2400)
159
+ // The pure core owns ordering + fault isolation (delete-before-deliver,
160
+ // recordMiss-before-deliver, guarded deliver) — pinned BEHAVIORALLY in
161
+ // pending-card-expiry.test.ts. Here we pin that the gateway routes each
162
+ // family through it with the right dependencies.
163
+ expect(fn, fnName).toMatch(/expirePendingCard\(\{/)
164
+ expect(fn, fnName).toMatch(new RegExp(builder))
165
+ // turn-safe injection gate
166
+ expect(fn, fnName).toMatch(/deliverResumeSyntheticOrBuffer\(/)
167
+ // re-offered when the operator returns
168
+ expect(fn, fnName).toMatch(/recordMissedApproval\(/)
169
+ // card edited to expired + store cleared
170
+ expect(fn, fnName).toMatch(/editCardExpired\(/)
171
+ expect(fn, fnName).toMatch(/pendingCardStore\.remove\(stageId\)/)
172
+ }
173
+ })
174
+
175
+ it('each family sweep is per-entry guarded via sweepExpiredEntries', () => {
176
+ for (const fnName of [
177
+ 'function sweepPendingVaultRequestAccesses',
178
+ 'function sweepPendingVaultRequestSaves',
179
+ 'function sweepPendingMentalModelProposes',
180
+ 'function sweepSecretRequests',
181
+ ]) {
182
+ const fn = slice(GATEWAY, fnName, 500)
183
+ expect(fn, fnName).toMatch(/sweepExpiredEntries\(/)
184
+ }
185
+ })
186
+
187
+ it('recordMissedApproval writes to the missed-approvals store (re-offer path)', () => {
188
+ const fn = slice(GATEWAY, 'function recordMissedApproval', 700)
189
+ expect(fn).toMatch(/missedApprovalsStore\.add\(/)
190
+ expect(fn).toMatch(/MISSED_APPROVAL_REOFFER_ENABLED/)
191
+ })
192
+ })
193
+
194
+ // ─── Copy-bug fix: derive the timeout window from the real TTL ─────────────
195
+
196
+ describe('vault_request_access copy derives the window from the real TTL', () => {
197
+ it('no hard-coded "10 min" claim; uses VAULT_REQUEST_ACCESS_TTL_MS', () => {
198
+ const fn = slice(GATEWAY, 'async function executeVaultRequestAccess', 12000)
199
+ expect(fn).not.toMatch(/times out \(10 min\)/)
200
+ expect(fn).toMatch(/Math\.round\(VAULT_REQUEST_ACCESS_TTL_MS \/ 60000\)/)
201
+ })
202
+ })
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Behavioral pins for the pure approval-card expiry core
3
+ * (gateway/pending-card-expiry.ts) — the ordering + fault-isolation contract
4
+ * the gateway's reaper relies on:
5
+ *
6
+ * - single-shot: two reaper ticks fire exactly ONE synthetic per expired card
7
+ * (delete-before-deliver, pinned behaviorally, not by regex),
8
+ * - a throwing deliver (half-dead IPC socket) never loses the
9
+ * missed-approvals re-offer and never escapes to the caller,
10
+ * - one throwing expiry never skips the remaining entries in a sweep.
11
+ */
12
+
13
+ import { describe, it, expect, vi } from 'vitest'
14
+ import type { InboundMessage } from '../gateway/ipc-protocol.js'
15
+ import {
16
+ expirePendingCard,
17
+ sweepExpiredEntries,
18
+ type ExpireCardDeps,
19
+ } from '../gateway/pending-card-expiry.js'
20
+
21
+ const INBOUND: InboundMessage = {
22
+ type: 'inbound',
23
+ chatId: '1',
24
+ messageId: 1,
25
+ user: 'vault-broker',
26
+ userId: 0,
27
+ ts: 1,
28
+ text: 'timeout',
29
+ }
30
+
31
+ function makeDeps(overrides: Partial<ExpireCardDeps> = {}): ExpireCardDeps & {
32
+ calls: string[]
33
+ } {
34
+ const calls: string[] = []
35
+ return {
36
+ calls,
37
+ remove: () => calls.push('remove'),
38
+ editCard: () => calls.push('editCard'),
39
+ buildInbound: () => {
40
+ calls.push('buildInbound')
41
+ return INBOUND
42
+ },
43
+ deliver: () => {
44
+ calls.push('deliver')
45
+ return true
46
+ },
47
+ recordMiss: () => calls.push('recordMiss'),
48
+ log: () => {},
49
+ ...overrides,
50
+ }
51
+ }
52
+
53
+ describe('expirePendingCard — ordering contract', () => {
54
+ it('runs remove FIRST, then editCard, then recordMiss BEFORE deliver', () => {
55
+ const deps = makeDeps()
56
+ const { delivered } = expirePendingCard(deps)
57
+ expect(delivered).toBe(true)
58
+ expect(deps.calls).toEqual(['remove', 'editCard', 'recordMiss', 'buildInbound', 'deliver'])
59
+ })
60
+
61
+ it('a throwing deliver returns delivered=false, never throws, and the re-offer is already recorded', () => {
62
+ const log = vi.fn()
63
+ const deps = makeDeps({
64
+ deliver: () => {
65
+ throw new Error('EPIPE: half-dead socket')
66
+ },
67
+ log,
68
+ })
69
+ const { delivered } = expirePendingCard(deps)
70
+ expect(delivered).toBe(false)
71
+ // recordMiss ran BEFORE the deliver attempt — the missed-approvals entry
72
+ // survives the failed wake (the operator's return re-offers it).
73
+ expect(deps.calls).toContain('recordMiss')
74
+ expect(deps.calls.indexOf('recordMiss')).toBeLessThan(deps.calls.indexOf('buildInbound'))
75
+ expect(log).toHaveBeenCalledWith(expect.stringContaining('delivery failed'))
76
+ })
77
+
78
+ it('a throwing editCard still records the miss and delivers the wake', () => {
79
+ const deps = makeDeps({
80
+ editCard: () => {
81
+ throw new Error('telegram edit failed')
82
+ },
83
+ })
84
+ const { delivered } = expirePendingCard(deps)
85
+ expect(delivered).toBe(true)
86
+ expect(deps.calls).toEqual(['remove', 'recordMiss', 'buildInbound', 'deliver'])
87
+ })
88
+
89
+ it('a throwing recordMiss still delivers the wake', () => {
90
+ const deps = makeDeps({
91
+ recordMiss: () => {
92
+ throw new Error('store write failed')
93
+ },
94
+ })
95
+ const { delivered } = expirePendingCard(deps)
96
+ expect(delivered).toBe(true)
97
+ expect(deps.calls).toContain('deliver')
98
+ })
99
+ })
100
+
101
+ describe('sweepExpiredEntries — single-shot across ticks', () => {
102
+ interface Entry {
103
+ staged_at: number
104
+ }
105
+ const TTL = 60_000
106
+
107
+ function makeSweepWorld() {
108
+ const map = new Map<string, Entry>()
109
+ const synthetics: string[] = []
110
+ const expire = (stageId: string, _v: Entry, _now: number) => {
111
+ // Mirrors the gateway wiring: expirePendingCard with remove-from-map
112
+ // FIRST and the synthetic recorded on deliver.
113
+ expirePendingCard({
114
+ remove: () => map.delete(stageId),
115
+ editCard: () => {},
116
+ buildInbound: () => INBOUND,
117
+ deliver: () => {
118
+ synthetics.push(stageId)
119
+ return true
120
+ },
121
+ recordMiss: () => {},
122
+ log: () => {},
123
+ })
124
+ }
125
+ const tick = (now: number) =>
126
+ sweepExpiredEntries(map, (v, n) => v.staged_at < n - TTL, expire, now, () => {})
127
+ return { map, synthetics, tick }
128
+ }
129
+
130
+ it('two reaper ticks fire exactly ONE synthetic per expired card', () => {
131
+ const { map, synthetics, tick } = makeSweepWorld()
132
+ map.set('s1', { staged_at: 0 })
133
+ map.set('s2', { staged_at: 0 })
134
+ map.set('fresh', { staged_at: 150_000 }) // still inside TTL at both ticks
135
+
136
+ tick(120_000) // both s1+s2 expired; fresh is not
137
+ tick(180_000) // second tick — expired entries are GONE from the map
138
+
139
+ expect(synthetics.sort()).toEqual(['s1', 's2'])
140
+ expect(map.has('fresh')).toBe(true)
141
+ expect(map.size).toBe(1)
142
+ })
143
+
144
+ it('delete-before-deliver: the map entry is gone by the time the synthetic fires', () => {
145
+ const map = new Map<string, Entry>()
146
+ map.set('s1', { staged_at: 0 })
147
+ let presentAtDeliver: boolean | null = null
148
+ sweepExpiredEntries(
149
+ map,
150
+ (v, n) => v.staged_at < n - TTL,
151
+ (stageId) => {
152
+ expirePendingCard({
153
+ remove: () => map.delete(stageId),
154
+ editCard: () => {},
155
+ buildInbound: () => INBOUND,
156
+ deliver: () => {
157
+ presentAtDeliver = map.has(stageId)
158
+ return true
159
+ },
160
+ recordMiss: () => {},
161
+ log: () => {},
162
+ })
163
+ },
164
+ 120_000,
165
+ () => {},
166
+ )
167
+ expect(presentAtDeliver).toBe(false)
168
+ })
169
+
170
+ it('one throwing expiry does not skip the remaining entries', () => {
171
+ const map = new Map<string, Entry>()
172
+ map.set('boom', { staged_at: 0 })
173
+ map.set('ok', { staged_at: 0 })
174
+ const expired: string[] = []
175
+ const log = vi.fn()
176
+ sweepExpiredEntries(
177
+ map,
178
+ (v, n) => v.staged_at < n - TTL,
179
+ (stageId) => {
180
+ if (stageId === 'boom') throw new Error('dead socket at the worst layer')
181
+ map.delete(stageId)
182
+ expired.push(stageId)
183
+ },
184
+ 120_000,
185
+ log,
186
+ )
187
+ expect(expired).toEqual(['ok'])
188
+ expect(log).toHaveBeenCalledWith(expect.stringContaining('boom'))
189
+ })
190
+ })
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Durable store for the four AGENT-INITIATED approval-card families
3
+ * (vault_request_access / vault_request_save / request_secret /
4
+ * mental_model_propose). Mirrors permission-card-store.test.ts.
5
+ *
6
+ * The load-bearing extra invariant here (beyond plain persistence): NO SECRET
7
+ * VALUE ever lands on disk. The file is written unencrypted; a vault_request_save
8
+ * stages the secret value in gateway memory only, and this store must persist
9
+ * just its key/metadata. The "no value field" test is the regression guard for
10
+ * that hygiene rule.
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
14
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync, statSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import { tmpdir } from 'node:os'
17
+ import {
18
+ createPendingCardStore,
19
+ type PersistedApprovalCard,
20
+ } from '../gateway/pending-card-store.js'
21
+
22
+ function makeTmpDir() {
23
+ return mkdtempSync(join(tmpdir(), 'pending-card-store-test-'))
24
+ }
25
+
26
+ const ACCESS: PersistedApprovalCard = {
27
+ family: 'vault_request_access',
28
+ stageId: 'a1b2c3d4',
29
+ agent: 'gymbro',
30
+ chatId: '10001',
31
+ cardMessageId: 555,
32
+ key: 'fatsecret/credentials',
33
+ scope: 'read',
34
+ reason: 'needs the API for the daily log',
35
+ ttlSeconds: 30 * 86400,
36
+ stagedAt: 1_700_000_000_000,
37
+ }
38
+
39
+ const SAVE: PersistedApprovalCard = {
40
+ family: 'vault_request_save',
41
+ stageId: 'e5f6a7b8',
42
+ agent: 'marko',
43
+ chatId: '10002',
44
+ cardMessageId: 556,
45
+ key: 'brevo/api-key',
46
+ kind: 'string',
47
+ why: 'store the campaign key',
48
+ stagedAt: 1_700_000_000_000,
49
+ }
50
+
51
+ const SECRET: PersistedApprovalCard = {
52
+ family: 'request_secret',
53
+ stageId: 'c9d0e1f2',
54
+ agent: 'gymbro',
55
+ chatId: '10003',
56
+ cardMessageId: 557,
57
+ key: 'openai/token',
58
+ reason: 'the model call needs it',
59
+ stagedAt: 1_700_000_000_000,
60
+ }
61
+
62
+ const MMP: PersistedApprovalCard = {
63
+ family: 'mental_model_propose',
64
+ stageId: 'aabbccdd',
65
+ agent: 'gymbro',
66
+ chatId: '10004',
67
+ cardMessageId: 558,
68
+ spec: { name: 'training-plan-state', source_query: 'the user current training plan' },
69
+ stagedAt: 1_700_000_000_000,
70
+ }
71
+
72
+ describe('createPendingCardStore', () => {
73
+ let dir: string
74
+ beforeEach(() => { dir = makeTmpDir() })
75
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
76
+
77
+ it('starts empty', () => {
78
+ expect(createPendingCardStore(dir).loadAll()).toEqual([])
79
+ })
80
+
81
+ it('add + loadAll round-trips each family', () => {
82
+ const store = createPendingCardStore(dir)
83
+ store.add(ACCESS)
84
+ store.add(SAVE)
85
+ store.add(SECRET)
86
+ store.add(MMP)
87
+ const all = store.loadAll()
88
+ expect(all.length).toBe(4)
89
+ expect(new Set(all.map(e => e.family))).toEqual(
90
+ new Set(['vault_request_access', 'vault_request_save', 'request_secret', 'mental_model_propose']),
91
+ )
92
+ })
93
+
94
+ it('add is idempotent on stageId (replaces in place)', () => {
95
+ const store = createPendingCardStore(dir)
96
+ store.add(SAVE)
97
+ store.add({ ...SAVE, cardMessageId: 999 })
98
+ const all = store.loadAll()
99
+ expect(all.length).toBe(1)
100
+ expect((all[0] as { cardMessageId?: number }).cardMessageId).toBe(999)
101
+ })
102
+
103
+ it('remove drops only the matching stageId', () => {
104
+ const store = createPendingCardStore(dir)
105
+ store.add(ACCESS)
106
+ store.add(SAVE)
107
+ store.remove(ACCESS.stageId)
108
+ const all = store.loadAll()
109
+ expect(all.length).toBe(1)
110
+ expect(all[0].stageId).toBe(SAVE.stageId)
111
+ })
112
+
113
+ it('remove is idempotent when the entry is absent', () => {
114
+ const store = createPendingCardStore(dir)
115
+ expect(() => store.remove('nope')).not.toThrow()
116
+ })
117
+
118
+ it('clear empties the store', () => {
119
+ const store = createPendingCardStore(dir)
120
+ store.add(ACCESS)
121
+ store.clear()
122
+ expect(store.loadAll()).toEqual([])
123
+ })
124
+
125
+ it('survives re-instantiation (data persists across gateway restart)', () => {
126
+ createPendingCardStore(dir).add(ACCESS)
127
+ const all = createPendingCardStore(dir).loadAll()
128
+ expect(all.length).toBe(1)
129
+ expect(all[0].stageId).toBe(ACCESS.stageId)
130
+ // The restored access card carries everything a post-restart tap needs to
131
+ // mint the grant: agent, key, scope, ttl, chat, card message id.
132
+ const restored = all[0] as Extract<PersistedApprovalCard, { family: 'vault_request_access' }>
133
+ expect(restored.agent).toBe('gymbro')
134
+ expect(restored.key).toBe('fatsecret/credentials')
135
+ expect(restored.scope).toBe('read')
136
+ expect(restored.ttlSeconds).toBe(30 * 86400)
137
+ expect(restored.cardMessageId).toBe(555)
138
+ })
139
+
140
+ // ── Secrets hygiene — the whole reason this store persists metadata only ──
141
+ it('a vault_request_save record has NO `value` field (type + runtime)', () => {
142
+ // Type-level: PersistedVaultSaveCard has no `value` member, so this object
143
+ // literal wouldn't compile with one. Runtime-level: assert it's absent.
144
+ expect('value' in SAVE).toBe(false)
145
+ })
146
+
147
+ it('the on-disk JSON contains NO secret value for a saved card', () => {
148
+ const store = createPendingCardStore(dir)
149
+ // Simulate the gateway callsite: it builds the persisted record from the
150
+ // pending entry WITHOUT copying `value`. Even if a caller mistakenly spread
151
+ // a value in, the type wouldn't allow it — but assert the file is clean.
152
+ store.add(SAVE)
153
+ const raw = readFileSync(join(dir, 'pending-approval-cards.json'), 'utf-8')
154
+ const SECRET_SENTINEL = 'super-secret-brevo-value'
155
+ expect(raw).not.toContain(SECRET_SENTINEL)
156
+ expect(raw).not.toContain('"value"')
157
+ // The key/metadata we DO need is present.
158
+ expect(raw).toContain('brevo/api-key')
159
+ expect(raw).toContain('vault_request_save')
160
+ })
161
+
162
+ it('the file ends up 0600 even when it pre-existed with lax perms', () => {
163
+ // writeFileSync's `mode` only applies on CREATE — a pre-existing
164
+ // world-readable file would keep its perms without the explicit chmod
165
+ // the store does on every write.
166
+ const filePath = join(dir, 'pending-approval-cards.json')
167
+ writeFileSync(filePath, '[]', { mode: 0o644 })
168
+ const store = createPendingCardStore(dir)
169
+ store.add(ACCESS)
170
+ expect(statSync(filePath).mode & 0o777).toBe(0o600)
171
+ expect(createPendingCardStore(dir).loadAll().length).toBe(1)
172
+ })
173
+ })