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,130 @@
1
+ /**
2
+ * Static-source contract pin for the grant-union behavior shipped to
3
+ * fix #1051's silent-token-overwrite bug class.
4
+ *
5
+ * The bug: when an operator approved a vault_request_access card for
6
+ * key A and then later approved a second card for key B, the second
7
+ * mint OVERWROTE the agent's `.vault-token` file with a single-key
8
+ * grant. Both grants existed in the broker DB but the agent could
9
+ * only authenticate against the most-recent one — `vault get keyA`
10
+ * after the second approval returned VAULT-BROKER-DENIED.
11
+ *
12
+ * Fix: before minting, the gateway lists the agent's existing
13
+ * non-expired grants (using the new passphrase-attested list_grants
14
+ * path) and unions their keys with the new key. The fresh grant
15
+ * covers OLD ∪ NEW; the old grant ages out via TTL.
16
+ *
17
+ * This file pins the wiring at the call site so a future refactor
18
+ * can't silently drop the union step.
19
+ */
20
+
21
+ import { describe, it, expect } from "vitest";
22
+ import { readFileSync } from "node:fs";
23
+ import { resolve } from "node:path";
24
+
25
+ const gatewaySrc = readFileSync(
26
+ resolve(__dirname, "..", "gateway", "gateway.ts"),
27
+ "utf-8",
28
+ );
29
+
30
+ function extractPerformBlock(): string {
31
+ const start = gatewaySrc.indexOf("async function performVaultAccessApproval");
32
+ if (start < 0) throw new Error("performVaultAccessApproval not found");
33
+ // Bound the block by the next top-level `async function` keyword.
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 unions keys with the agent's existing grant (#1051)", () => {
40
+ const block = extractPerformBlock();
41
+
42
+ it("calls listGrantsViaBroker BEFORE mintGrantViaBroker", () => {
43
+ // fails when: a refactor drops the list step. Without it the
44
+ // mint covers only [pending.key] and OVERWRITES the agent's
45
+ // .vault-token, stranding the previous approval's grant from
46
+ // the agent's perspective.
47
+ const listIdx = block.indexOf("listGrantsViaBroker(");
48
+ const mintIdx = block.indexOf("mintGrantViaBroker(");
49
+ expect(listIdx, "listGrantsViaBroker call missing in performVaultAccessApproval").toBeGreaterThan(0);
50
+ expect(mintIdx, "mintGrantViaBroker call missing").toBeGreaterThan(0);
51
+ expect(listIdx, "list MUST happen BEFORE mint").toBeLessThan(mintIdx);
52
+ });
53
+
54
+ it("forwards an attestation to listGrantsViaBroker (#1115 follow-up: passphrase OR posture)", () => {
55
+ // The non-admin agent socket needs operator-attestation to call
56
+ // list_grants (#1051's broker-side gate widening). Pre-#1115-
57
+ // follow-up this had to be the operator passphrase. Post-fix the
58
+ // gateway threads either `{ passphrase }` or
59
+ // `{ attest_via_posture: true }` via `brokerAuthOpts`, depending
60
+ // on whether the operator typed the passphrase or the host is
61
+ // running telegram-id posture. The call must forward the auth
62
+ // opts object intact.
63
+ const listMatch = block.match(/listGrantsViaBroker\([^)]+\)/);
64
+ expect(listMatch, "listGrantsViaBroker call shape").not.toBeNull();
65
+ expect(listMatch![0], "attestation MUST be forwarded (brokerAuthOpts)").toMatch(/brokerAuthOpts/);
66
+ });
67
+
68
+ it("unions existing key_allow with the new key before minting", () => {
69
+ // Pin the union semantics. The mint call must pass a Set-like
70
+ // union of existing keys + new key, not just [pending.key].
71
+ expect(block).toMatch(/key_allow/);
72
+ expect(block).toMatch(/new Set/);
73
+ // The Set MUST be seeded from the existing grant's keys.
74
+ expect(block).toMatch(/existingReadKeys|active\[0\]/);
75
+ });
76
+
77
+ it("includes write_allow union for write-scope requests", () => {
78
+ // Mirror the read-side union for write scope.
79
+ expect(block).toMatch(/write_allow/);
80
+ expect(block).toMatch(/writeKeys\.add|writeKeys\.size/);
81
+ });
82
+
83
+ it("concurrent Approve taps queue into a single pending-op (#1051)", () => {
84
+ // Without the queue, a second Approve tap before the operator
85
+ // types their passphrase OVERWRITES the first stage's pending
86
+ // op — the first card's grant never mints. The new shape has
87
+ // `items: [...]` so a second tap APPENDS, and the text-handler
88
+ // drains every queued stage off one passphrase entry.
89
+ //
90
+ // Anchor on the producer site inside handleVaultRequestAccessCallback.
91
+ const callbackHandler =
92
+ gatewaySrc
93
+ .split("async function handleVaultRequestAccessCallback")[1]
94
+ ?.split("\nasync function ")[0] ?? "";
95
+ // The producer reads any existing pending op, and APPENDS to
96
+ // items[] rather than overwriting.
97
+ expect(callbackHandler).toMatch(/pendingVaultOps\.get\(pending\.chat_id\)/);
98
+ // `items` declared (either as `items: [...]` literal or `const items = ...`).
99
+ expect(callbackHandler).toMatch(/\bitems\b/);
100
+ expect(callbackHandler).toMatch(/passphrase-for-access-approve/);
101
+
102
+ // The consumer (text-handler) loops over items. Anchor on the
103
+ // unique consumer code (the `else if` branch that handles a
104
+ // passphrase reply), not the type-discriminator string itself —
105
+ // the latter appears in the PendingVaultOp type definition too,
106
+ // so a naive split lands on the wrong slice.
107
+ const textHandlerIdx = gatewaySrc.indexOf("else if (pendingVault.kind === 'passphrase-for-access-approve')");
108
+ expect(textHandlerIdx, "text-handler consumer branch not found").toBeGreaterThan(0);
109
+ const textHandler = gatewaySrc.slice(textHandlerIdx, textHandlerIdx + 3000);
110
+ expect(textHandler, "text-handler MUST iterate items").toMatch(/for\s*\(\s*const\s+item\s+of\s+pendingVault\.items/);
111
+ // Each iteration looks up the staged access (sibling-stage may
112
+ // have been denied / expired between tap and passphrase reply).
113
+ expect(textHandler).toMatch(/pendingVaultRequestAccesses\.get\(item\.stageId\)/);
114
+ });
115
+
116
+ it("gracefully proceeds with single-key mint when listGrants fails", () => {
117
+ // Non-blocker: if listGrants returns unreachable/error, the
118
+ // gateway should STILL mint the new grant (without union) so
119
+ // the operator's tap-to-approve doesn't get blocked on a
120
+ // transient broker issue. Documented intent.
121
+ expect(block).toMatch(/list\.kind === 'ok'/);
122
+ // A comment explaining the fallback behavior must be present so
123
+ // a future reader knows the gracefully-degraded path is
124
+ // intentional.
125
+ // The comment is split across multiple // lines, so collapse
126
+ // whitespace + comment prefixes before matching.
127
+ const flattened = block.replace(/\n\s*\/\/\s*/g, " ");
128
+ expect(flattened).toMatch(/(without union|edge case|fall.?back|fail closed|degrade)/i);
129
+ });
130
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Contract pin for #1047 — the gateway's vault-key regex was stricter
3
+ * than the broker's, so canonical slash-namespaced keys like
4
+ * `fatsecret/client_id` couldn't be requested via the in-band
5
+ * approval card flow. Filed by gymbro after hitting
6
+ * VAULT-BROKER-DENIED on `fatsecret/client_id` and being unable to
7
+ * call `vault_request_access` because of the schema regex.
8
+ *
9
+ * Three call sites use the regex:
10
+ * 1. `vault_request_save` execute (telegram-plugin/gateway/gateway.ts ~3549)
11
+ * 2. `vault_request_access` execute (~3633)
12
+ * 3. The rename-the-staged-key text-message handler (~5185)
13
+ *
14
+ * All three must accept the canonical slash-namespaced shape used by
15
+ * production keys: `fatsecret/client_id`, `mff/agent-private-key`,
16
+ * `microsoft/ken-tokens`. The broker itself has no key-shape regex
17
+ * (just `z.string().min(1)` in protocol.ts), so the gateway should
18
+ * mirror that posture — accept what the broker accepts.
19
+ *
20
+ * This is a static-source pin — the regex literal must appear in the
21
+ * gateway source. A runtime test would need a full bot harness;
22
+ * static-source mirrors the convention used elsewhere in this file
23
+ * tree (see jtbd-talk-from-anywhere.test.ts, vault-request-access-tool.test.ts).
24
+ */
25
+
26
+ import { describe, it, expect } from "vitest";
27
+ import { readFileSync } from "node:fs";
28
+ import { resolve } from "node:path";
29
+
30
+ const gatewaySrc = readFileSync(
31
+ resolve(__dirname, "..", "gateway", "gateway.ts"),
32
+ "utf-8",
33
+ );
34
+
35
+ /** The exported regex literal — what the gateway actually validates against. */
36
+ function extractVaultKeyRegex(): RegExp {
37
+ // The shared constant declaration. Anchored on the `VAULT_KEY_REGEX
38
+ // = /` prefix and the `/` immediately before the line terminator
39
+ // (the regex has no flags). The source includes `/` inside the
40
+ // character class, so a greedy match up to end-of-line is the
41
+ // safe extraction strategy.
42
+ const m = gatewaySrc.match(/const\s+VAULT_KEY_REGEX\s*=\s*\/(.+)\/\s*$/m);
43
+ if (!m) throw new Error("VAULT_KEY_REGEX constant not found in gateway.ts");
44
+ return new RegExp(m[1]);
45
+ }
46
+
47
+ /** Either inline literal with `/`, OR a reference to VAULT_KEY_REGEX. */
48
+ const ACCEPTS_SLASH = /\[A-Za-z0-9_(\/|\\\/|\.)+-\]|VAULT_KEY_REGEX/;
49
+
50
+ describe("vault-key regex accepts canonical slash-namespaced keys (#1047)", () => {
51
+ it("vault_request_save validation includes '/' in the charclass", () => {
52
+ // Anchor on the unique error message so a refactor that moves
53
+ // the validation can still be located.
54
+ const ix = gatewaySrc.indexOf("vault_request_save: key must match");
55
+ expect(ix, "could not find vault_request_save key error message").toBeGreaterThan(0);
56
+ const window = gatewaySrc.slice(Math.max(0, ix - 400), ix);
57
+ expect(
58
+ window,
59
+ "vault_request_save validation should accept '/' (e.g. fatsecret/client_id)",
60
+ ).toMatch(ACCEPTS_SLASH);
61
+ });
62
+
63
+ it("vault_request_access validation includes '/' in the charclass", () => {
64
+ const ix = gatewaySrc.indexOf("vault_request_access: key must match");
65
+ expect(ix, "could not find vault_request_access key error message").toBeGreaterThan(0);
66
+ const window = gatewaySrc.slice(Math.max(0, ix - 400), ix);
67
+ expect(
68
+ window,
69
+ "vault_request_access validation should accept '/' (e.g. fatsecret/client_id)",
70
+ ).toMatch(ACCEPTS_SLASH);
71
+ });
72
+
73
+ it("rename-staged-key validation includes '/' in the charclass", () => {
74
+ // The rename handler for the [✏️ Rename] button on a
75
+ // vault_request_save card. If this stays strict, operators can't
76
+ // rename a staged key to a canonical namespaced form.
77
+ const ix = gatewaySrc.indexOf("Key must match");
78
+ expect(ix, "could not find rename validation error message").toBeGreaterThan(0);
79
+ const window = gatewaySrc.slice(Math.max(0, ix - 400), ix);
80
+ expect(
81
+ window,
82
+ "rename handler should accept '/' (e.g. fatsecret/client_id)",
83
+ ).toMatch(ACCEPTS_SLASH);
84
+ });
85
+
86
+ it("/vault audit one-tap Allow callback validation includes '/' in the charclass", () => {
87
+ // Reviewer-caught oversight on #1049: the
88
+ // handleVaultRecentDenialCallback handler (#969 P2b) validates
89
+ // the keyName parsed from the inline-button callback_data with
90
+ // its own copy of the key regex. Without this site updated, the
91
+ // exact bug from #1047 — operator opens /vault audit on a
92
+ // denied `fatsecret/client_id`, taps [Allow] — would surface
93
+ // "Invalid key name" even though the agent-initiated card flow
94
+ // works.
95
+ const ix = gatewaySrc.indexOf("'Invalid key name'");
96
+ expect(ix, "could not find /vault audit Invalid key name error").toBeGreaterThan(0);
97
+ const window = gatewaySrc.slice(Math.max(0, ix - 400), ix);
98
+ expect(
99
+ window,
100
+ "/vault audit one-tap allow should accept '/' (e.g. fatsecret/client_id)",
101
+ ).toMatch(ACCEPTS_SLASH);
102
+ });
103
+
104
+ it("user-facing rename error message names the slash as allowed", () => {
105
+ // The visible error text guides the operator on what's allowed.
106
+ // If we widened the regex but didn't update the message, the
107
+ // operator is told `/` is disallowed even though it isn't.
108
+ // The VAULT_KEY_REGEX_LABEL string is the canonical user-visible
109
+ // hint and includes the `/` shape.
110
+ const m = gatewaySrc.match(/const\s+VAULT_KEY_REGEX_LABEL\s*=\s*"([^"]+)"/);
111
+ expect(m, "VAULT_KEY_REGEX_LABEL constant not found").not.toBeNull();
112
+ expect(m![1], "label must mention '/' as allowed").toMatch(/\//);
113
+ });
114
+ });
115
+
116
+ describe("vault-key regex: regression guards (the fix doesn't break the original shape)", () => {
117
+ // Anchored to the live VAULT_KEY_REGEX constant declaration so the
118
+ // test runs the same regex the gateway runs. A breaking refactor
119
+ // (typo, accidental tightening) fails loudly here.
120
+ const re = extractVaultKeyRegex();
121
+
122
+ it.each([
123
+ ["telegram_bot_token", true, "underscores + lowercase"],
124
+ ["MY_TOKEN", true, "uppercase + underscore"],
125
+ ["api.key", true, "dot namespace"],
126
+ ["fatsecret/client_id", true, "slash namespace (the bug)"],
127
+ ["fatsecret/credentials", true, "slash namespace"],
128
+ ["mff/agent-private-key", true, "slash namespace with hyphen"],
129
+ ["microsoft/ken-tokens", true, "slash namespace from issue"],
130
+ ["k", true, "single char"],
131
+ ["a".repeat(200), true, "max length"],
132
+ ["", false, "empty rejected"],
133
+ ["a".repeat(201), false, "over-length rejected"],
134
+ ["key with space", false, "space rejected"],
135
+ ['key"with"quotes', false, "quotes rejected"],
136
+ ["key\nwith\nnewlines", false, "newlines rejected"],
137
+ ] as const)("VAULT_KEY_REGEX: %s — should %s (%s)", (input, expected) => {
138
+ expect(re.test(input), `${JSON.stringify(input)} (${expected ? "accept" : "reject"})`).toBe(expected);
139
+ });
140
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Regression for #1115 follow-up — vault-approval-posture config errors
3
+ * must NOT manifest as unhandled-rejection crash loops.
4
+ *
5
+ * Pre-fix (2026-05-13 overnight UAT discovery): when the operator
6
+ * declared `vault.broker.approvalAuth: telegram-id` in switchroom.yaml
7
+ * but the auto-unlock blob couldn't be read (e.g. agent UID can't
8
+ * access the operator's home dir), `resolveVaultApprovalPosture`
9
+ * threw, the startup IIFE in gateway.ts let the error propagate as an
10
+ * unhandled rejection, and `_switchroom_supervise` saw status=0 from
11
+ * the shutdown handler and respawned the gateway. 10 restarts in <60s
12
+ * before the supervisor's restart-cap kicked in. Each restart posted
13
+ * an "agent-crashed" operator-event card and the bridge was alive only
14
+ * in brief windows between restarts — inbound messages dropped.
15
+ *
16
+ * Post-fix: the startup catches the config-class error, writes a
17
+ * quarantine marker with reason `startup.config_error`, and calls
18
+ * `process.exit(78)` (sysexits EX_CONFIG). The supervisor short-
19
+ * circuits on exit 78 without restarting (`_switchroom_supervise` in
20
+ * `profiles/_base/start.sh.hbs`), so the operator sees ONE clean
21
+ * error and a quarantine file instead of a crash-loop log smear.
22
+ *
23
+ * These tests assert the contracts:
24
+ * 1. The reason code `startup.config_error` exists in both quarantine
25
+ * modules (host-side and plugin-side stay in sync).
26
+ * 2. The quarantine marker writer accepts the new reason and writes
27
+ * a parseable JSON file.
28
+ * 3. The reader at `src/agents/quarantine.ts` round-trips the new
29
+ * reason.
30
+ */
31
+
32
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
33
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
34
+ import { tmpdir } from 'node:os'
35
+ import { join } from 'node:path'
36
+ import { writeQuarantineMarker, QUARANTINE_FILENAME } from '../gateway/quarantine.js'
37
+ import {
38
+ readQuarantineMarker,
39
+ type QuarantineReason,
40
+ } from '../../src/agents/quarantine.js'
41
+
42
+ let stateDir: string
43
+
44
+ beforeEach(() => {
45
+ stateDir = mkdtempSync(join(tmpdir(), 'vault-posture-quarantine-'))
46
+ })
47
+
48
+ afterEach(() => {
49
+ rmSync(stateDir, { recursive: true, force: true })
50
+ })
51
+
52
+ describe('quarantine — startup.config_error reason', () => {
53
+ it('plugin-side writer accepts the new reason and writes a parseable marker', () => {
54
+ writeQuarantineMarker(
55
+ stateDir,
56
+ 'startup.config_error',
57
+ 'vault.broker.approvalAuth=telegram-id but blob unreadable',
58
+ )
59
+ const raw = readFileSync(join(stateDir, QUARANTINE_FILENAME), 'utf-8')
60
+ const parsed = JSON.parse(raw) as {
61
+ v: number
62
+ reason: string
63
+ ts: number
64
+ detail?: string
65
+ }
66
+ expect(parsed.v).toBe(1)
67
+ expect(parsed.reason).toBe('startup.config_error')
68
+ expect(typeof parsed.ts).toBe('number')
69
+ expect(parsed.detail).toContain('vault.broker.approvalAuth')
70
+ })
71
+
72
+ it('host-side reader round-trips the new reason', () => {
73
+ writeQuarantineMarker(stateDir, 'startup.config_error', 'detail goes here')
74
+ const m = readQuarantineMarker(stateDir)
75
+ expect(m).not.toBeNull()
76
+ expect(m!.reason).toBe('startup.config_error')
77
+ expect(m!.detail).toBe('detail goes here')
78
+ })
79
+
80
+ it('startup.unauthorized reason still round-trips (no regression)', () => {
81
+ writeQuarantineMarker(stateDir, 'startup.unauthorized', '401 Unauthorized')
82
+ const m = readQuarantineMarker(stateDir)
83
+ expect(m!.reason).toBe('startup.unauthorized')
84
+ })
85
+
86
+ it('type-level: both reasons accepted by QuarantineReason union', () => {
87
+ const accepted: QuarantineReason[] = ['startup.unauthorized', 'startup.config_error']
88
+ expect(accepted).toHaveLength(2)
89
+ })
90
+
91
+ it('marker survives in detail field — no truncation of operator-facing message', () => {
92
+ const longDetail =
93
+ 'vault.broker.approvalAuth=telegram-id but reading auto-unlock blob at '
94
+ + '/state/agent/home/.switchroom/vault-auto-unlock failed: ENOENT no such '
95
+ + 'file or directory. Refusing to boot — silently falling back to '
96
+ + 'passphrase posture would invert the operator\'s declared security '
97
+ + 'posture. Either repair the auto-unlock blob (rerun `switchroom setup` '
98
+ + '/ `switchroom vault auto-unlock`) or remove vault.broker.approvalAuth '
99
+ + 'from switchroom.yaml.'
100
+ writeQuarantineMarker(stateDir, 'startup.config_error', longDetail)
101
+ const m = readQuarantineMarker(stateDir)
102
+ expect(m!.detail).toBe(longDetail)
103
+ })
104
+ })
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Pin the agent-initiated vault ACL request flow shipped in #1012.
3
+ *
4
+ * Regressions guarded here:
5
+ * 1. `vault_request_access` MCP tool dropping from bridge.ts schema
6
+ * (the agent would lose the tool with no compile-time signal).
7
+ * 2. `vault_request_access` missing from the gateway's ALLOWED_TOOLS
8
+ * set (bridge would emit it but gateway would 403 with
9
+ * `tool not allowed`).
10
+ * 3. The `vra:` callback prefix losing its dispatcher branch (taps
11
+ * on [Approve] / [Deny] silently fall through to the trailing
12
+ * "unknown callback" arm).
13
+ * 4. The 90-day TTL ceiling and `read|write` scope shape — both are
14
+ * part of the threat model (agents can't request perpetual or
15
+ * undefined-scope grants).
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest'
19
+ import { readFileSync } from 'node:fs'
20
+ import { resolve } from 'node:path'
21
+
22
+ const bridgeSrc = readFileSync(
23
+ resolve(__dirname, '..', 'bridge', 'bridge.ts'),
24
+ 'utf-8',
25
+ )
26
+ const gatewaySrc = readFileSync(
27
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
28
+ 'utf-8',
29
+ )
30
+
31
+ describe('vault_request_access (#1012)', () => {
32
+ it('bridge advertises the tool to MCP clients', () => {
33
+ // fails when: the schema is removed from TOOL_SCHEMAS — agents
34
+ // running the new tool will hit a generic "unknown tool" path
35
+ // before they ever see the gateway.
36
+ expect(bridgeSrc).toContain("name: 'vault_request_access'")
37
+ })
38
+
39
+ it('bridge schema declares the threat-model-critical fields', () => {
40
+ // fails when: a refactor drops `scope` (defaults to read) or
41
+ // `duration` (caps requested TTL at 90d). Both gates are part of
42
+ // the agent-can-only-request-not-mint boundary described in #1012.
43
+ const block = bridgeSrc.split("name: 'vault_request_access'")[1]?.split("name: '")[0] ?? ''
44
+ expect(block).toContain("'read'")
45
+ expect(block).toContain("'write'")
46
+ expect(block).toMatch(/duration/)
47
+ // Required fields: chat_id + key. Value is NOT required (this is
48
+ // an access request, not a save — the agent doesn't have the
49
+ // value, it wants permission to read it).
50
+ expect(block).toMatch(/required:\s*\[\s*'chat_id',\s*'key'\s*\]/)
51
+ })
52
+
53
+ it('gateway accepts vault_request_access in ALLOWED_TOOLS', () => {
54
+ // fails when: the ALLOWED_TOOLS set is touched and the entry
55
+ // gets dropped. Bridge would forward the call and the gateway
56
+ // would reject with `tool not allowed`.
57
+ expect(gatewaySrc).toMatch(/ALLOWED_TOOLS[\s\S]*?'vault_request_access'/)
58
+ })
59
+
60
+ it('gateway routes vault_request_access in executeToolCall', () => {
61
+ // fails when: the switch arm is dropped. Tool would be accepted
62
+ // by ALLOWED_TOOLS but fall through to the `unknown tool` branch.
63
+ expect(gatewaySrc).toMatch(/case\s+'vault_request_access':\s*\n\s*return\s+executeVaultRequestAccess/)
64
+ })
65
+
66
+ it('gateway dispatches vra: callback prefix', () => {
67
+ // fails when: the callback_query dispatcher loses the `vra:`
68
+ // branch. Operator taps on [Approve] / [Deny] would fall to the
69
+ // catch-all "unknown callback" path and the card would stay
70
+ // open forever.
71
+ expect(gatewaySrc).toMatch(/data\.startsWith\('vra:'\)/)
72
+ expect(gatewaySrc).toMatch(/handleVaultRequestAccessCallback/)
73
+ })
74
+
75
+ it('approve handler mints via broker (not direct grants.db write)', () => {
76
+ // fails when: someone tries to short-circuit by writing to the
77
+ // grants DB directly. The broker is the single point of grant
78
+ // issuance — bypassing it skips audit-log emission and breaks
79
+ // the path-as-identity ACL contract.
80
+ //
81
+ // The mint call lives in performVaultAccessApproval — the helper
82
+ // factored out so both the direct-approve path AND the
83
+ // tap-on-locked → passphrase-resume path drive identical minting
84
+ // (see telegram-plugin/tests/vault-request-access-unlock-resume.test.ts).
85
+ const mintHelperBlock =
86
+ gatewaySrc
87
+ .split('async function performVaultAccessApproval')[1]
88
+ ?.split('async function handleVaultRequestAccessCallback')[0] ?? ''
89
+ expect(mintHelperBlock).toMatch(/mintGrantViaBroker/)
90
+ // Description string must carry the audit breadcrumb so post-hoc
91
+ // forensics can tell agent-initiated grants apart from
92
+ // operator-host-CLI grants and from /vault audit one-tap grants.
93
+ expect(mintHelperBlock).toMatch(/vault_request_access/)
94
+ expect(mintHelperBlock).toMatch(/#1012/)
95
+ })
96
+
97
+ it('duration parser enforces the 90-day ceiling', () => {
98
+ // fails when: the cap is removed or widened without a corresponding
99
+ // doc/threat-model update. Agent-initiated grants must have a
100
+ // finite sunset; "never" must be refused outright.
101
+ const execBlock = gatewaySrc.split('async function executeVaultRequestAccess')[1]?.split('async function ')[0] ?? ''
102
+ expect(execBlock).toMatch(/NINETY_DAYS/)
103
+ expect(execBlock).toMatch(/90\s*\*\s*86400/)
104
+ })
105
+
106
+ it('approve handler is gated on the operator allowFrom list', () => {
107
+ // fails when: the access check is dropped. Without this gate any
108
+ // chat member could approve a grant — breaks the operator-only
109
+ // mint authority that's load-bearing for #1012's threat model.
110
+ const handlerBlock = gatewaySrc.split('async function handleVaultRequestAccessCallback')[1]?.split('async function handleVaultRequestSaveCallback')[0] ?? ''
111
+ expect(handlerBlock).toMatch(/loadAccess\(\)/)
112
+ expect(handlerBlock).toMatch(/allowFrom\.includes/)
113
+ })
114
+ })
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Contract pin for the tap-to-unlock-and-approve flow added on top of
3
+ * #1012 Phase 2 (#1030 broker passphrase-attested mint_grant).
4
+ *
5
+ * Before this PR: tapping Approve on a vault_request_access card
6
+ * without first unlocking the vault edited the card to "🔒 Vault is
7
+ * locked. Run /vault unlock... then ask the agent to re-issue." The
8
+ * operator had to (a) clear that card, (b) /vault unlock, (c) ask
9
+ * the agent to re-emit the request, (d) tap Approve again. Four steps
10
+ * for one decision.
11
+ *
12
+ * After this PR: the cache-miss tap keeps the card open, prompts for
13
+ * the passphrase as the next message, captures+caches it, deletes the
14
+ * passphrase message from chat, then auto-resumes the mint. One tap
15
+ * + one reply = one grant.
16
+ *
17
+ * Mirrors the `passphrase-for-deferred` flow from #44 (deferred-secret
18
+ * card's "🔓 Unlock vault & save"). Same idiom, same trust posture.
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest'
22
+ import { readFileSync } from 'node:fs'
23
+ import { resolve } from 'node:path'
24
+
25
+ const gatewaySrc = readFileSync(
26
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
27
+ 'utf-8',
28
+ )
29
+
30
+ describe('vault_request_access — tap-to-unlock-and-approve UX', () => {
31
+ it('declares the passphrase-for-access-approve PendingVaultOp variant', () => {
32
+ // fails when: the new PendingVaultOp kind is dropped. The
33
+ // resume flow leans on this discriminator — without it the
34
+ // text handler can't route the passphrase reply back to the
35
+ // staged request.
36
+ expect(gatewaySrc).toMatch(/kind:\s*['"]passphrase-for-access-approve['"]/)
37
+ })
38
+
39
+ it('Approve on a locked vault stages a passphrase prompt instead of clearing the card', () => {
40
+ // fails when: the cache-miss branch reverts to the old behaviour
41
+ // of editing the card to "ask the agent to re-issue." That UX
42
+ // is the four-step workaround we just replaced.
43
+ //
44
+ // Anchor: the approve-action block inside handleVaultRequestAccessCallback.
45
+ const approveBlock =
46
+ gatewaySrc.split('if (action === \'approve\')')[1]?.split('await ctx.answerCallbackQuery({ text: \'Unknown action\'')[0] ?? ''
47
+ expect(approveBlock).toMatch(/pendingVaultOps\.set/)
48
+ expect(approveBlock).toMatch(/passphrase-for-access-approve/)
49
+ // Card text must invite a passphrase reply, not punt to a
50
+ // /vault unlock detour.
51
+ expect(approveBlock).toMatch(/Reply with your passphrase/i)
52
+ // The "ask the agent to re-issue the request card" copy belonged
53
+ // to the pre-fix path. Should be gone from the cache-miss branch.
54
+ expect(approveBlock).not.toMatch(/ask the agent to re-issue the request card/)
55
+ })
56
+
57
+ it('passphrase intercept deletes the chat message and resumes mint', () => {
58
+ // fails when: the new pending-op handler stops calling
59
+ // deleteSensitiveMessage on the passphrase message OR stops
60
+ // routing into performVaultAccessApproval. Both are load-bearing:
61
+ // - delete: prevents the passphrase from lingering in chat history
62
+ // - resume: closes the "one decision" UX promise
63
+ //
64
+ // Anchor: the text-handler branch keyed on the new kind.
65
+ const handlerBlock =
66
+ gatewaySrc
67
+ .split("pendingVault.kind === 'passphrase-for-access-approve'")[1]
68
+ ?.split("pendingVault.kind === 'grant-wizard'")[0] ?? ''
69
+ expect(handlerBlock).toMatch(/deleteSensitiveMessage/)
70
+ expect(handlerBlock).toMatch(/performVaultAccessApproval/)
71
+ // Cache the passphrase so future operations in the same chat
72
+ // don't re-prompt within the TTL window.
73
+ expect(handlerBlock).toMatch(/vaultPassphraseCache\.set/)
74
+ })
75
+
76
+ it('expired-stage path edits the card cleanly, does not silently drop', () => {
77
+ // fails when: the resume path forgets to handle the edge case
78
+ // where the staged access entry's 10-min TTL elapsed between
79
+ // tap-on-locked and passphrase reply. Without this branch the
80
+ // operator types their passphrase, gets nothing visible back,
81
+ // and is confused about whether their secret leaked.
82
+ const handlerBlock =
83
+ gatewaySrc
84
+ .split("pendingVault.kind === 'passphrase-for-access-approve'")[1]
85
+ ?.split("pendingVault.kind === 'grant-wizard'")[0] ?? ''
86
+ expect(handlerBlock).toMatch(/expired before you replied|expired/)
87
+ expect(handlerBlock).toMatch(/editMessageText/)
88
+ })
89
+
90
+ it('mint failure (e.g. wrong passphrase) edits the card; does not silent-drop', () => {
91
+ // fails when: performVaultAccessApproval's error branch returns
92
+ // without editing the card. Without the edit, a wrong-passphrase
93
+ // attempt leaves the locked-vault prompt on screen forever and
94
+ // the operator can't tell whether the system saw their reply.
95
+ //
96
+ // Anchor: performVaultAccessApproval's `result.kind === 'error'`
97
+ // branch.
98
+ const mintHelper =
99
+ gatewaySrc.split('async function performVaultAccessApproval')[1]?.split('async function handleVaultRequestAccessCallback')[0] ?? ''
100
+ expect(mintHelper).toMatch(/result\.kind === 'error'/)
101
+ // After error: card edited AND pending entry dropped (no
102
+ // zombie staged request).
103
+ expect(mintHelper).toMatch(/editMessageText[\s\S]{0,400}mint_grant failed/)
104
+ expect(mintHelper).toMatch(/pendingVaultRequestAccesses\.delete/)
105
+ })
106
+ })