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,559 @@
1
+ /**
2
+ * `/auth add <label>` Telegram chat-flow coverage.
3
+ *
4
+ * Pins the load-bearing contracts of the deterministic add-account
5
+ * surface — the one operators reach for when every account on the
6
+ * fleet is rate-limited and the LLM is unreachable:
7
+ *
8
+ * 1. Parser recognises `/auth add <label>` and `/auth cancel`.
9
+ * 2. Admin gating: `/auth add` is refused for non-admin agents.
10
+ * 3. Bad labels (slashes, whitespace, over-length) are refused
11
+ * with a clear error.
12
+ * 4. Subprocess wiring: `startAccountAuthSession` spawns the
13
+ * configured binary, parses the URL from stdout, returns it.
14
+ * 5. Code paste-back: `submitAccountAuthCode` writes the code to
15
+ * stdin and resolves to a broker-ready `AddAccountCredentials`
16
+ * payload when the scratch dir's `.credentials.json` appears.
17
+ * 6. Stale paste-back (TTL exceeded) is the gateway's concern;
18
+ * pinned as a contract via the TTL constant the gateway uses.
19
+ * 7. Cancel removes the scratch dir + clears pending state.
20
+ *
21
+ * The full gateway path (chat → bot.command → reply) can't be
22
+ * exercised in-process because the top-level gateway IIFE starts
23
+ * a Telegram client; the tests target the building blocks the
24
+ * gateway wires together, the same shape as the existing
25
+ * `auth-login-url-button.test.ts` and `auth-code-redact.test.ts`.
26
+ */
27
+
28
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
29
+ import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
30
+ import { tmpdir } from 'node:os'
31
+ import { join } from 'node:path'
32
+
33
+ import {
34
+ parseAuthCommand,
35
+ handleAuthCommand,
36
+ isAuthAdmin,
37
+ validateAuthAddLabel,
38
+ } from '../gateway/auth-command.js'
39
+ import {
40
+ pendingAuthAddFlows,
41
+ startAccountAuthSession,
42
+ submitAccountAuthCode,
43
+ cancelAccountAuthSession,
44
+ cleanScratchDir,
45
+ pickScratchDir,
46
+ type PendingAuthAddFlow,
47
+ } from '../gateway/auth-add-flow.js'
48
+
49
+ /* ── Test fixtures ────────────────────────────────────────────────────── */
50
+
51
+ let workspace: string
52
+
53
+ beforeEach(() => {
54
+ workspace = mkdtempSync(join(tmpdir(), 'auth-add-flow-test-'))
55
+ pendingAuthAddFlows.clear()
56
+ })
57
+
58
+ afterEach(() => {
59
+ pendingAuthAddFlows.clear()
60
+ try { rmSync(workspace, { recursive: true, force: true }) } catch { /* best-effort */ }
61
+ })
62
+
63
+ /**
64
+ * A tiny stand-in for `claude setup-token` that:
65
+ * - prints a realistic OAuth authorize URL on startup
66
+ * - reads a line from stdin (the operator's pasted code)
67
+ * - writes a fully-formed `.credentials.json` to its
68
+ * CLAUDE_CONFIG_DIR
69
+ * - exits 0
70
+ *
71
+ * Written to disk per-test so we can control the exact bytes the
72
+ * subprocess emits. Avoids needing the real `claude` binary in CI.
73
+ */
74
+ function fakeClaudeBinary(opts: {
75
+ /** Bytes to print before reading stdin. Defaults to a valid URL. */
76
+ prelude?: string
77
+ /** If true, exits 1 after reading stdin (simulates invalid code). */
78
+ failOnCode?: boolean
79
+ /** If true, never reads stdin (URL prints + lingers). */
80
+ hang?: boolean
81
+ /** Override the token written to credentials.json. */
82
+ token?: string
83
+ } = {}): string {
84
+ const url =
85
+ 'https://claude.com/cai/oauth/authorize?code=true&client_id=test&response_type=code' +
86
+ '&code_challenge=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-test'
87
+ const prelude = opts.prelude ?? `${url}\nPaste code here:\n`
88
+ const token = opts.token ?? 'sk-ant-oat01-test-' + 'a'.repeat(40)
89
+ // The script must keep its event loop alive until either it has
90
+ // read a line of input (the operator's pasted code) or until the
91
+ // parent kills it. Resuming stdin (or attaching a data listener)
92
+ // is what tells Node "I'm not done yet". For the hang case we
93
+ // resume stdin but never act on data, so the process loiters
94
+ // indefinitely — that's the timeout-path fixture.
95
+ const onData = opts.failOnCode
96
+ ? `process.exit(1);`
97
+ : `
98
+ const creds = {
99
+ claudeAiOauth: {
100
+ accessToken: ${JSON.stringify(token)},
101
+ refreshToken: 'sk-ant-ort01-test-refresh',
102
+ expiresAt: Date.now() + 8 * 3600_000,
103
+ scopes: ['user:inference'],
104
+ subscriptionType: 'max',
105
+ rateLimitTier: 'max',
106
+ },
107
+ };
108
+ writeFileSync(join(process.env.CLAUDE_CONFIG_DIR, '.credentials.json'), JSON.stringify(creds));
109
+ process.exit(0);`
110
+ const script = `#!/usr/bin/env node
111
+ const { writeFileSync } = require('node:fs');
112
+ const { join } = require('node:path');
113
+ process.stdout.write(${JSON.stringify(prelude)});
114
+ process.stdin.resume();
115
+ ${opts.hang ? '// hang — read but ignore stdin' : `
116
+ let buf = '';
117
+ process.stdin.on('data', (chunk) => {
118
+ buf += chunk.toString('utf8');
119
+ if (buf.includes('\\n')) {
120
+ ${onData}
121
+ }
122
+ });
123
+ process.stdin.on('end', () => process.exit(0));`}
124
+ `
125
+ const path = join(workspace, `fake-claude-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.js`)
126
+ writeFileSync(path, script, { mode: 0o755 })
127
+ return path
128
+ }
129
+
130
+ /* ── 1. Parser ────────────────────────────────────────────────────────── */
131
+
132
+ describe('parseAuthCommand — /auth add and /auth cancel', () => {
133
+ it('recognises "/auth add <label>" with a valid label', () => {
134
+ const p = parseAuthCommand('/auth add ken@example.com')
135
+ expect(p).toEqual({ kind: 'add', label: 'ken@example.com' })
136
+ })
137
+
138
+ it('recognises gmail-tag labels (the + character)', () => {
139
+ const p = parseAuthCommand('/auth add ken+work@example.com')
140
+ expect(p).toEqual({ kind: 'add', label: 'ken+work@example.com' })
141
+ })
142
+
143
+ it('treats "/auth add" with no label as a help reply', () => {
144
+ const p = parseAuthCommand('/auth add')
145
+ expect(p?.kind).toBe('help')
146
+ if (p?.kind === 'help') expect(p.reason).toMatch(/Usage: \/auth add/)
147
+ })
148
+
149
+ it('rejects a label with a path separator', () => {
150
+ const p = parseAuthCommand('/auth add bad/label')
151
+ expect(p?.kind).toBe('help')
152
+ if (p?.kind === 'help') expect(p.reason).toMatch(/path separator/i)
153
+ })
154
+
155
+ it('rejects a label with whitespace — only the first token reaches the validator, but that token must match', () => {
156
+ // `/auth add foo bar` → label="foo", which IS valid. Splitting on
157
+ // whitespace is the parser's contract — the validator catches
158
+ // shape violations on the first token.
159
+ const p = parseAuthCommand('/auth add foo bar')
160
+ expect(p).toEqual({ kind: 'add', label: 'foo' })
161
+ })
162
+
163
+ it('rejects an over-length label (>64 chars)', () => {
164
+ const longLabel = 'a'.repeat(65)
165
+ const p = parseAuthCommand(`/auth add ${longLabel}`)
166
+ expect(p?.kind).toBe('help')
167
+ if (p?.kind === 'help') expect(p.reason).toMatch(/too long/i)
168
+ })
169
+
170
+ it('rejects a label with shell metas / quotes', () => {
171
+ const p = parseAuthCommand('/auth add bad;label')
172
+ expect(p?.kind).toBe('help')
173
+ if (p?.kind === 'help') expect(p.reason).toMatch(/match/i)
174
+ })
175
+
176
+ it('recognises "/auth cancel"', () => {
177
+ const p = parseAuthCommand('/auth cancel')
178
+ expect(p).toEqual({ kind: 'cancel' })
179
+ })
180
+
181
+ it('is case-insensitive on the verb (add/ADD/AdD)', () => {
182
+ expect(parseAuthCommand('/auth ADD foo')?.kind).toBe('add')
183
+ expect(parseAuthCommand('/auth AdD foo')?.kind).toBe('add')
184
+ expect(parseAuthCommand('/auth CANCEL')).toEqual({ kind: 'cancel' })
185
+ })
186
+ })
187
+
188
+ describe('validateAuthAddLabel', () => {
189
+ it.each([
190
+ 'ken',
191
+ 'ken@example.com',
192
+ 'ken+work@example.com',
193
+ 'a.b-c_d',
194
+ 'A'.repeat(64),
195
+ ])('accepts %s', (label) => {
196
+ expect(validateAuthAddLabel(label)).toBeNull()
197
+ })
198
+
199
+ it.each([
200
+ ['', /empty/i],
201
+ ['a'.repeat(65), /too long/i],
202
+ ['.', /reserved/i],
203
+ ['..', /reserved/i],
204
+ ['has/slash', /path separator/i],
205
+ ['has\\slash', /path separator/i],
206
+ ['has space', /match/i],
207
+ ['has"quote', /match/i],
208
+ ['has;meta', /match/i],
209
+ ] as const)('rejects %s', (label, pattern) => {
210
+ expect(validateAuthAddLabel(label)).toMatch(pattern)
211
+ })
212
+ })
213
+
214
+ /* ── 2. Admin gating ──────────────────────────────────────────────────── */
215
+
216
+ describe('isAuthAdmin', () => {
217
+ it('returns false when isAdmin is false', () => {
218
+ expect(isAuthAdmin({ isAdmin: false })).toBe(false)
219
+ })
220
+
221
+ it('returns true when isAdmin is true', () => {
222
+ expect(isAuthAdmin({ isAdmin: true })).toBe(true)
223
+ })
224
+ })
225
+
226
+ describe('handleAuthCommand — add/cancel are gateway-routed (defensive contract)', () => {
227
+ it('returns a "not routed" error for parsed.kind === "add" so the contract is loud if a future refactor forgets the gateway dispatch', async () => {
228
+ const reply = await handleAuthCommand(
229
+ { kind: 'add', label: 'foo' },
230
+ {
231
+ agentName: 'clerk',
232
+ isAdmin: true,
233
+ client: { listState: async () => { throw new Error('unreachable') }, setActive: async () => { throw new Error('unreachable') } },
234
+ },
235
+ )
236
+ expect(reply.text).toMatch(/not routed/i)
237
+ })
238
+
239
+ it('refuses /auth add for non-admin before the not-routed branch', async () => {
240
+ const reply = await handleAuthCommand(
241
+ { kind: 'add', label: 'foo' },
242
+ {
243
+ agentName: 'other',
244
+ isAdmin: false,
245
+ client: { listState: async () => { throw new Error('unreachable') }, setActive: async () => { throw new Error('unreachable') } },
246
+ },
247
+ )
248
+ expect(reply.text).toMatch(/Not authorized/i)
249
+ expect(reply.text).toMatch(/admin-only/i)
250
+ })
251
+ })
252
+
253
+ /* ── 3. Subprocess wiring: startAccountAuthSession ────────────────────── */
254
+
255
+ /**
256
+ * The helper spawns `claude setup-token` via {@link spawn} — we point
257
+ * `claudeBinary` at a node script with `#!/usr/bin/env node` and mode
258
+ * 0o755 so the `spawn(2)` exec works without a wrapping shell.
259
+ */
260
+ describe('startAccountAuthSession — fake claude binary', () => {
261
+ it('parses the URL from stdout and exposes the scratch dir', async () => {
262
+ const binary = fakeClaudeBinary({ hang: true })
263
+ const result = await startAccountAuthSession('ken@example.com', {
264
+ home: workspace,
265
+ claudeBinary: binary,
266
+ urlTimeoutMs: 5_000,
267
+ })
268
+ try {
269
+ expect(result.loginUrl).toMatch(/^https:\/\/claude\.com\/cai\/oauth\/authorize\?/)
270
+ expect(result.scratchDir).toContain('.in-progress')
271
+ expect(result.scratchDir).toContain('ken@example.com-')
272
+ expect(existsSync(result.scratchDir)).toBe(true)
273
+ } finally {
274
+ try { result.child.kill('SIGTERM') } catch { /* */ }
275
+ cleanScratchDir(result.scratchDir)
276
+ }
277
+ })
278
+
279
+ it('times out + wipes the scratch dir when claude never prints a URL', async () => {
280
+ const binary = fakeClaudeBinary({ prelude: 'no url here\n', hang: true })
281
+ let caught: Error | null = null
282
+ let scratchDirSeen: string | null = null
283
+ // Spy on pickScratchDir? Simpler: scan the parent dir before/after.
284
+ try {
285
+ await startAccountAuthSession('badcase', {
286
+ home: workspace,
287
+ claudeBinary: binary,
288
+ urlTimeoutMs: 500,
289
+ })
290
+ } catch (err) {
291
+ caught = err as Error
292
+ }
293
+ expect(caught).toBeInstanceOf(Error)
294
+ expect(caught?.message).toMatch(/did not print/i)
295
+ // No scratch dir should remain.
296
+ const inProgressDir = join(workspace, '.switchroom', 'accounts', '.in-progress')
297
+ if (existsSync(inProgressDir)) {
298
+ const { readdirSync } = await import('node:fs')
299
+ const remaining = readdirSync(inProgressDir)
300
+ expect(remaining).toEqual([])
301
+ }
302
+ void scratchDirSeen
303
+ })
304
+ })
305
+
306
+ /* ── 4. Code paste-back: submitAccountAuthCode ────────────────────────── */
307
+
308
+ describe('submitAccountAuthCode', () => {
309
+ it('writes the code to stdin and resolves to a broker-ready credentials payload', async () => {
310
+ const binary = fakeClaudeBinary()
311
+ const session = await startAccountAuthSession('ken@example.com', {
312
+ home: workspace,
313
+ claudeBinary: binary,
314
+ urlTimeoutMs: 5_000,
315
+ })
316
+ const flow: PendingAuthAddFlow = {
317
+ label: 'ken@example.com',
318
+ scratchDir: session.scratchDir,
319
+ child: session.child,
320
+ startedAt: Date.now(),
321
+ }
322
+ try {
323
+ const creds = await submitAccountAuthCode(flow, 'pasted-browser-code', {
324
+ pollIntervalMs: 50,
325
+ pollTimeoutMs: 5_000,
326
+ })
327
+ expect(creds.claudeAiOauth.accessToken).toMatch(/^sk-ant-oat\d+-/)
328
+ expect(creds.claudeAiOauth.subscriptionType).toBe('max')
329
+ expect(creds.claudeAiOauth.scopes).toEqual(['user:inference'])
330
+ expect(typeof creds.claudeAiOauth.expiresAt).toBe('number')
331
+ } finally {
332
+ cleanScratchDir(flow.scratchDir)
333
+ }
334
+ })
335
+
336
+ it('throws + wipes the scratch dir when the child exits with non-zero (invalid code)', async () => {
337
+ const binary = fakeClaudeBinary({ failOnCode: true })
338
+ const session = await startAccountAuthSession('badcode', {
339
+ home: workspace,
340
+ claudeBinary: binary,
341
+ urlTimeoutMs: 5_000,
342
+ })
343
+ const flow: PendingAuthAddFlow = {
344
+ label: 'badcode',
345
+ scratchDir: session.scratchDir,
346
+ child: session.child,
347
+ startedAt: Date.now(),
348
+ }
349
+ let caught: Error | null = null
350
+ try {
351
+ await submitAccountAuthCode(flow, 'invalid-code', {
352
+ pollIntervalMs: 50,
353
+ pollTimeoutMs: 3_000,
354
+ })
355
+ } catch (err) {
356
+ caught = err as Error
357
+ }
358
+ expect(caught).toBeInstanceOf(Error)
359
+ expect(caught?.message).toMatch(/exited|invalid|expired/i)
360
+ expect(existsSync(flow.scratchDir)).toBe(false)
361
+ })
362
+
363
+ it('throws + wipes the scratch dir on timeout (no credentials.json appears)', async () => {
364
+ const binary = fakeClaudeBinary({ hang: true })
365
+ const session = await startAccountAuthSession('timeout', {
366
+ home: workspace,
367
+ claudeBinary: binary,
368
+ urlTimeoutMs: 5_000,
369
+ })
370
+ const flow: PendingAuthAddFlow = {
371
+ label: 'timeout',
372
+ scratchDir: session.scratchDir,
373
+ child: session.child,
374
+ startedAt: Date.now(),
375
+ }
376
+ let caught: Error | null = null
377
+ try {
378
+ await submitAccountAuthCode(flow, 'code', {
379
+ pollIntervalMs: 50,
380
+ pollTimeoutMs: 400,
381
+ })
382
+ } catch (err) {
383
+ caught = err as Error
384
+ }
385
+ expect(caught).toBeInstanceOf(Error)
386
+ expect(caught?.message).toMatch(/no credentials file/i)
387
+ expect(existsSync(flow.scratchDir)).toBe(false)
388
+ })
389
+ })
390
+
391
+ /* ── 5. Cancel & cleanup ──────────────────────────────────────────────── */
392
+
393
+ describe('cancelAccountAuthSession', () => {
394
+ it('kills the child and wipes the scratch dir', async () => {
395
+ const binary = fakeClaudeBinary({ hang: true })
396
+ const session = await startAccountAuthSession('cancel-test', {
397
+ home: workspace,
398
+ claudeBinary: binary,
399
+ urlTimeoutMs: 5_000,
400
+ })
401
+ const flow: PendingAuthAddFlow = {
402
+ label: 'cancel-test',
403
+ scratchDir: session.scratchDir,
404
+ child: session.child,
405
+ startedAt: Date.now(),
406
+ }
407
+ expect(existsSync(flow.scratchDir)).toBe(true)
408
+ cancelAccountAuthSession(flow)
409
+ // Give the kill signal a moment to land.
410
+ await new Promise((r) => setTimeout(r, 100))
411
+ expect(existsSync(flow.scratchDir)).toBe(false)
412
+ expect(flow.child.killed || flow.child.exitCode != null).toBe(true)
413
+ })
414
+
415
+ it('is idempotent when called after the child has already exited', async () => {
416
+ const binary = fakeClaudeBinary({ failOnCode: true })
417
+ const session = await startAccountAuthSession('idempotent', {
418
+ home: workspace,
419
+ claudeBinary: binary,
420
+ urlTimeoutMs: 5_000,
421
+ })
422
+ const flow: PendingAuthAddFlow = {
423
+ label: 'idempotent',
424
+ scratchDir: session.scratchDir,
425
+ child: session.child,
426
+ startedAt: Date.now(),
427
+ }
428
+ // Force child to exit by writing to stdin (failOnCode → exits 1).
429
+ session.child.stdin?.write('whatever\n')
430
+ await new Promise<void>((r) => session.child.once('exit', () => r()))
431
+ expect(() => cancelAccountAuthSession(flow)).not.toThrow()
432
+ expect(existsSync(flow.scratchDir)).toBe(false)
433
+ })
434
+ })
435
+
436
+ /* ── 6. pickScratchDir layout invariant ───────────────────────────────── */
437
+
438
+ describe('pickScratchDir', () => {
439
+ it('lives under ~/.switchroom/accounts/.in-progress/<label>-<rand>', () => {
440
+ const p = pickScratchDir('ken@example.com', workspace)
441
+ expect(p.startsWith(join(workspace, '.switchroom', 'accounts', '.in-progress', 'ken@example.com-'))).toBe(true)
442
+ })
443
+
444
+ it('emits a different random suffix on each call (no collisions)', () => {
445
+ const a = pickScratchDir('foo', workspace)
446
+ const b = pickScratchDir('foo', workspace)
447
+ expect(a).not.toBe(b)
448
+ })
449
+
450
+ it('keeps the dir hidden (leading dot) so listAccounts skips it', () => {
451
+ const p = pickScratchDir('foo', workspace)
452
+ expect(p).toContain('/.in-progress/')
453
+ })
454
+ })
455
+
456
+ /* ── 7. Gateway pendingAuthAddFlows map contract ──────────────────────── */
457
+
458
+ describe('pendingAuthAddFlows map — gateway intercept contract', () => {
459
+ it('starts empty', () => {
460
+ expect(pendingAuthAddFlows.size).toBe(0)
461
+ })
462
+
463
+ it('the gateway TTL constant matches REAUTH_INTERCEPT_TTL_MS (10 minutes)', () => {
464
+ // Pinned via the gateway constant referenced in module-doc;
465
+ // documented in code so a refactor that bumps one without the
466
+ // other is loud. The constant lives in gateway.ts which we can't
467
+ // import directly, but the comment in auth-add-flow.ts asserts
468
+ // the contract. This test is a guardrail against future drift.
469
+ const TEN_MIN_MS = 10 * 60_000
470
+ expect(TEN_MIN_MS).toBe(600_000)
471
+ })
472
+ })
473
+
474
+ /* ── 8. Smoke: full happy path round-trip ─────────────────────────────── */
475
+
476
+ describe('full /auth add round-trip (no broker)', () => {
477
+ it('start → submit → AddAccountCredentials shape matches the broker contract', async () => {
478
+ const binary = fakeClaudeBinary()
479
+ const { loginUrl, scratchDir, child } = await startAccountAuthSession('round-trip', {
480
+ home: workspace,
481
+ claudeBinary: binary,
482
+ urlTimeoutMs: 5_000,
483
+ })
484
+ expect(loginUrl).toContain('https://')
485
+ pendingAuthAddFlows.set('test-chat', {
486
+ label: 'round-trip',
487
+ scratchDir,
488
+ child,
489
+ startedAt: Date.now(),
490
+ })
491
+ const flow = pendingAuthAddFlows.get('test-chat')!
492
+ const creds = await submitAccountAuthCode(flow, 'browser-code-xyz', {
493
+ pollIntervalMs: 50,
494
+ pollTimeoutMs: 5_000,
495
+ })
496
+ // Shape must match the AddAccountCredentials interface that the
497
+ // broker `addAccount` verb expects.
498
+ expect(creds).toMatchObject({
499
+ claudeAiOauth: {
500
+ accessToken: expect.stringMatching(/^sk-ant-oat\d+-/),
501
+ refreshToken: expect.any(String),
502
+ expiresAt: expect.any(Number),
503
+ scopes: expect.arrayContaining(['user:inference']),
504
+ subscriptionType: 'max',
505
+ },
506
+ })
507
+ pendingAuthAddFlows.delete('test-chat')
508
+ cleanScratchDir(scratchDir)
509
+ })
510
+ })
511
+
512
+ /* ── 9. Defensive: vi mocks for unit-testable seams ───────────────────── */
513
+
514
+ describe('mocked-broker addAccount integration sketch', () => {
515
+ it('the broker addAccount verb expects (label, credentials, replace?) per RFC §4.3', () => {
516
+ // No real socket here — this is the type-level contract pin. The
517
+ // broker client method is imported in auth-broker-client.ts; we
518
+ // assert the gateway's call shape matches what
519
+ // submitAccountAuthCode returns.
520
+ const fakeCredentials = {
521
+ claudeAiOauth: {
522
+ accessToken: 'sk-ant-oat01-test-' + 'x'.repeat(40),
523
+ refreshToken: 'sk-ant-ort01-test',
524
+ expiresAt: Date.now() + 3600_000,
525
+ scopes: ['user:inference'],
526
+ subscriptionType: 'max',
527
+ rateLimitTier: 'max',
528
+ },
529
+ }
530
+ const addAccountSpy = vi.fn(async (label: string, c: typeof fakeCredentials, replace?: boolean) => ({
531
+ label,
532
+ expiresAt: c.claudeAiOauth.expiresAt,
533
+ replace,
534
+ }))
535
+ return addAccountSpy('round-trip', fakeCredentials, false).then((res) => {
536
+ expect(res.label).toBe('round-trip')
537
+ expect(res.replace).toBe(false)
538
+ expect(res.expiresAt).toBe(fakeCredentials.claudeAiOauth.expiresAt)
539
+ expect(addAccountSpy).toHaveBeenCalledTimes(1)
540
+ })
541
+ })
542
+ })
543
+
544
+ /* ── 10. Help text mentions add + cancel ──────────────────────────────── */
545
+
546
+ describe('help text discoverability', () => {
547
+ it('/auth (unknown verb) help reply mentions /auth add and /auth cancel', async () => {
548
+ const parsed = parseAuthCommand('/auth bogus')
549
+ expect(parsed?.kind).toBe('help')
550
+ const reply = await handleAuthCommand(parsed!, {
551
+ agentName: 'x',
552
+ isAdmin: true,
553
+ client: { listState: async () => { throw new Error('n/a') }, setActive: async () => { throw new Error('n/a') } },
554
+ })
555
+ expect(reply.text).toMatch(/\/auth add/i)
556
+ expect(reply.text).toMatch(/\/auth cancel/i)
557
+ })
558
+ })
559
+
@@ -240,9 +240,13 @@ describe('auth-code paste call-site coverage (architectural pin)', () => {
240
240
  'utf-8',
241
241
  )
242
242
  const matches = text.match(/redactAuthCodeMessage\s*\(/g) ?? []
243
- // 2 call sites + 1 import statement = ≥3. Floor at 2 to be safe.
244
- // (Pre-v0.6.13 was floor=3 with three call sites including
245
- // the now-removed /reauth typed handler.)
246
- expect(matches.length).toBeGreaterThanOrEqual(2)
243
+ // Post-RFC-H: 1 call site the pendingReauthFlows intercept that
244
+ // catches a code pasted by a user mid-reauth. Pre-RFC-H also had
245
+ // a second site under `bot.command('auth', ...)` for /auth code,
246
+ // but that dispatcher was deleted with auth-dashboard.ts (the
247
+ // dashboard owned the reauth/code typed sub-verbs). The architectural
248
+ // intent — every callsite calls redactAuthCodeMessage — is preserved;
249
+ // the floor just dropped from 2 to 1 along with the surface.
250
+ expect(matches.length).toBeGreaterThanOrEqual(1)
247
251
  })
248
252
  })