switchroom 0.7.15 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (301) hide show
  1. package/README.md +51 -59
  2. package/bin/run-hook.sh +27 -11
  3. package/bin/timezone-hook.sh +9 -7
  4. package/dist/agent-scheduler/index.js +410 -133
  5. package/dist/auth-broker/index.js +13932 -0
  6. package/dist/cli/switchroom.js +26937 -5601
  7. package/dist/host-control/main.js +12702 -0
  8. package/dist/vault/approvals/kernel-server.js +467 -184
  9. package/dist/vault/broker/server.js +1430 -724
  10. package/examples/minimal.yaml +63 -0
  11. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  12. package/examples/personal-google-workspace-mcp/README.md +194 -0
  13. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  14. package/examples/switchroom.yaml +220 -0
  15. package/package.json +7 -4
  16. package/profiles/_base/settings.json.hbs +20 -5
  17. package/profiles/_base/start.sh.hbs +16 -3
  18. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  19. package/profiles/_shared/telegram-style.md.hbs +20 -90
  20. package/profiles/_shared/vault-protocol.md.hbs +68 -0
  21. package/profiles/default/CLAUDE.md +50 -96
  22. package/profiles/default/CLAUDE.md.hbs +36 -6
  23. package/profiles/default/workspace/SOUL.md.hbs +12 -5
  24. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  25. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  26. package/skills/buildkite-api/SKILL.md +31 -8
  27. package/skills/buildkite-cli/SKILL.md +27 -9
  28. package/skills/buildkite-migration/SKILL.md +22 -9
  29. package/skills/buildkite-pipelines/SKILL.md +26 -9
  30. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  31. package/skills/buildkite-test-engine/SKILL.md +25 -8
  32. package/skills/docx/SKILL.md +1 -1
  33. package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
  34. package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
  35. package/skills/file-bug/SKILL.md +34 -6
  36. package/skills/humanizer/SKILL.md +15 -0
  37. package/skills/humanizer-calibrate/SKILL.md +7 -1
  38. package/skills/mcp-builder/SKILL.md +1 -1
  39. package/skills/pdf/SKILL.md +1 -1
  40. package/skills/pptx/SKILL.md +1 -1
  41. package/skills/skill-creator/SKILL.md +21 -1
  42. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  43. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  44. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  45. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  46. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  47. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  48. package/skills/switchroom-cli/SKILL.md +63 -64
  49. package/skills/switchroom-health/SKILL.md +23 -10
  50. package/skills/switchroom-install/SKILL.md +3 -3
  51. package/skills/switchroom-manage/SKILL.md +26 -19
  52. package/skills/switchroom-runtime/SKILL.md +191 -0
  53. package/skills/switchroom-status/SKILL.md +27 -2
  54. package/skills/telegram-test-harness/SKILL.md +3 -0
  55. package/skills/token-helpers/SKILL.md +24 -1
  56. package/skills/webapp-testing/SKILL.md +31 -1
  57. package/skills/xlsx/SKILL.md +1 -1
  58. package/telegram-plugin/admin-commands/index.ts +7 -5
  59. package/telegram-plugin/analytics-posthog.ts +191 -0
  60. package/telegram-plugin/bridge/bridge.ts +69 -0
  61. package/telegram-plugin/bridge/ipc-client.ts +4 -1
  62. package/telegram-plugin/dist/bridge/bridge.js +194 -119
  63. package/telegram-plugin/dist/gateway/gateway.js +23611 -19671
  64. package/telegram-plugin/dist/server.js +245 -189
  65. package/telegram-plugin/first-paint.ts +3 -24
  66. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  67. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  68. package/telegram-plugin/gateway/auth-command.ts +794 -0
  69. package/telegram-plugin/gateway/auth-line.ts +123 -0
  70. package/telegram-plugin/gateway/boot-card.ts +169 -40
  71. package/telegram-plugin/gateway/boot-issue-cache.ts +308 -0
  72. package/telegram-plugin/gateway/boot-probes.ts +166 -123
  73. package/telegram-plugin/gateway/boot-reason.ts +41 -7
  74. package/telegram-plugin/gateway/boot-version.ts +66 -0
  75. package/telegram-plugin/gateway/gateway.ts +3499 -1885
  76. package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
  77. package/telegram-plugin/gateway/ipc-protocol.ts +18 -0
  78. package/telegram-plugin/gateway/pending-inbound-buffer.ts +106 -0
  79. package/telegram-plugin/gateway/quarantine.ts +69 -0
  80. package/telegram-plugin/gateway/quota-cache.ts +9 -4
  81. package/telegram-plugin/gateway/reaction-trigger.ts +401 -0
  82. package/telegram-plugin/gateway/recent-denials.test.ts +103 -0
  83. package/telegram-plugin/gateway/recent-denials.ts +77 -0
  84. package/telegram-plugin/gateway/startup-network-retry.ts +109 -31
  85. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +125 -0
  86. package/telegram-plugin/history.ts +91 -0
  87. package/telegram-plugin/hooks/hooks.json +10 -0
  88. package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +130 -0
  89. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -2
  90. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +22 -2
  91. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  92. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  93. package/telegram-plugin/inbound-classifier.ts +50 -0
  94. package/telegram-plugin/inline-keyboard-callbacks.ts +136 -0
  95. package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  96. package/telegram-plugin/package.json +4 -2
  97. package/telegram-plugin/permission-rule.ts +51 -0
  98. package/telegram-plugin/permission-title.ts +56 -0
  99. package/telegram-plugin/quota-check.ts +19 -41
  100. package/telegram-plugin/registry/reaper.ts +223 -0
  101. package/telegram-plugin/retry-api-call.ts +80 -0
  102. package/telegram-plugin/runtime-metrics.ts +177 -0
  103. package/telegram-plugin/scripts/build.mjs +0 -1
  104. package/telegram-plugin/secret-detect/index.ts +24 -0
  105. package/telegram-plugin/secret-detect/vault-error.test.ts +64 -12
  106. package/telegram-plugin/secret-detect/vault-error.ts +78 -11
  107. package/telegram-plugin/secret-detect/vault-write.ts +14 -2
  108. package/telegram-plugin/server.js +41795 -0
  109. package/telegram-plugin/session-tail.ts +6 -1
  110. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  111. package/telegram-plugin/silence-poke.ts +420 -0
  112. package/telegram-plugin/silent-end.ts +174 -0
  113. package/telegram-plugin/stream-controller.ts +13 -0
  114. package/telegram-plugin/stream-reply-handler.ts +7 -0
  115. package/telegram-plugin/subagent-watcher.ts +213 -4
  116. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  117. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  118. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  119. package/telegram-plugin/tests/boot-card-issue-dedup.test.ts +247 -0
  120. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +182 -0
  121. package/telegram-plugin/tests/boot-card-reason.test.ts +65 -2
  122. package/telegram-plugin/tests/boot-card-render.test.ts +146 -0
  123. package/telegram-plugin/tests/boot-card-silent-on-operator.test.ts +103 -0
  124. package/telegram-plugin/tests/boot-probes.test.ts +216 -10
  125. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  126. package/telegram-plugin/tests/finalize-callback.test.ts +190 -0
  127. package/telegram-plugin/tests/gateway-message-validator.test.ts +26 -0
  128. package/telegram-plugin/tests/gateway-secret-detect.test.ts +12 -3
  129. package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +104 -0
  130. package/telegram-plugin/tests/history-reaper.test.ts +378 -0
  131. package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
  132. package/telegram-plugin/tests/inbound-classifier.test.ts +76 -0
  133. package/telegram-plugin/tests/inbound-message-types.test.ts +267 -0
  134. package/telegram-plugin/tests/issues-card.test.ts +49 -0
  135. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +132 -0
  136. package/telegram-plugin/tests/permission-rule.test.ts +80 -1
  137. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  138. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  139. package/telegram-plugin/tests/races.test.ts +179 -0
  140. package/telegram-plugin/tests/reaction-trigger-flow.test.ts +353 -0
  141. package/telegram-plugin/tests/reaction-trigger.test.ts +397 -0
  142. package/telegram-plugin/tests/retry-api-call.test.ts +152 -1
  143. package/telegram-plugin/tests/runtime-metrics.test.ts +145 -0
  144. package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +155 -0
  145. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +133 -0
  146. package/telegram-plugin/tests/secret-detect-false-positives.test.ts +137 -0
  147. package/telegram-plugin/tests/silence-poke.test.ts +493 -0
  148. package/telegram-plugin/tests/silent-end.test.ts +206 -0
  149. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +107 -0
  150. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +224 -0
  151. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +316 -0
  152. package/telegram-plugin/tests/subagent-watcher.test.ts +263 -0
  153. package/telegram-plugin/tests/turn-signal-tracker.test.ts +81 -0
  154. package/telegram-plugin/tests/vault-approval-posture.test.ts +256 -0
  155. package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +73 -0
  156. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +226 -0
  157. package/telegram-plugin/tests/vault-grant-union.test.ts +130 -0
  158. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +140 -0
  159. package/telegram-plugin/tests/vault-posture-quarantine.test.ts +104 -0
  160. package/telegram-plugin/tests/vault-request-access-tool.test.ts +114 -0
  161. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +106 -0
  162. package/telegram-plugin/turn-signal-tracker.ts +100 -24
  163. package/telegram-plugin/uat/SETUP.md +210 -35
  164. package/telegram-plugin/uat/assertions.ts +264 -37
  165. package/telegram-plugin/uat/driver-info.ts +57 -0
  166. package/telegram-plugin/uat/driver.ts +590 -51
  167. package/telegram-plugin/uat/harness.ts +140 -94
  168. package/telegram-plugin/uat/load-env.test.ts +72 -0
  169. package/telegram-plugin/uat/load-env.ts +48 -0
  170. package/telegram-plugin/uat/login.ts +96 -53
  171. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  172. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  173. package/telegram-plugin/uat/runners/report.ts +150 -0
  174. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  175. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  176. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  177. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  178. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  179. package/telegram-plugin/uat/scenarios/ask-user-button-tap-dm.test.ts +141 -0
  180. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +191 -0
  181. package/telegram-plugin/uat/scenarios/fuzz-extended-dm.test.ts +255 -0
  182. package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +275 -0
  183. package/telegram-plugin/uat/scenarios/fuzz-random-prompts-dm.test.ts +146 -0
  184. package/telegram-plugin/uat/scenarios/fuzz-status-ask-dm.test.ts +486 -0
  185. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +67 -0
  186. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +100 -0
  187. package/telegram-plugin/uat/scenarios/jtbd-soft-commit-dm.test.ts +67 -0
  188. package/telegram-plugin/uat/scenarios/jtbd-status-query-dm.test.ts +49 -0
  189. package/telegram-plugin/uat/scenarios/location-inbound-dm.test.ts +65 -0
  190. package/telegram-plugin/uat/scenarios/midturn-silent-dm.test.ts +175 -0
  191. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +142 -0
  192. package/telegram-plugin/uat/scenarios/reactions-trigger-turn-dm.test.ts +96 -0
  193. package/telegram-plugin/uat/scenarios/secret-redaction-deletes-original-dm.test.ts +123 -0
  194. package/telegram-plugin/uat/scenarios/secret-redaction-no-false-positive-dm.test.ts +87 -0
  195. package/telegram-plugin/uat/scenarios/silence-poke-soft-dm.test.ts +155 -0
  196. package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +95 -0
  197. package/telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts +57 -0
  198. package/telegram-plugin/uat/scenarios/subagent-watcher-no-rerun-dm.test.ts +135 -0
  199. package/telegram-plugin/uat/scenarios/vault-approval-posture-telegram-id-dm.test.ts +191 -0
  200. package/telegram-plugin/uat/scenarios/vault-audit-allow-dm.test.ts +108 -0
  201. package/telegram-plugin/uat/scenarios/vault-grant-auto-resume-dm.test.ts +121 -0
  202. package/telegram-plugin/uat/scenarios/vault-request-access-concurrent-dm.test.ts +161 -0
  203. package/telegram-plugin/uat/scenarios/vault-request-access-end-to-end-dm.test.ts +158 -0
  204. package/telegram-plugin/uat/scenarios/voice-inbound-dm.test.ts +65 -0
  205. package/telegram-plugin/vault-approval-posture.ts +42 -0
  206. package/telegram-plugin/welcome-text.ts +1 -0
  207. package/telegram-plugin/active-pins-sweep.ts +0 -204
  208. package/telegram-plugin/active-pins.ts +0 -146
  209. package/telegram-plugin/auth-dashboard.ts +0 -1104
  210. package/telegram-plugin/auth-slot-parser.ts +0 -497
  211. package/telegram-plugin/card-event-log.ts +0 -138
  212. package/telegram-plugin/dist/foreman/foreman.js +0 -31106
  213. package/telegram-plugin/docs/multi-agent-card-design.md +0 -847
  214. package/telegram-plugin/docs/pinned-progress-card-reliability.md +0 -144
  215. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  216. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  217. package/telegram-plugin/foreman/foreman.ts +0 -1165
  218. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  219. package/telegram-plugin/foreman/setup-state.ts +0 -239
  220. package/telegram-plugin/foreman/state.ts +0 -203
  221. package/telegram-plugin/pin-event-log.ts +0 -76
  222. package/telegram-plugin/progress-card-driver.ts +0 -2886
  223. package/telegram-plugin/progress-card-pin-manager.ts +0 -589
  224. package/telegram-plugin/progress-card-pin-watchdog.ts +0 -98
  225. package/telegram-plugin/progress-card.ts +0 -1409
  226. package/telegram-plugin/tests/HARNESS.md +0 -340
  227. package/telegram-plugin/tests/_progress-card-harness.ts +0 -109
  228. package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +0 -211
  229. package/telegram-plugin/tests/active-pins-sweep.test.ts +0 -309
  230. package/telegram-plugin/tests/active-pins.test.ts +0 -187
  231. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  232. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  233. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  234. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  235. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  236. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  237. package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +0 -201
  238. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  239. package/telegram-plugin/tests/card-event-log.test.ts +0 -145
  240. package/telegram-plugin/tests/first-paint.test.ts +0 -257
  241. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  242. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  243. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  244. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  245. package/telegram-plugin/tests/harness-ordering-invariants.test.ts +0 -243
  246. package/telegram-plugin/tests/pin-event-log.test.ts +0 -124
  247. package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +0 -73
  248. package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +0 -272
  249. package/telegram-plugin/tests/progress-card-cross-turn.test.ts +0 -258
  250. package/telegram-plugin/tests/progress-card-delay-842.test.ts +0 -160
  251. package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +0 -81
  252. package/telegram-plugin/tests/progress-card-draft-flag.test.ts +0 -80
  253. package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +0 -215
  254. package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +0 -123
  255. package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +0 -76
  256. package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +0 -62
  257. package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +0 -84
  258. package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +0 -139
  259. package/telegram-plugin/tests/progress-card-pin-manager.test.ts +0 -773
  260. package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +0 -66
  261. package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +0 -64
  262. package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +0 -190
  263. package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +0 -146
  264. package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +0 -123
  265. package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +0 -82
  266. package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +0 -114
  267. package/telegram-plugin/tests/real-gateway-harness.ts +0 -699
  268. package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +0 -313
  269. package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +0 -299
  270. package/telegram-plugin/tests/real-gateway-spec.test.ts +0 -487
  271. package/telegram-plugin/tests/real-gateway.smoke.test.ts +0 -101
  272. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  273. package/telegram-plugin/tests/setup-state.test.ts +0 -146
  274. package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +0 -116
  275. package/telegram-plugin/tests/turn-end-regressions.test.ts +0 -489
  276. package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +0 -218
  277. package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +0 -78
  278. package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +0 -131
  279. package/telegram-plugin/tests/two-zone-bg-detection.test.ts +0 -120
  280. package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +0 -116
  281. package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +0 -87
  282. package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +0 -211
  283. package/telegram-plugin/tests/two-zone-card-cap.test.ts +0 -62
  284. package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +0 -101
  285. package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +0 -78
  286. package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +0 -110
  287. package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +0 -128
  288. package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +0 -58
  289. package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +0 -133
  290. package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +0 -155
  291. package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +0 -117
  292. package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +0 -187
  293. package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +0 -149
  294. package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +0 -101
  295. package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +0 -114
  296. package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +0 -105
  297. package/telegram-plugin/tests/waiting-ux-harness.ts +0 -381
  298. package/telegram-plugin/tests/waiting-ux.e2e.test.ts +0 -233
  299. package/telegram-plugin/turn-flush-prose-recovery.ts +0 -40
  300. package/telegram-plugin/two-zone-card.ts +0 -269
  301. package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +0 -61
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Contract pins for the `vault.broker.approvalAuth` posture toggle and
3
+ * its #1115 follow-up — broker-mediated attestation.
4
+ *
5
+ * Posture summary:
6
+ * - `passphrase` (default): Approve on a grant card prompts the
7
+ * operator for the vault passphrase. Two-factor (Telegram ID +
8
+ * passphrase). Gateway holds the passphrase only briefly after
9
+ * operator typing.
10
+ * - `telegram-id` (opt-in): Approve mints immediately. The gateway
11
+ * signals operator-tap intent to the broker via
12
+ * `attest_via_posture: true` on the mint_grant call; the broker
13
+ * uses its OWN retained passphrase internally. Single-factor;
14
+ * passphrase never leaves the broker process.
15
+ *
16
+ * Load-bearing invariants pinned here:
17
+ * 1. The resolver returns the posture mode and nothing else — the
18
+ * gateway no longer holds the passphrase in memory under
19
+ * telegram-id (the #1115 first-cut and #1115-follow-up-v1
20
+ * designs did, and the reviewer flagged it as a bypass).
21
+ * 2. The allowlist check is the FIRST gate in every vault callback
22
+ * handler — no posture branching, no mint, no
23
+ * `pendingVaultOps.set` runs before it.
24
+ * 3. handleVaultDeferCallback and handleVaultRequestSaveCallback
25
+ * under telegram-id NO LONGER short-circuit on an in-memory
26
+ * passphrase — they fall through to the standard
27
+ * cached-passphrase path (#1115 follow-up cleanup; the original
28
+ * shortcut was the same bypass class as the access-approve
29
+ * one).
30
+ * 4. handleVaultRequestAccessCallback under telegram-id calls
31
+ * `performVaultAccessApproval` with `{ kind: 'posture' }` — the
32
+ * attestation type that drives `attest_via_posture: true` on
33
+ * mint_grant.
34
+ * 5. `handleVaultGrantCallback` (operator-initiated wizard) NEVER
35
+ * references `VAULT_APPROVAL_AUTH_MODE` — flipping posture
36
+ * cannot change wizard behaviour.
37
+ */
38
+
39
+ import { describe, expect, it } from 'vitest'
40
+ import { readFileSync } from 'node:fs'
41
+ import { resolve } from 'node:path'
42
+ import { resolveVaultApprovalPosture } from '../vault-approval-posture.js'
43
+
44
+ const gatewaySrc = readFileSync(
45
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
46
+ 'utf-8',
47
+ )
48
+
49
+ function sliceAccessApproveBlock(): string {
50
+ const fn =
51
+ gatewaySrc.split('async function handleVaultRequestAccessCallback')[1]?.split('async function')[0] ?? ''
52
+ return fn.split("if (action === 'approve')")[1]?.split("await ctx.answerCallbackQuery({ text: 'Unknown action'")[0] ?? ''
53
+ }
54
+
55
+ describe('vault grant approval posture — module-level wiring', () => {
56
+ it('declares the posture mode holder; does NOT hold the passphrase in memory', () => {
57
+ expect(gatewaySrc).toMatch(/let VAULT_APPROVAL_AUTH_MODE:\s*['"]passphrase['"]\s*\|\s*['"]telegram-id['"]/)
58
+ // Regression guard: post-#1115-follow-up the gateway must NOT
59
+ // declare AUTO_UNLOCK_PASSPHRASE — the passphrase stays in the
60
+ // broker. If a future change reintroduces this variable, the
61
+ // reviewer's "agent can self-mint" bypass returns.
62
+ expect(gatewaySrc).not.toMatch(/let AUTO_UNLOCK_PASSPHRASE/)
63
+ expect(gatewaySrc).not.toMatch(/AUTO_UNLOCK_PASSPHRASE\s*=/)
64
+ })
65
+
66
+ it('initialises posture from switchroom config at startup', () => {
67
+ expect(gatewaySrc).toMatch(/function initVaultApprovalPosture/)
68
+ expect(gatewaySrc).toMatch(/initVaultApprovalPosture\(\)/)
69
+ expect(gatewaySrc).toMatch(/resolveVaultApprovalPosture\(/)
70
+ })
71
+ })
72
+
73
+ describe('handleVaultRequestAccessCallback — posture branch', () => {
74
+ it('mints via posture attestation (NOT in-memory passphrase) when posture is telegram-id', () => {
75
+ const approveBlock = sliceAccessApproveBlock()
76
+ expect(approveBlock).toMatch(/VAULT_APPROVAL_AUTH_MODE === ['"]telegram-id['"]/)
77
+ // Pinned: the call shape MUST be `{ kind: 'posture' }`. If the
78
+ // gateway ever reverts to passing a real passphrase here, the
79
+ // bypass surface returns.
80
+ expect(approveBlock).toMatch(/performVaultAccessApproval\(ctx, pending, stageId, senderId, \{ kind: ['"]posture['"] \}\)/)
81
+ expect(approveBlock).toMatch(/Approved by @/)
82
+ })
83
+
84
+ it('preserves the allowlist guard regardless of posture', () => {
85
+ const handlerBlock =
86
+ gatewaySrc.split('async function handleVaultRequestAccessCallback')[1]?.split('async function')[0] ?? ''
87
+ expect(handlerBlock).toMatch(/if \(!access\.allowFrom\.includes\(senderId\)\)/)
88
+ expect(handlerBlock).toMatch(/Not authorized/)
89
+ })
90
+
91
+ it('passphrase-mode branch unchanged: cache lookup + prompt still present', () => {
92
+ const approveBlock = sliceAccessApproveBlock()
93
+ expect(approveBlock).toMatch(/vaultPassphraseCache\.get\(pending\.chat_id\)/)
94
+ expect(approveBlock).toMatch(/Reply with your passphrase/i)
95
+ expect(approveBlock).toMatch(/passphrase-for-access-approve/)
96
+ // Pinned: the queued-drain path passes the typed passphrase via
97
+ // the new attestation shape `{ kind: 'passphrase', passphrase }`.
98
+ expect(gatewaySrc).toMatch(/performVaultAccessApproval\(ctx, stagedAccess, item\.stageId, item\.senderId, \{ kind: ['"]passphrase['"], passphrase \}\)/)
99
+ })
100
+ })
101
+
102
+ describe('performVaultAccessApproval — broker-mediated attestation', () => {
103
+ it('builds brokerAuthOpts from the AccessApprovalAttestation discriminator', () => {
104
+ const fnBlock =
105
+ gatewaySrc
106
+ .split('async function performVaultAccessApproval')[1]
107
+ ?.split('async function handleVaultRequestAccessCallback')[0] ?? ''
108
+ // Pinned: the passphrase variant feeds the broker passphrase
109
+ // attestation; the posture variant feeds `attest_via_posture:
110
+ // true`. NOT a free-form union — the discriminator is what
111
+ // makes the call shapes type-safe.
112
+ expect(fnBlock).toMatch(/attestation\.kind === ['"]passphrase['"]/)
113
+ expect(fnBlock).toMatch(/attest_via_posture: true/)
114
+ expect(fnBlock).toMatch(/passphrase: attestation\.passphrase/)
115
+ // Pinned: the same brokerAuthOpts threads both
116
+ // listGrantsViaBroker (for #1051 grant-union) AND
117
+ // mintGrantViaBroker. If only one is wired, the union path
118
+ // silently re-strands the prior token under telegram-id.
119
+ expect(fnBlock).toMatch(/listGrantsViaBroker\(pending\.agent, brokerAuthOpts\)/)
120
+ expect(fnBlock).toMatch(/\.\.\.brokerAuthOpts/)
121
+ })
122
+ })
123
+
124
+ describe('handleVaultRequestSaveCallback — telegram-id silent path withdrawn', () => {
125
+ it('NO LONGER reads an in-memory passphrase for telegram-id; falls through to cached-passphrase path', () => {
126
+ const fnBlock =
127
+ gatewaySrc
128
+ .split('async function handleVaultRequestSaveCallback')[1]
129
+ ?.split('async function handleVaultDeferCallback')[0] ?? ''
130
+ // Regression guard: the original PR added a
131
+ // `VAULT_APPROVAL_AUTH_MODE === 'telegram-id'` shortcut that
132
+ // pulled an in-memory passphrase. That was a bypass surface.
133
+ // The save handler must NOT branch on the posture for an
134
+ // in-memory passphrase any more.
135
+ expect(fnBlock).not.toMatch(/AUTO_UNLOCK_PASSPHRASE/)
136
+ // Standard cache lookup still present.
137
+ expect(fnBlock).toMatch(/vaultPassphraseCache\.get\(pending\.chat_id\)/)
138
+ })
139
+ })
140
+
141
+ describe('handleVaultDeferCallback — telegram-id silent path withdrawn', () => {
142
+ it('NO LONGER reads an in-memory passphrase for telegram-id; falls through to cached-passphrase path', () => {
143
+ const fnBlock =
144
+ gatewaySrc
145
+ .split('async function handleVaultDeferCallback')[1]
146
+ ?.split('\nasync function ')[0] ?? ''
147
+ expect(fnBlock).not.toMatch(/AUTO_UNLOCK_PASSPHRASE/)
148
+ // Cached-passphrase path still present.
149
+ const unlockBranch = fnBlock.split("if (action === 'unlock')")[1] ?? ''
150
+ expect(unlockBranch).toMatch(/vaultPassphraseCache\.get\(/)
151
+ })
152
+ })
153
+
154
+ describe('handleVaultGrantCallback (wizard) — posture cannot affect wizard', () => {
155
+ it('wizard handler never references VAULT_APPROVAL_AUTH_MODE — posture flips are inert here', () => {
156
+ const fnBlock =
157
+ gatewaySrc
158
+ .split('async function handleVaultGrantCallback')[1]
159
+ ?.split('\nasync function ')[0] ?? ''
160
+ expect(fnBlock).not.toMatch(/VAULT_APPROVAL_AUTH_MODE/)
161
+ expect(fnBlock).not.toMatch(/AUTO_UNLOCK_PASSPHRASE/)
162
+ expect(fnBlock).not.toMatch(/attest_via_posture/)
163
+ })
164
+ })
165
+
166
+ describe('allowlist is the first gate in every vault callback handler', () => {
167
+ function handlerBody(name: string): string {
168
+ const after = gatewaySrc.split(`async function ${name}(`)[1] ?? ''
169
+ return after.split('\nasync function ')[0] ?? ''
170
+ }
171
+ for (const handler of [
172
+ 'handleVaultRequestAccessCallback',
173
+ 'handleVaultRequestSaveCallback',
174
+ 'handleVaultDeferCallback',
175
+ 'handleVaultGrantCallback',
176
+ ]) {
177
+ it(`${handler}: allowlist check fires before any other branching`, () => {
178
+ const body = handlerBody(handler)
179
+ expect(body).not.toBe('')
180
+ const idx = body.indexOf('if (')
181
+ const firstGuard = body.slice(idx, body.indexOf('\n', body.indexOf('\n', idx) + 1))
182
+ expect(firstGuard).toMatch(/access\.allowFrom\.includes\(senderId\)/)
183
+ })
184
+
185
+ it(`${handler}: no callback side-effect appears before the allowlist check`, () => {
186
+ const body = handlerBody(handler)
187
+ const beforeAllowlist = body.split('access.allowFrom.includes(senderId)')[0] ?? body
188
+ for (const sentinel of [
189
+ 'mintGrantViaBroker',
190
+ 'performVaultAccessApproval',
191
+ 'pendingVaultOps.set',
192
+ "VAULT_APPROVAL_AUTH_MODE === 'telegram-id'",
193
+ 'attest_via_posture',
194
+ ]) {
195
+ expect(
196
+ beforeAllowlist.includes(sentinel),
197
+ `${handler}: sentinel "${sentinel}" must NOT appear before the allowlist check`,
198
+ ).toBe(false)
199
+ }
200
+ })
201
+ }
202
+ })
203
+
204
+ describe('resolveVaultApprovalPosture — runtime behaviour', () => {
205
+ it('passphrase posture when approvalAuth is absent', () => {
206
+ expect(resolveVaultApprovalPosture(undefined)).toEqual({ mode: 'passphrase' })
207
+ expect(resolveVaultApprovalPosture({})).toEqual({ mode: 'passphrase' })
208
+ })
209
+
210
+ it('passphrase posture when approvalAuth is explicitly passphrase', () => {
211
+ expect(resolveVaultApprovalPosture({ approvalAuth: 'passphrase' })).toEqual({ mode: 'passphrase' })
212
+ })
213
+
214
+ it('telegram-id posture when approvalAuth is telegram-id', () => {
215
+ expect(resolveVaultApprovalPosture({ approvalAuth: 'telegram-id' })).toEqual({ mode: 'telegram-id' })
216
+ })
217
+
218
+ it('defence-in-depth: unknown approvalAuth values fall back to passphrase (schema rejects them, but trust nothing)', () => {
219
+ expect(resolveVaultApprovalPosture({ approvalAuth: 'TELEGRAM-ID' })).toEqual({ mode: 'passphrase' })
220
+ expect(resolveVaultApprovalPosture({ approvalAuth: 'telegram_id' })).toEqual({ mode: 'passphrase' })
221
+ expect(resolveVaultApprovalPosture({ approvalAuth: '' })).toEqual({ mode: 'passphrase' })
222
+ expect(resolveVaultApprovalPosture({ approvalAuth: 'nonsense' })).toEqual({ mode: 'passphrase' })
223
+ })
224
+
225
+ it('adversarial fuzz: 200 random inputs never return a non-passphrase, non-telegram-id mode and never throw', () => {
226
+ const rand = mulberry32(0xdeadbeef)
227
+ const choices: any[] = [
228
+ undefined,
229
+ 'passphrase',
230
+ 'telegram-id',
231
+ 'PASSPHRASE',
232
+ '',
233
+ 'nonsense',
234
+ null,
235
+ 0,
236
+ false,
237
+ { nested: 'telegram-id' },
238
+ ]
239
+ for (let i = 0; i < 200; i++) {
240
+ const broker = { approvalAuth: choices[Math.floor(rand() * choices.length)] }
241
+ const result = resolveVaultApprovalPosture(broker as never)
242
+ expect(result.mode === 'passphrase' || result.mode === 'telegram-id', `iter ${i}: mode must be one of the two literals`).toBe(true)
243
+ }
244
+ })
245
+ })
246
+
247
+ function mulberry32(seed: number): () => number {
248
+ let a = seed >>> 0
249
+ return () => {
250
+ a = (a + 0x6D2B79F5) >>> 0
251
+ let t = a
252
+ t = Math.imul(t ^ (t >>> 15), t | 1)
253
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
254
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
255
+ }
256
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Contract pin for #1052 — agent auto-resumes after operator approves
3
+ * a vault_request_access card.
4
+ *
5
+ * Pre-fix: agent called vault_request_access → tool returned ack →
6
+ * agent's turn ended ("waiting for approval"). Operator approved later
7
+ * → grant minted → BUT the agent did nothing because its turn was
8
+ * already over. Operator had to send a fresh message to kick the
9
+ * agent back into action.
10
+ *
11
+ * Fix: after successful mint (via passphrase-attestation broker call
12
+ * + token-write), the gateway injects a synthetic InboundMessage into
13
+ * the agent's bridge via `ipcServer.sendToAgent`. The bridge sees it
14
+ * as a normal channel event (with meta.source="vault_grant_approved"
15
+ * for distinct rendering) and starts a new turn. Re-uses the existing
16
+ * inject_inbound primitive that cron has used since #890+.
17
+ *
18
+ * This file pins the call site so a future refactor can't quietly
19
+ * drop the injection.
20
+ */
21
+
22
+ import { describe, it, expect } from "vitest";
23
+ import { readFileSync } from "node:fs";
24
+ import { resolve } from "node:path";
25
+
26
+ const gatewaySrc = readFileSync(
27
+ resolve(__dirname, "..", "gateway", "gateway.ts"),
28
+ "utf-8",
29
+ );
30
+
31
+ function extractPerformBlock(): string {
32
+ const start = gatewaySrc.indexOf("async function performVaultAccessApproval");
33
+ if (start < 0) throw new Error("performVaultAccessApproval not found");
34
+ const restAfter = gatewaySrc.slice(start + 1);
35
+ const endRel = restAfter.indexOf("\nasync function ");
36
+ return gatewaySrc.slice(start, start + 1 + (endRel >= 0 ? endRel : restAfter.length));
37
+ }
38
+
39
+ describe("performVaultAccessApproval injects a synthetic inbound on success (#1052)", () => {
40
+ const block = extractPerformBlock();
41
+
42
+ it("calls ipcServer.sendToAgent AFTER successful mint + token-write", () => {
43
+ // fails when: the auto-resume injection gets dropped. Pre-fix
44
+ // operator had to message the agent again to resume the task —
45
+ // the injection is the load-bearing wiring.
46
+ expect(block, "missing ipcServer.sendToAgent call").toMatch(/ipcServer\.sendToAgent\(/);
47
+ // Must run AFTER the mint-success path (i.e., after the
48
+ // `result.kind === 'error'` early-return guard).
49
+ const errorReturn = block.indexOf("result.kind === 'error'");
50
+ const sendIdx = block.indexOf("ipcServer.sendToAgent(");
51
+ expect(errorReturn).toBeGreaterThan(0);
52
+ expect(sendIdx, "sendToAgent must come AFTER the error-return early exit").toBeGreaterThan(errorReturn);
53
+ });
54
+
55
+ it("delegates inbound construction to buildVaultGrantApprovedInbound", () => {
56
+ // PR #1168 extracted the InboundMessage literals (meta.source,
57
+ // user, userId, meta.{agent,key,scope,grant_id,stage_id,operator_id})
58
+ // into `gateway/vault-grant-inbound-builders.ts`. The shape itself
59
+ // is now pinned by `vault-grant-inbound-builders.test.ts` against
60
+ // the builder directly. What this test still pins is the call-site
61
+ // contract: `performVaultAccessApproval` must keep wiring to the
62
+ // builder — a regression that inlines or replaces the builder
63
+ // would silently drop the meta fields downstream filters rely on.
64
+ expect(block).toMatch(/buildVaultGrantApprovedInbound\(/);
65
+ });
66
+
67
+ it("logs delivery outcome to stderr for forensics", () => {
68
+ // Mirrors the cron inject_inbound logging at gateway.ts:2470 so
69
+ // ops can confirm an injection actually delivered (vs the agent's
70
+ // bridge being down).
71
+ expect(block).toMatch(/vault_grant_approved injection[\s\S]*delivered/);
72
+ });
73
+ });
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Pin the InboundMessage shapes the gateway synthesizes when the
3
+ * operator taps Approve / Deny on a `vault_request_access` card
4
+ * (#1052 / #1150). A regression that drops a `meta.source` field, or
5
+ * changes the source string, would silently break the agent's wake-
6
+ * up — the bridge wouldn't recognize the source, the message would
7
+ * render as a generic channel event, and the model wouldn't know it
8
+ * was an approval response.
9
+ *
10
+ * These tests are the cheap regression guard. The wire shape is
11
+ * load-bearing — downstream filters / dashboards / future replay
12
+ * tooling may anchor on individual meta fields.
13
+ */
14
+
15
+ import { describe, it, expect } from 'vitest'
16
+ import {
17
+ buildVaultGrantApprovedInbound,
18
+ buildVaultGrantDeniedInbound,
19
+ type VaultGrantInboundContext,
20
+ } from '../gateway/vault-grant-inbound-builders.js'
21
+
22
+ const FIXED_NOW = 1_700_000_000_000
23
+
24
+ const CTX_READ: VaultGrantInboundContext = {
25
+ agent: 'gymbro',
26
+ key: 'fatsecret/credentials',
27
+ scope: 'read',
28
+ chat_id: '8248703757',
29
+ ttl_seconds: 30 * 86400,
30
+ }
31
+
32
+ const CTX_WRITE: VaultGrantInboundContext = {
33
+ ...CTX_READ,
34
+ key: 'analytics/api-token',
35
+ scope: 'write',
36
+ ttl_seconds: 7 * 86400,
37
+ }
38
+
39
+ describe('buildVaultGrantApprovedInbound', () => {
40
+ it('emits the canonical envelope (type, chat_id, user, userId, ts, messageId)', () => {
41
+ const msg = buildVaultGrantApprovedInbound({
42
+ ctx: CTX_READ,
43
+ grantId: 'vg_a1b2c3',
44
+ stageId: 'stage-001',
45
+ operatorId: '8248703757',
46
+ nowMs: FIXED_NOW,
47
+ })
48
+ expect(msg.type).toBe('inbound')
49
+ expect(msg.chatId).toBe('8248703757')
50
+ expect(msg.user).toBe('vault-broker')
51
+ expect(msg.userId).toBe(0)
52
+ expect(msg.ts).toBe(FIXED_NOW)
53
+ // messageId is synthetic — pin that it equals ts so the gateway
54
+ // can produce a stable id under fake-clock tests without colliding
55
+ // with real Telegram messageIds.
56
+ expect(msg.messageId).toBe(FIXED_NOW)
57
+ })
58
+
59
+ it('pins meta.source = "vault_grant_approved" — load-bearing for the bridge', () => {
60
+ const msg = buildVaultGrantApprovedInbound({
61
+ ctx: CTX_READ,
62
+ grantId: 'vg_x',
63
+ stageId: 's',
64
+ operatorId: '1',
65
+ })
66
+ expect(msg.meta?.source).toBe('vault_grant_approved')
67
+ })
68
+
69
+ it('carries all forensic fields in meta', () => {
70
+ const msg = buildVaultGrantApprovedInbound({
71
+ ctx: CTX_READ,
72
+ grantId: 'vg_a1b2c3',
73
+ stageId: 'stage-001',
74
+ operatorId: '8248703757',
75
+ })
76
+ expect(msg.meta).toEqual({
77
+ source: 'vault_grant_approved',
78
+ agent: 'gymbro',
79
+ key: 'fatsecret/credentials',
80
+ scope: 'read',
81
+ grant_id: 'vg_a1b2c3',
82
+ stage_id: 'stage-001',
83
+ operator_id: '8248703757',
84
+ })
85
+ })
86
+
87
+ it('text names the key, scope, ttl-in-days, and grant id', () => {
88
+ const msg = buildVaultGrantApprovedInbound({
89
+ ctx: CTX_READ,
90
+ grantId: 'vg_a1b2c3',
91
+ stageId: 's',
92
+ operatorId: '1',
93
+ })
94
+ expect(msg.text).toContain('approved')
95
+ expect(msg.text).toContain('`fatsecret/credentials`')
96
+ expect(msg.text).toContain('scope=read')
97
+ expect(msg.text).toContain('30d')
98
+ expect(msg.text).toContain('grant=vg_a1b2c3')
99
+ // Instructional: tells the agent how to recover the value.
100
+ expect(msg.text).toContain('switchroom vault get')
101
+ })
102
+
103
+ it('rounds ttl_seconds to days', () => {
104
+ const ctx7d: VaultGrantInboundContext = { ...CTX_READ, ttl_seconds: 7 * 86400 }
105
+ const msg = buildVaultGrantApprovedInbound({
106
+ ctx: ctx7d,
107
+ grantId: 'vg_x',
108
+ stageId: 's',
109
+ operatorId: '1',
110
+ })
111
+ expect(msg.text).toContain('7d')
112
+ })
113
+
114
+ it('honors write scope in text + meta', () => {
115
+ const msg = buildVaultGrantApprovedInbound({
116
+ ctx: CTX_WRITE,
117
+ grantId: 'vg_x',
118
+ stageId: 's',
119
+ operatorId: '1',
120
+ })
121
+ expect(msg.text).toContain('scope=write')
122
+ expect(msg.meta?.scope).toBe('write')
123
+ })
124
+
125
+ it('defaults nowMs to Date.now() when omitted', () => {
126
+ const before = Date.now()
127
+ const msg = buildVaultGrantApprovedInbound({
128
+ ctx: CTX_READ,
129
+ grantId: 'vg_x',
130
+ stageId: 's',
131
+ operatorId: '1',
132
+ })
133
+ const after = Date.now()
134
+ expect(msg.ts).toBeGreaterThanOrEqual(before)
135
+ expect(msg.ts).toBeLessThanOrEqual(after)
136
+ expect(msg.messageId).toBe(msg.ts)
137
+ })
138
+ })
139
+
140
+ describe('buildVaultGrantDeniedInbound', () => {
141
+ it('pins meta.source = "vault_grant_denied" — the deny-side wake-up was added in #1156', () => {
142
+ const msg = buildVaultGrantDeniedInbound({
143
+ ctx: CTX_READ,
144
+ stageId: 's',
145
+ operatorId: '1',
146
+ })
147
+ expect(msg.meta?.source).toBe('vault_grant_denied')
148
+ })
149
+
150
+ it('omits grant_id (denial never mints a grant)', () => {
151
+ const msg = buildVaultGrantDeniedInbound({
152
+ ctx: CTX_READ,
153
+ stageId: 'stage-001',
154
+ operatorId: '8248703757',
155
+ })
156
+ expect(msg.meta).toEqual({
157
+ source: 'vault_grant_denied',
158
+ agent: 'gymbro',
159
+ key: 'fatsecret/credentials',
160
+ scope: 'read',
161
+ stage_id: 'stage-001',
162
+ operator_id: '8248703757',
163
+ })
164
+ expect((msg.meta as { grant_id?: string }).grant_id).toBeUndefined()
165
+ })
166
+
167
+ it('text steers toward a fallback path', () => {
168
+ const msg = buildVaultGrantDeniedInbound({
169
+ ctx: CTX_READ,
170
+ stageId: 's',
171
+ operatorId: '1',
172
+ })
173
+ expect(msg.text).toContain('denied')
174
+ expect(msg.text).toContain('`fatsecret/credentials`')
175
+ expect(msg.text).toContain('fallback')
176
+ // The "DO NOT re-request" line is load-bearing UX — prevents the
177
+ // model from spam-tapping a fresh request immediately after a
178
+ // deny. Pin it.
179
+ expect(msg.text).toMatch(/Do NOT re-request/)
180
+ })
181
+
182
+ it('shares envelope shape with the approve builder (type, user, chat)', () => {
183
+ const denied = buildVaultGrantDeniedInbound({
184
+ ctx: CTX_READ,
185
+ stageId: 's',
186
+ operatorId: '1',
187
+ nowMs: FIXED_NOW,
188
+ })
189
+ expect(denied.type).toBe('inbound')
190
+ expect(denied.user).toBe('vault-broker')
191
+ expect(denied.userId).toBe(0)
192
+ expect(denied.chatId).toBe('8248703757')
193
+ expect(denied.ts).toBe(FIXED_NOW)
194
+ expect(denied.messageId).toBe(FIXED_NOW)
195
+ })
196
+ })
197
+
198
+ describe('approve vs deny shape invariants', () => {
199
+ it('both emit type=inbound, user=vault-broker — bridge anchors on these', () => {
200
+ const approve = buildVaultGrantApprovedInbound({
201
+ ctx: CTX_READ, grantId: 'g', stageId: 's', operatorId: '1',
202
+ })
203
+ const deny = buildVaultGrantDeniedInbound({
204
+ ctx: CTX_READ, stageId: 's', operatorId: '1',
205
+ })
206
+ for (const m of [approve, deny]) {
207
+ expect(m.type).toBe('inbound')
208
+ expect(m.user).toBe('vault-broker')
209
+ expect(m.userId).toBe(0)
210
+ }
211
+ })
212
+
213
+ it('source strings are disjoint — no shared substring that could route both to the same handler', () => {
214
+ const approve = buildVaultGrantApprovedInbound({
215
+ ctx: CTX_READ, grantId: 'g', stageId: 's', operatorId: '1',
216
+ })
217
+ const deny = buildVaultGrantDeniedInbound({
218
+ ctx: CTX_READ, stageId: 's', operatorId: '1',
219
+ })
220
+ expect(approve.meta?.source).not.toBe(deny.meta?.source)
221
+ // Defensive: a fuzzy match like `meta.source.includes('approve')`
222
+ // shouldn't accidentally fire on the deny side. Pin the prefixes.
223
+ expect(String(approve.meta?.source)).toMatch(/^vault_grant_approved$/)
224
+ expect(String(deny.meta?.source)).toMatch(/^vault_grant_denied$/)
225
+ })
226
+ })