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
@@ -3,11 +3,20 @@
3
3
  *
4
4
  * Issue: https://github.com/switchroom/switchroom/issues/866
5
5
  *
6
- * `spinUp({ agent, topic })` is the single entry point a scenario
7
- * uses. Phase 1 ships the type shape + the lifecycle skeleton; the
8
- * actual `child_process.spawn` of the agent under test is stubbed
9
- * with TODO markers so the reviewer can see exactly where Phase 2
10
- * lands.
6
+ * `spinUp({ agent })` connects the mtcute driver and resolves the test
7
+ * bot's user_id, returning a Scenario the test can interact with.
8
+ *
9
+ * **Runtime model Phase 2a (DM focus):** the test-harness agent is
10
+ * a standard switchroom agent the operator created once via
11
+ * `switchroom agent add test-harness ...` (see uat/SETUP.md). The
12
+ * harness does NOT spin the agent up per-scenario — it relies on the
13
+ * agent being already running. Per-scenario state isolation rolls in
14
+ * with Phase 2b once we move beyond DM smoke tests.
15
+ *
16
+ * Forum-topic routing, ephemeral STATE_DIR, child-process agents, and
17
+ * the progress-card observers are deferred to Phase 2b (#866 v2) — the
18
+ * epic's original plan was written before the Docker runtime landed
19
+ * and would substantially re-invent the agent lifecycle.
11
20
  */
12
21
 
13
22
  import { Driver } from "./driver.js";
@@ -19,32 +28,65 @@ import {
19
28
  type PollOptions,
20
29
  type PinnedCardSnapshot,
21
30
  } from "./assertions.js";
22
- import { allocatePort } from "./port-allocator.js";
31
+ import type { ObservedMessage } from "./driver.js";
32
+ import { loadUatEnv } from "./load-env.js";
33
+
34
+ loadUatEnv();
23
35
 
24
36
  export interface SpinUpOptions {
25
- /** Agent name to install + run, e.g. `"clerk"`, `"test-harness"`. */
37
+ /**
38
+ * Agent name to run scenarios against, e.g. `"test-harness"`. The
39
+ * agent must already be configured + running (Phase 2a: standard
40
+ * runtime + persistent agent).
41
+ */
26
42
  agent: string;
27
43
  /**
28
- * Forum topic slug for isolation. The harness creates the topic in
29
- * the test supergroup and tears it down after the scenario.
44
+ * Bot username (with or without `@`) the harness should resolve to
45
+ * a user_id. Defaults to `process.env.TELEGRAM_TEST_BOT_USERNAME`.
46
+ */
47
+ botUsername?: string;
48
+ /**
49
+ * Settle delay (ms) after the driver connects, before the scenario's
50
+ * first send. Gives the previous scenario's turn time to finish its
51
+ * outbound stream on the agent side. Without this the next inbound
52
+ * lands while the gateway is still pinning/editing the prior turn's
53
+ * card, the gateway reuses the existing pin via edit (instead of
54
+ * pinning a new message), and observePins-based assertions miss the
55
+ * event entirely. Default {@link DEFAULT_SETTLE_MS}; set to 0 for
56
+ * single-scenario runs where the cooldown is dead time. Scenarios
57
+ * that account for this in their outer `it()` budget should add the
58
+ * settle on top of inner poll deadlines.
30
59
  */
31
- topic: string;
60
+ settleMs?: number;
32
61
  }
33
62
 
63
+ export const DEFAULT_SETTLE_MS = 8_000;
64
+
34
65
  export interface Scenario {
35
66
  /** mtcute driver, already connected. */
36
67
  driver: Driver;
37
- /** Negative supergroup chat id; from `$SWITCHROOM_UAT_CHAT_ID`. */
38
- chatId: number;
39
- /** Topic id created for this scenario. */
40
- threadId: number;
68
+ /** Test bot's Telegram user_id; doubles as the chat_id for DMs. */
69
+ botUserId: number;
70
+ /** Driver user account's Telegram user_id. */
71
+ driverUserId: number;
41
72
 
42
- // Sugar over the assertion helpers, pre-bound to this scenario's
43
- // chat + thread. Phase 1 returns a thin pass-through.
73
+ /** Sugar for `driver.sendText(botUserId, text)`. */
74
+ sendDM: (text: string) => Promise<{ messageId: number }>;
75
+
76
+ /**
77
+ * Wait for the next message in the bot DM chat matching `match`.
78
+ * `opts.from` filters by sender side: `"bot"` for replies from the
79
+ * test bot, `"driver"` for the driver's own echoes (rare in
80
+ * scenarios but useful for assertions on the outbound side).
81
+ */
44
82
  expectMessage: (
45
- match: Parameters<typeof expectMessage>[2],
46
- opts: PollOptions & { from?: "bot" | "user" },
47
- ) => ReturnType<typeof expectMessage>;
83
+ match: string | RegExp | ((m: ObservedMessage) => boolean),
84
+ opts: PollOptions & { from?: "bot" | "driver" },
85
+ ) => Promise<ObservedMessage>;
86
+
87
+ // Phase 2b stubs — type-only so existing scenarios that reference
88
+ // these helpers still typecheck after this PR. Implementations land
89
+ // alongside `observeReactions` / `observePins` in #866 v2.
48
90
  expectReaction: (
49
91
  messageId: number,
50
92
  sequence: string[],
@@ -57,105 +99,109 @@ export interface Scenario {
57
99
  opts: PollOptions,
58
100
  ) => ReturnType<typeof waitForCardPhase>;
59
101
 
60
- /** Stop the agent process, delete the topic, disconnect the driver. */
102
+ /** Disconnect the driver. The persistent test-harness agent keeps running. */
61
103
  tearDown: () => Promise<void>;
62
104
  }
63
105
 
64
- /**
65
- * Spin up an isolated agent + scenario context.
66
- *
67
- * Phase 1: returns a stub Scenario whose tools throw helpful "not
68
- * implemented" errors. The shape is correct so scenarios written
69
- * against it will typecheck today and run for real once Phase 2
70
- * lands.
71
- */
72
- export async function spinUp(opts: SpinUpOptions): Promise<Scenario> {
73
- // TODO(#866): resolve secrets from vault.
74
- // - `telegram-test-bot-token` for the agent under test
75
- // - `telegram-uat-driver-session` for the mtcute driver
76
- // - `TELEGRAM_API_ID` / `TELEGRAM_API_HASH` from env
77
- // For now we throw early with a clear message so accidental runs
78
- // before Phase 2 don't crash with confusing stack traces.
79
- if (!process.env.SWITCHROOM_UAT_CHAT_ID) {
80
- throw new Error(
81
- "[uat/harness] SWITCHROOM_UAT_CHAT_ID is not set — see uat/SETUP.md §2",
106
+ interface ResolvedConfig {
107
+ apiId: number;
108
+ apiHash: string;
109
+ session: string;
110
+ botUsername: string;
111
+ }
112
+
113
+ function resolveConfig(opts: SpinUpOptions): ResolvedConfig {
114
+ const apiId = Number.parseInt(process.env.TELEGRAM_API_ID ?? "", 10);
115
+ if (!Number.isFinite(apiId)) {
116
+ fail("TELEGRAM_API_ID is missing or not an integer — see uat/SETUP.md §3");
117
+ }
118
+ const apiHash = process.env.TELEGRAM_API_HASH ?? "";
119
+ if (apiHash.length === 0) {
120
+ fail("TELEGRAM_API_HASH is empty see uat/SETUP.md §3");
121
+ }
122
+ const session = process.env.TELEGRAM_UAT_DRIVER_SESSION ?? "";
123
+ if (session.length === 0) {
124
+ fail(
125
+ "TELEGRAM_UAT_DRIVER_SESSION is empty — run `bun run uat:login` first " +
126
+ "(see uat/SETUP.md §4)",
82
127
  );
83
128
  }
84
- const chatId = Number.parseInt(process.env.SWITCHROOM_UAT_CHAT_ID, 10);
85
- if (!Number.isFinite(chatId) || chatId >= 0) {
86
- throw new Error(
87
- `[uat/harness] SWITCHROOM_UAT_CHAT_ID must be a negative supergroup id (got ${process.env.SWITCHROOM_UAT_CHAT_ID})`,
129
+ const botUsername =
130
+ opts.botUsername ?? process.env.TELEGRAM_TEST_BOT_USERNAME ?? "";
131
+ if (botUsername.length === 0) {
132
+ fail(
133
+ "Bot username not provided — pass `botUsername` to spinUp() or set " +
134
+ "TELEGRAM_TEST_BOT_USERNAME",
88
135
  );
89
136
  }
137
+ return { apiId, apiHash, session, botUsername };
138
+ }
139
+
140
+ export async function spinUp(opts: SpinUpOptions): Promise<Scenario> {
141
+ const cfg = resolveConfig(opts);
142
+ void opts.agent; // currently informational; #866 v2 will use it for state-dir scoping
90
143
 
91
- // TODO(#866): allocate gateway port + ephemeral STATE_DIR.
92
- // const port = await allocatePort();
93
- // const stateDir = await mkdtemp(join(tmpdir(), `uat-${opts.agent}-`));
94
- // process.env.STATE_DIR is per-process — we instead pass STATE_DIR
95
- // in the spawned child's env, never mutate ours.
96
- const port = await allocatePort();
97
- void port; // Phase 2: feed into agent child env
98
-
99
- // TODO(#866): create the forum topic via Bot API
100
- // (`createForumTopic`) using the test bot token; capture the
101
- // returned `message_thread_id` and stash for tearDown's
102
- // `deleteForumTopic`.
103
- const threadId = -1; // sentinel; Phase 2 fills in
104
-
105
- // TODO(#866): spawn the agent under test as a child process.
106
- // const child = spawn(process.execPath, [agentEntry], {
107
- // env: {
108
- // ...process.env,
109
- // STATE_DIR: stateDir,
110
- // TELEGRAM_GATEWAY_PORT: String(port),
111
- // SWITCHROOM_AGENT_NAME: opts.agent,
112
- // BOT_TOKEN: <vault: telegram-test-bot-token>,
113
- // },
114
- // stdio: ["ignore", "pipe", "pipe"],
115
- // });
116
- // await waitForGatewayReady(port, { timeout: 30_000 });
117
-
118
- // TODO(#866): connect mtcute driver.
119
- // const driver = new Driver({ apiId, apiHash, session });
120
- // await driver.connect();
121
144
  const driver = new Driver({
122
- apiId: 0,
123
- apiHash: "",
124
- session: "<resolved-from-vault>",
145
+ apiId: cfg.apiId,
146
+ apiHash: cfg.apiHash,
147
+ session: cfg.session,
125
148
  });
126
149
 
150
+ await driver.connect();
151
+
152
+ // Resolve both IDs eagerly so scenarios can rely on them being
153
+ // populated by the time `spinUp` returns. Run in parallel — the
154
+ // two calls don't interact.
155
+ const [botUserId, driverUserId] = await Promise.all([
156
+ driver.resolveBotUserId(cfg.botUsername),
157
+ driver.getMyUserId(),
158
+ ]);
159
+
160
+ // Unpin FIRST, then settle. Order matters: the gateway is a logical
161
+ // singleton for the chat's pinned card — on every turn it tries to
162
+ // edit the existing pin rather than pin a fresh one, so observePins
163
+ // (a transition listener) sees nothing on the next turn. Unpinning
164
+ // forces the agent to issue a fresh `pin` event we can observe.
165
+ // The settle delay then absorbs (a) the unpin's own propagation
166
+ // round-trip and (b) any tail-end edits from the prior scenario's
167
+ // turn still in flight. Doing unpin before settle keeps the gap a
168
+ // single window of dead time rather than two stacked waits.
169
+ await driver.unpinAllMessages(botUserId);
170
+ const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
171
+ if (settleMs > 0) {
172
+ await new Promise((resolve) => setTimeout(resolve, settleMs));
173
+ }
174
+
127
175
  const scenario: Scenario = {
128
176
  driver,
129
- chatId,
130
- threadId,
177
+ botUserId,
178
+ driverUserId,
179
+ sendDM: (text) => driver.sendText(botUserId, text),
131
180
  expectMessage: (match, pollOpts) =>
132
- expectMessage(driver, chatId, match, {
181
+ expectMessage(driver, botUserId, match, {
133
182
  ...pollOpts,
134
- threadId,
183
+ senderFilter:
184
+ pollOpts.from === "bot"
185
+ ? { notUserId: driverUserId }
186
+ : pollOpts.from === "driver"
187
+ ? { userId: driverUserId }
188
+ : undefined,
135
189
  }),
136
190
  expectReaction: (messageId, sequence, pollOpts) =>
137
- expectReaction(driver, chatId, messageId, sequence, pollOpts),
138
- expectPinnedCard: (pollOpts) =>
139
- expectPinnedCard(driver, chatId, { ...pollOpts, threadId }),
191
+ expectReaction(driver, botUserId, messageId, sequence, pollOpts),
192
+ expectPinnedCard: (pollOpts) => expectPinnedCard(driver, botUserId, pollOpts),
140
193
  waitForCardPhase: (card, phase, pollOpts) =>
141
194
  waitForCardPhase(driver, card, phase, pollOpts),
142
195
  tearDown: async () => {
143
- // TODO(#866): SIGTERM child, await exit (or SIGKILL after 5s),
144
- // delete forum topic, rm -rf state dir, disconnect driver.
145
196
  await driver.disconnect().catch(() => {
146
- /* idempotent */
197
+ /* idempotent — log-and-move-on; the persistent agent is unaffected */
147
198
  });
148
199
  },
149
200
  };
150
201
 
151
- // Phase 1 marker so accidental runs fail loudly instead of silently
152
- // sending nothing.
153
- void opts;
154
- throw new Error(
155
- "[uat/harness] spinUp is scaffolded but not wired (Phase 1 stub) — see TODO markers in uat/harness.ts",
156
- );
157
-
158
- // unreachable in Phase 1; left for shape:
159
- // eslint-disable-next-line no-unreachable
160
202
  return scenario;
161
203
  }
204
+
205
+ function fail(msg: string): never {
206
+ throw new Error(`[uat/harness] ${msg}`);
207
+ }
@@ -0,0 +1,72 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadUatEnv } from "./load-env.js";
6
+
7
+ describe("loadUatEnv", () => {
8
+ let tmpDir: string;
9
+ let envFile: string;
10
+ const originalEnv = { ...process.env };
11
+
12
+ beforeEach(() => {
13
+ tmpDir = mkdtempSync(join(tmpdir(), "uat-load-env-"));
14
+ envFile = join(tmpDir, ".env");
15
+ });
16
+
17
+ afterEach(() => {
18
+ rmSync(tmpDir, { recursive: true, force: true });
19
+ for (const key of Object.keys(process.env)) {
20
+ if (!(key in originalEnv)) delete process.env[key];
21
+ }
22
+ for (const [key, value] of Object.entries(originalEnv)) {
23
+ process.env[key] = value;
24
+ }
25
+ });
26
+
27
+ it("populates env vars from KEY=value lines", () => {
28
+ writeFileSync(envFile, "UAT_TEST_FOO=bar\nUAT_TEST_BAZ=qux\n");
29
+ loadUatEnv(envFile);
30
+ expect(process.env.UAT_TEST_FOO).toBe("bar");
31
+ expect(process.env.UAT_TEST_BAZ).toBe("qux");
32
+ });
33
+
34
+ it("does not overwrite values already set in process.env", () => {
35
+ process.env.UAT_TEST_FOO = "from-shell";
36
+ writeFileSync(envFile, "UAT_TEST_FOO=from-file\n");
37
+ loadUatEnv(envFile);
38
+ expect(process.env.UAT_TEST_FOO).toBe("from-shell");
39
+ });
40
+
41
+ it("strips surrounding single or double quotes", () => {
42
+ writeFileSync(envFile, `UAT_TEST_DQ="quoted"\nUAT_TEST_SQ='quoted'\n`);
43
+ loadUatEnv(envFile);
44
+ expect(process.env.UAT_TEST_DQ).toBe("quoted");
45
+ expect(process.env.UAT_TEST_SQ).toBe("quoted");
46
+ });
47
+
48
+ it("ignores blank lines and # comments", () => {
49
+ writeFileSync(envFile, "# top comment\n\nUAT_TEST_FOO=bar\n# trailing\n");
50
+ loadUatEnv(envFile);
51
+ expect(process.env.UAT_TEST_FOO).toBe("bar");
52
+ });
53
+
54
+ it("is a no-op when the file does not exist", () => {
55
+ const before = process.env.UAT_TEST_NONEXISTENT;
56
+ loadUatEnv(join(tmpDir, "missing.env"));
57
+ expect(process.env.UAT_TEST_NONEXISTENT).toBe(before);
58
+ });
59
+
60
+ it("handles values containing = signs (session strings)", () => {
61
+ writeFileSync(envFile, "UAT_TEST_SESSION=abc=def=ghi\n");
62
+ loadUatEnv(envFile);
63
+ expect(process.env.UAT_TEST_SESSION).toBe("abc=def=ghi");
64
+ });
65
+
66
+ it("skips empty values so unpopulated .env.example copies stay unset", () => {
67
+ writeFileSync(envFile, "UAT_TEST_EMPTY=\nUAT_TEST_QUOTED_EMPTY=\"\"\n");
68
+ loadUatEnv(envFile);
69
+ expect(process.env.UAT_TEST_EMPTY).toBeUndefined();
70
+ expect(process.env.UAT_TEST_QUOTED_EMPTY).toBeUndefined();
71
+ });
72
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Load the repo-root `.env` into process.env at harness startup.
3
+ *
4
+ * The UAT harness needs four env vars (TELEGRAM_API_ID, TELEGRAM_API_HASH,
5
+ * TELEGRAM_UAT_DRIVER_SESSION, TELEGRAM_TEST_BOT_USERNAME) that originate
6
+ * in the operator's vault. Re-exporting them every shell session is fiddly,
7
+ * so we let the operator stash them in a gitignored repo-root `.env`.
8
+ * Consolidated at the repo root (previously lived at
9
+ * `telegram-plugin/uat/.env`) so a single file handles UAT secrets +
10
+ * any future repo-wide dev env knobs. See `SETUP.md` §6 for the
11
+ * refresh workflow.
12
+ *
13
+ * Existing process.env values win — a CI run that supplies vars via the
14
+ * job environment doesn't get clobbered by a stale local `.env`.
15
+ */
16
+
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import path from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
22
+ // HERE is `<repo>/telegram-plugin/uat` (or the dist-mirror); two
23
+ // dirname() hops up gets us the repo root regardless of where the
24
+ // bundled output lives.
25
+ export const UAT_ENV_FILE = path.resolve(HERE, "..", "..", ".env");
26
+
27
+ export function loadUatEnv(envPath: string = UAT_ENV_FILE): void {
28
+ if (!existsSync(envPath)) return;
29
+ const content = readFileSync(envPath, "utf-8");
30
+ for (const rawLine of content.split("\n")) {
31
+ const line = rawLine.trim();
32
+ if (line === "" || line.startsWith("#")) continue;
33
+ const eq = line.indexOf("=");
34
+ if (eq === -1) continue;
35
+ const key = line.slice(0, eq).trim();
36
+ let value = line.slice(eq + 1).trim();
37
+ if (
38
+ (value.startsWith('"') && value.endsWith('"')) ||
39
+ (value.startsWith("'") && value.endsWith("'"))
40
+ ) {
41
+ value = value.slice(1, -1);
42
+ }
43
+ if (value === "") continue;
44
+ if (process.env[key] === undefined) {
45
+ process.env[key] = value;
46
+ }
47
+ }
48
+ }
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * Issue: https://github.com/switchroom/switchroom/issues/865
5
5
  *
6
- * Run via `bun run uat:login` from telegram-plugin/. Prompts for
7
- * phone, login code, and (optionally) 2FA password on stdin. Captures
8
- * the session string in memory and writes it to vault under
6
+ * Run via `bun run uat:login` from `telegram-plugin/`. Prompts for
7
+ * phone, login code, and 2FA password on stdin. Captures the session
8
+ * string in memory and writes it to vault under
9
9
  * `telegram-uat-driver-session`. The session string is **never
10
10
  * printed** — not to stdout, not to stderr, not to logs. If you see
11
11
  * one in scrollback, file an incident.
@@ -13,14 +13,29 @@
13
13
  * Required env:
14
14
  * TELEGRAM_API_ID — from https://my.telegram.org/apps
15
15
  * TELEGRAM_API_HASH — from https://my.telegram.org/apps
16
+ *
17
+ * Vault write: the script writes the session into a 0600 tmpfile and
18
+ * spawns `switchroom vault set --file <tmpf> --allow test-harness`
19
+ * with inherited stdio. The operator is prompted once for the vault
20
+ * passphrase (the broker-mediated stdin path doesn't support `--allow`
21
+ * scope flags; see `src/cli/vault.ts:331-356` for the rationale). The
22
+ * tmpfile is `shred -u`'d after the spawn returns.
16
23
  */
17
24
 
18
25
  import { spawn } from "node:child_process";
26
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
27
+ import { tmpdir } from "node:os";
28
+ import { join } from "node:path";
19
29
  import { createInterface } from "node:readline/promises";
20
30
  import { stdin as input, stdout as output } from "node:process";
21
- import { TelegramClient } from "@mtcute/node";
31
+ import { fileURLToPath } from "node:url";
32
+ import { MemoryStorage, TelegramClient } from "@mtcute/node";
33
+ import { loadUatEnv } from "./load-env.js";
34
+
35
+ loadUatEnv();
22
36
 
23
- const VAULT_KEY = "telegram-uat-driver-session";
37
+ export const VAULT_KEY = "telegram-uat-driver-session";
38
+ export const VAULT_SCOPE = "test-harness";
24
39
 
25
40
  async function main(): Promise<void> {
26
41
  const apiId = Number.parseInt(process.env.TELEGRAM_API_ID ?? "", 10);
@@ -33,8 +48,6 @@ async function main(): Promise<void> {
33
48
 
34
49
  const rl = createInterface({ input, output, terminal: true });
35
50
 
36
- // Confirm the operator understands the security posture before we
37
- // mint a bearer-equivalent credential.
38
51
  const ack = await rl.question(
39
52
  [
40
53
  "",
@@ -52,12 +65,15 @@ async function main(): Promise<void> {
52
65
  const phone = (await rl.question("Phone number (E.164, e.g. +14155551234): ")).trim();
53
66
  if (!phone.startsWith("+")) fail("Phone must start with '+'.");
54
67
 
55
- const client = new TelegramClient({ apiId, apiHash });
68
+ // MemoryStorage so nothing lands on disk in this process. The
69
+ // exported session string is the only durable output; everything
70
+ // else is ephemeral.
71
+ const client = new TelegramClient({
72
+ apiId,
73
+ apiHash,
74
+ storage: new MemoryStorage(),
75
+ });
56
76
 
57
- // mtcute exposes a `start()` flow that takes async callbacks for
58
- // each interactive step. Exact callback names may shift across
59
- // versions — verify against the pinned mtcute version before first
60
- // run.
61
77
  await client.start({
62
78
  phone: async () => phone,
63
79
  code: async () =>
@@ -66,54 +82,73 @@ async function main(): Promise<void> {
66
82
  (await rl.question("2FA password (leave blank if none): ")),
67
83
  });
68
84
 
69
- // TODO(#865): mtcute v0.27 exports sessions via the
70
- // `@mtcute/core/utils.js` `StringSessionStorage` adapter; the
71
- // exact call is `await client.exportSession()` only when that
72
- // storage is configured, otherwise sessions live in the SQLite
73
- // file at `client.session`. Phase 2 wires the string-session
74
- // storage so this script can mint a string. For now we throw
75
- // before producing a value so the operator never gets a half-
76
- // baked session in vault.
77
- const session: string = await Promise.reject(
78
- new Error(
79
- "uat:login: Phase 1 stub — Phase 2 wires StringSessionStorage. See uat/SETUP.md §3.",
80
- ),
81
- );
82
-
85
+ const session = await client.exportSession();
83
86
  await client.destroy();
84
87
  rl.close();
85
88
 
86
- // Write to vault via the switchroom CLI's stdin path so the
87
- // session never appears in argv (which would land in `ps` output).
88
89
  await writeToVault(VAULT_KEY, session);
89
90
 
90
- // Belt-and-suspenders: zero out the local copy.
91
- scrub(session);
92
-
93
91
  process.stdout.write(
94
- `\nDone. Session stored in vault as \`${VAULT_KEY}\`.\n` +
92
+ `\nDone. Session stored in vault as \`${VAULT_KEY}\` (scope: allow=${VAULT_SCOPE}).\n` +
95
93
  "If you ever see the actual session string in your terminal, file an incident.\n",
96
94
  );
97
95
  }
98
96
 
99
- function writeToVault(key: string, value: string): Promise<void> {
97
+ /**
98
+ * Spawns `switchroom vault set --file <tmpf> --allow test-harness` so
99
+ * the operator passphrase prompt works (broker-mediated stdin writes
100
+ * reject `--allow`/`--deny`). The tmpfile is created 0700-mode dir,
101
+ * 0600 file, and `shred -u`'d after the set returns regardless of
102
+ * outcome.
103
+ *
104
+ * Exported so `tests/uat-login.test.ts` can pin the security-critical
105
+ * invariants (mode 0600, `--allow test-harness`, cleanup on failure)
106
+ * against the real implementation.
107
+ */
108
+ export async function writeToVault(key: string, value: string): Promise<void> {
109
+ const dir = await mkdtemp(join(tmpdir(), "uat-session-"));
110
+ const path = join(dir, "session");
111
+ try {
112
+ await writeFile(path, value, { mode: 0o600 });
113
+ await runInherit("switchroom", [
114
+ "vault",
115
+ "set",
116
+ key,
117
+ "--file",
118
+ path,
119
+ "--format",
120
+ "string",
121
+ "--allow",
122
+ VAULT_SCOPE,
123
+ ]);
124
+ } finally {
125
+ // Best-effort secure delete. `shred -u` first (overwrites then
126
+ // unlinks); fall back to plain rm if shred is missing.
127
+ await runQuiet("shred", ["-u", path]).catch(() => undefined);
128
+ await rm(dir, { recursive: true, force: true }).catch(() => undefined);
129
+ }
130
+ }
131
+
132
+ function runInherit(cmd: string, args: string[]): Promise<void> {
100
133
  return new Promise((resolve, reject) => {
101
- const proc = spawn("switchroom", ["vault", "set", key], {
102
- stdio: ["pipe", "inherit", "inherit"],
103
- });
134
+ const proc = spawn(cmd, args, { stdio: "inherit" });
104
135
  proc.on("error", reject);
105
136
  proc.on("exit", (code) => {
106
137
  if (code === 0) resolve();
107
- else reject(new Error(`switchroom vault set exited ${code}`));
138
+ else reject(new Error(`${cmd} ${args[0] ?? ""} exited ${code}`));
108
139
  });
109
- proc.stdin.end(value + "\n");
110
140
  });
111
141
  }
112
142
 
113
- function scrub(_s: string): void {
114
- // JS strings are immutable — best we can do is drop the reference
115
- // and trust the GC. Documented here so a future hardening pass
116
- // (e.g. SecureBuffer) has a hook.
143
+ function runQuiet(cmd: string, args: string[]): Promise<void> {
144
+ return new Promise((resolve, reject) => {
145
+ const proc = spawn(cmd, args, { stdio: "ignore" });
146
+ proc.on("error", reject);
147
+ proc.on("exit", (code) => {
148
+ if (code === 0) resolve();
149
+ else reject(new Error(`${cmd} exited ${code}`));
150
+ });
151
+ });
117
152
  }
118
153
 
119
154
  function fail(msg: string): never {
@@ -121,14 +156,22 @@ function fail(msg: string): never {
121
156
  process.exit(1);
122
157
  }
123
158
 
124
- main().catch((err) => {
125
- // Defensive: if mtcute throws, the error MAY contain the session
126
- // string in some adapters. Strip anything that looks like a base64
127
- // blob > 64 chars before printing.
128
- const sanitized = String(err?.message ?? err).replace(
129
- /[A-Za-z0-9+/=_-]{64,}/g,
130
- "<redacted>",
131
- );
132
- process.stderr.write(`uat:login failed: ${sanitized}\n`);
133
- process.exit(1);
134
- });
159
+ // Only run the interactive flow when invoked directly (`bun run
160
+ // uat:login`). Tests that `import` this module for `writeToVault`
161
+ // otherwise trigger the prompt-for-phone-number flow on every load.
162
+ const invokedDirectly =
163
+ process.argv[1] !== undefined &&
164
+ fileURLToPath(import.meta.url) === process.argv[1];
165
+ if (invokedDirectly) {
166
+ main().catch((err) => {
167
+ // Defensive: if mtcute throws, the error MAY contain the session
168
+ // string in some adapters. Strip anything that looks like a long
169
+ // base64 blob before printing.
170
+ const sanitized = String(err?.message ?? err).replace(
171
+ /[A-Za-z0-9+/=_-]{64,}/g,
172
+ "<redacted>",
173
+ );
174
+ process.stderr.write(`uat:login failed: ${sanitized}\n`);
175
+ process.exit(1);
176
+ });
177
+ }