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,103 @@
1
+ /**
2
+ * Pin the contract: the boot card silences its Telegram notification
3
+ * (passes `disable_notification: true` to `sendMessage`) iff the
4
+ * restart marker's `reason` text starts with `"operator:"`.
5
+ *
6
+ * Background: every agent in the fleet posts a boot card after a
7
+ * `switchroom update`. Without this gate the operator gets N push
8
+ * notifications for one planned redeploy — once-per-agent on every
9
+ * routine update. User-initiated restarts (`/restart` from chat,
10
+ * `cli: switchroom restart`) and unplanned events (crash, fresh) still
11
+ * notify because the user asked for them or needs to know something
12
+ * went wrong.
13
+ *
14
+ * The toggle is keyed on the reason TEXT (`opts.restartReasonDetail`),
15
+ * not the RestartReason enum, because the enum collapses all
16
+ * marker-bearing restarts into `'graceful'` — losing the operator-vs-
17
+ * user distinction. The reason text is the source of truth for who
18
+ * triggered the restart.
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest'
22
+ import { startBootCard } from '../gateway/boot-card.js'
23
+ import type { BotApiForBootCard } from '../gateway/boot-card.js'
24
+
25
+ /** Capture sendMessage opts for assertion. editMessageText is a no-op. */
26
+ function makeCapturingBot(): {
27
+ bot: BotApiForBootCard
28
+ sends: Array<{ chatId: string; text: string; opts: Record<string, unknown> }>
29
+ } {
30
+ const sends: Array<{ chatId: string; text: string; opts: Record<string, unknown> }> = []
31
+ const bot: BotApiForBootCard = {
32
+ sendMessage: async (chatId, text, opts) => {
33
+ sends.push({ chatId, text, opts: opts ?? {} })
34
+ return { message_id: 42 }
35
+ },
36
+ editMessageText: async () => ({}),
37
+ }
38
+ return { bot, sends }
39
+ }
40
+
41
+ /** Common opts — only the reason-detail varies per test. */
42
+ function mkOpts(overrides: { restartReasonDetail?: string; restartReason?: 'planned' | 'graceful' | 'crash' | 'fresh' } = {}) {
43
+ return {
44
+ agentName: 'TestAgent',
45
+ agentSlug: 'test-agent',
46
+ version: 'v0.0.0-test',
47
+ agentDir: '/tmp/test-agent',
48
+ gatewayInfo: { pid: 1, startedAtMs: Date.now() },
49
+ restartReason: overrides.restartReason ?? 'graceful' as const,
50
+ restartReasonDetail: overrides.restartReasonDetail,
51
+ // Disable the live loop + probes — we only want the initial sendMessage.
52
+ agentLiveWindowMs: 0,
53
+ settleWindowMs: 1_000_000,
54
+ }
55
+ }
56
+
57
+ describe('boot card — silent-on-operator-reason', () => {
58
+ it('passes disable_notification: true when restartReasonDetail starts with "operator:"', async () => {
59
+ const { bot, sends } = makeCapturingBot()
60
+ await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'operator: switchroom update' }))
61
+ expect(sends).toHaveLength(1)
62
+ expect(sends[0]!.opts.disable_notification).toBe(true)
63
+ })
64
+
65
+ it('omits disable_notification when restartReasonDetail starts with "user:"', async () => {
66
+ const { bot, sends } = makeCapturingBot()
67
+ await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'user: /restart from chat' }))
68
+ expect(sends).toHaveLength(1)
69
+ expect(sends[0]!.opts.disable_notification).toBeUndefined()
70
+ })
71
+
72
+ it('omits disable_notification when restartReasonDetail starts with "cli:"', async () => {
73
+ const { bot, sends } = makeCapturingBot()
74
+ await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'cli: switchroom restart' }))
75
+ expect(sends).toHaveLength(1)
76
+ expect(sends[0]!.opts.disable_notification).toBeUndefined()
77
+ })
78
+
79
+ it('omits disable_notification when restartReasonDetail is undefined (crash / fresh path)', async () => {
80
+ const { bot, sends } = makeCapturingBot()
81
+ await startBootCard('chat1', undefined, bot, mkOpts({ restartReason: 'crash' }))
82
+ expect(sends).toHaveLength(1)
83
+ expect(sends[0]!.opts.disable_notification).toBeUndefined()
84
+ })
85
+
86
+ it('omits disable_notification when restartReasonDetail is empty string', async () => {
87
+ const { bot, sends } = makeCapturingBot()
88
+ await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: '' }))
89
+ expect(sends).toHaveLength(1)
90
+ expect(sends[0]!.opts.disable_notification).toBeUndefined()
91
+ })
92
+
93
+ it('matches the "operator:" prefix exactly — "operator-ish" should NOT silence', async () => {
94
+ // Defence against future operator-side reasons that don't actually
95
+ // want silent — confirms we're matching the prefix-with-colon shape,
96
+ // not a fuzzy contains.
97
+ const { bot, sends } = makeCapturingBot()
98
+ await startBootCard('chat1', undefined, bot, mkOpts({ restartReasonDetail: 'operator-ish: rolled over' }))
99
+ expect(sends).toHaveLength(1)
100
+ // 'operator-ish:' does NOT start with 'operator:' so still notifies.
101
+ expect(sends[0]!.opts.disable_notification).toBeUndefined()
102
+ })
103
+ })
@@ -3,15 +3,16 @@
3
3
  *
4
4
  * Covers:
5
5
  * - #208: probeAgentProcess — deactivating → 🟡 (not 🔴), re-probe loop
6
- * - #210: probeQuota — 429 ok-with-note + 30 s cache
6
+ * - #1163: probeQuota — uses /v1/messages headers path (was /api/oauth/usage)
7
7
  */
8
8
 
9
9
  import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
10
- import { mkdtempSync, rmSync } from 'fs'
10
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs'
11
11
  import { tmpdir } from 'os'
12
12
  import { join } from 'path'
13
13
 
14
14
  import {
15
+ probeAccount,
15
16
  probeAgentProcess,
16
17
  probeScheduler,
17
18
  probeBroker,
@@ -242,7 +243,7 @@ describe('probeAgentProcess — #208: re-probe loop resolves transient', () => {
242
243
  })
243
244
  })
244
245
 
245
- // ── #210: probeQuota — 429 ok-with-note + 30s cache ────────────────────
246
+ // ── #1163: probeQuota — /v1/messages headers path ─────────────────────────
246
247
 
247
248
  import { writeFileSync, mkdirSync } from 'fs'
248
249
  import { writeQuotaCache } from '../gateway/quota-cache.js'
@@ -272,20 +273,38 @@ afterEach(() => {
272
273
  rmSync(tmp, { recursive: true, force: true })
273
274
  })
274
275
 
275
- describe('probeQuota — #210: 429 returns ok-with-note', () => {
276
- it('returns ok with "quota check skipped: rate limited" on 429', async () => {
276
+ describe('probeQuota — #1163: /v1/messages headers path', () => {
277
+ // The `/api/oauth/usage` endpoint has been deprecated/tightened
278
+ // probeQuota now uses `fetchQuota` against `/v1/messages` and reads
279
+ // the unified-ratelimit response headers, same path /status uses.
280
+ it('reports ok with utilization line on a healthy response', async () => {
281
+ const headers = new Headers({
282
+ 'anthropic-ratelimit-unified-5h-utilization': '0.42',
283
+ 'anthropic-ratelimit-unified-7d-utilization': '0.18',
284
+ })
277
285
  const fakeFetch: typeof fetch = async () =>
278
- new Response(null, { status: 429 }) as Response
286
+ new Response('{}', { status: 200, headers }) as Response
279
287
 
280
288
  const result = await probeQuota(claudeDir, agentDir, fakeFetch)
281
289
  expect(result.status).toBe('ok')
282
290
  expect(result.label).toBe('Quota')
283
- expect(result.detail).toBe('quota check skipped: rate limited')
284
- // #247: structured field so writeQuotaCache can key TTL off it
285
- expect(result.rateLimited).toBe(true)
291
+ expect(result.detail).toContain('42% / 5h')
292
+ expect(result.detail).toContain('18% / 7d')
286
293
  })
287
294
 
288
- it('writing 429 ok-result to cache produces a readable 30 s entry', () => {
295
+ it('surfaces auth rejection with the RFC-H replace-account hint on 403', async () => {
296
+ const fakeFetch: typeof fetch = async () =>
297
+ new Response(null, { status: 403 }) as Response
298
+
299
+ const result = await probeQuota(claudeDir, agentDir, fakeFetch)
300
+ expect(result.status).toBe('degraded')
301
+ // Post-RFC-H: per-agent `auth login` is retired. probeQuota emits the
302
+ // broker-aware "replace the account" hint pointing at `auth add ...
303
+ // --replace` instead. See telegram-plugin/gateway/boot-probes.ts.
304
+ expect(result.nextStep).toMatch(/switchroom auth add .*--from-oauth --replace/)
305
+ })
306
+
307
+ it('writing rate-limited result to cache produces a readable 30 s entry', () => {
289
308
  // Verify the cache contract: writeQuotaCache stores rate-limit results
290
309
  // with RATE_LIMIT_TTL_MS keyed off rateLimited:true, not the detail string.
291
310
  const rateLimitResult = {
@@ -855,6 +874,67 @@ describe('probeSkills', () => {
855
874
  })
856
875
  })
857
876
 
877
+ // ── probeAccount nextStep interpolation (PR #1081 reviewer follow-up) ─────
878
+
879
+ describe('probeAccount — nextStep agent-name interpolation', () => {
880
+ let tmpDir: string
881
+
882
+ function setupAgentDir(claudeJson: Record<string, unknown>, tokenMeta?: { expiresAt: number }): string {
883
+ const agentDir = mkdtempSync(join(tmpdir(), 'probe-account-'))
884
+ const claudeDir = join(agentDir, '.claude')
885
+ mkdirSync(claudeDir, { recursive: true })
886
+ writeFileSync(join(claudeDir, '.claude.json'), JSON.stringify(claudeJson))
887
+ if (tokenMeta) {
888
+ writeFileSync(join(claudeDir, '.oauth-token.meta.json'), JSON.stringify(tokenMeta))
889
+ }
890
+ return agentDir
891
+ }
892
+
893
+ afterEach(() => {
894
+ if (tmpDir) {
895
+ try { rmSync(tmpDir, { recursive: true, force: true }) } catch {}
896
+ }
897
+ })
898
+
899
+ it('not-signed-in hint interpolates agentName instead of <agent>', async () => {
900
+ tmpDir = setupAgentDir({})
901
+ const result = await probeAccount(tmpDir, { agentName: 'finn' })
902
+ expect(result.status).toBe('degraded')
903
+ expect(result.detail).toBe('not signed in')
904
+ expect(result.nextStep).toBeDefined()
905
+ expect(result.nextStep).toContain('switchroom auth login finn')
906
+ expect(result.nextStep).not.toContain('<agent>')
907
+ })
908
+
909
+ it('expired-token hint interpolates agentName', async () => {
910
+ tmpDir = setupAgentDir(
911
+ { oauthAccount: { emailAddress: 'me@example.com', billingType: 'max' } },
912
+ { expiresAt: Date.now() - 86_400_000 }, // expired yesterday
913
+ )
914
+ const result = await probeAccount(tmpDir, { agentName: 'klanker' })
915
+ expect(result.status).toBe('fail')
916
+ expect(result.nextStep).toContain('switchroom auth login klanker')
917
+ expect(result.nextStep).not.toContain('<agent>')
918
+ })
919
+
920
+ it('expiring-soon hint interpolates agentName', async () => {
921
+ tmpDir = setupAgentDir(
922
+ { oauthAccount: { emailAddress: 'me@example.com', billingType: 'max' } },
923
+ { expiresAt: Date.now() + 3 * 86_400_000 }, // 3 days left (< 7)
924
+ )
925
+ const result = await probeAccount(tmpDir, { agentName: 'lawgpt' })
926
+ expect(result.status).toBe('degraded')
927
+ expect(result.nextStep).toContain('switchroom auth login lawgpt')
928
+ expect(result.nextStep).not.toContain('<agent>')
929
+ })
930
+
931
+ it('falls back to <agent> placeholder when no agentName provided (backwards-compat)', async () => {
932
+ tmpDir = setupAgentDir({})
933
+ const result = await probeAccount(tmpDir)
934
+ expect(result.nextStep).toContain('<agent>')
935
+ })
936
+ })
937
+
858
938
  // ── /proc parser unit tests (synthetic fs) ────────────────────────────────
859
939
 
860
940
  /** Build a /proc/<pid>/stat string for tests. */
@@ -1012,4 +1092,130 @@ describe('uptimeMsForStarttime', () => {
1012
1092
  expect(uptimeMsForStarttime(99999999, fs)).toBeNull()
1013
1093
  })
1014
1094
  })
1095
+
1096
+ // ── nextStep remediation hints on degraded/fail probe branches ──────────────
1097
+ // Every fail/degraded result must carry an actionable `nextStep` per
1098
+ // reference/principles.md principle 1. These tests pin the hints across
1099
+ // the probes covered by the boot-card-dedup-and-next-steps PR so we don't
1100
+ // silently lose the hint on a future refactor.
1101
+
1102
+ describe('nextStep — agent systemd states', () => {
1103
+ it('attaches a journalctl hint when the unit is failed', async () => {
1104
+ const exec = makeSequence([makeSystemctlOutput('failed')])
1105
+ const r = await probeAgentProcess('klanker', {
1106
+ execFileImpl: exec as unknown as (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string }>,
1107
+ sleepImpl: async () => {},
1108
+ retryIntervalMs: 1,
1109
+ retryMaxMs: 0,
1110
+ })
1111
+ expect(r.status).toBe('fail')
1112
+ expect(r.nextStep).toMatch(/journalctl/)
1113
+ expect(r.nextStep).toMatch(/switchroom-klanker/)
1114
+ })
1115
+
1116
+ it('attaches a transient-state hint when the unit is activating after retry budget', async () => {
1117
+ const exec = makeSequence([makeSystemctlOutput('activating')])
1118
+ const r = await probeAgentProcess('klanker', {
1119
+ execFileImpl: exec as unknown as (cmd: string, args: string[]) => Promise<{ stdout: string; stderr: string }>,
1120
+ sleepImpl: async () => {},
1121
+ retryIntervalMs: 1,
1122
+ retryMaxMs: 0,
1123
+ })
1124
+ expect(r.status).toBe('degraded')
1125
+ expect(r.nextStep).toMatch(/transient/)
1126
+ expect(r.nextStep).toMatch(/`activating`/)
1127
+ })
1128
+
1129
+ it('attaches a docker-restart hint via the production dockerProbe when no claude in /proc', async () => {
1130
+ // Inject a synthetic /proc with no claude entries — the production
1131
+ // dockerProbe attaches the nextStep hint itself.
1132
+ const { findAgentProcessInContainer } = await import('../gateway/boot-probes.js')
1133
+ const fs = { readdir: () => [] as string[], readFile: () => '' }
1134
+ const found = findAgentProcessInContainer(fs)
1135
+ expect(found).toBeNull()
1136
+ // Now run the docker probe under an override that mimics the
1137
+ // production "claude not found" path so we exercise the nextStep
1138
+ // attachment without depending on the test host's /proc state.
1139
+ const r = await probeAgentProcess('klanker', {
1140
+ dockerMode: true,
1141
+ dockerProbeImpl: () => ({
1142
+ status: 'fail',
1143
+ label: 'Agent',
1144
+ detail: 'claude process not found',
1145
+ nextStep: 'No claude process in container — check container logs with `docker logs <container>` and restart with `switchroom agent restart <agent>`',
1146
+ }),
1147
+ })
1148
+ expect(r.status).toBe('fail')
1149
+ expect(r.nextStep).toMatch(/docker logs/)
1150
+ expect(r.nextStep).toMatch(/restart/)
1151
+ })
1152
+ })
1153
+
1154
+ describe('nextStep — quota / hindsight / broker / kernel / scheduler', () => {
1155
+ it('quota: no OAuth token → degraded with RFC-H add+use hint', async () => {
1156
+ const dir = mkdtempSync(join(tmpdir(), 'quota-nextstep-'))
1157
+ const oldCachePath = process.env.SWITCHROOM_QUOTA_CACHE_PATH
1158
+ process.env.SWITCHROOM_QUOTA_CACHE_PATH = join(dir, 'cache.json')
1159
+ try {
1160
+ const r = await probeQuota(dir, dir, (async () => new Response('{}')) as unknown as typeof fetch)
1161
+ expect(r.status).toBe('degraded')
1162
+ // Post-RFC-H: the no-token nextStep points at `auth add` (register a
1163
+ // fleet account) + `auth use` (set fleet active), not the retired
1164
+ // per-agent `auth login`. See telegram-plugin/gateway/boot-probes.ts.
1165
+ expect(r.nextStep).toMatch(/switchroom auth add .*--from-oauth/)
1166
+ expect(r.nextStep).toMatch(/switchroom auth use/)
1167
+ } finally {
1168
+ if (oldCachePath) process.env.SWITCHROOM_QUOTA_CACHE_PATH = oldCachePath
1169
+ else delete process.env.SWITCHROOM_QUOTA_CACHE_PATH
1170
+ rmSync(dir, { recursive: true, force: true })
1171
+ }
1172
+ })
1173
+
1174
+ it('broker: socket missing → fail with docker compose hint', async () => {
1175
+ const r = await probeBroker('/nonexistent/sock', { dockerMode: true })
1176
+ expect(r.status).toBe('fail')
1177
+ expect(r.nextStep).toMatch(/docker compose/)
1178
+ expect(r.nextStep).toMatch(/vault-broker/)
1179
+ })
1180
+
1181
+ it('kernel: socket missing → fail with docker compose hint', async () => {
1182
+ const r = await probeKernel('/nonexistent/sock', { dockerMode: true })
1183
+ expect(r.status).toBe('fail')
1184
+ expect(r.nextStep).toMatch(/docker compose/)
1185
+ expect(r.nextStep).toMatch(/approval-kernel/)
1186
+ })
1187
+
1188
+ it('scheduler: no lockfile → fail with restart hint (or settling hint inside fresh-boot window)', async () => {
1189
+ const fs: SchedulerFsImpl = { exists: () => false, readFile: () => '', mtimeMs: () => 0 }
1190
+ const r = await probeScheduler('klanker', {
1191
+ dockerMode: true,
1192
+ fs,
1193
+ lockPath: '/state/agent/scheduler.lock',
1194
+ jsonlPath: '/state/agent/scheduler.jsonl',
1195
+ now: () => Date.now(),
1196
+ containerBootTimeMs: null, // disable softening
1197
+ })
1198
+ expect(r.status).toBe('fail')
1199
+ expect(r.nextStep).toMatch(/restart/)
1200
+ })
1201
+
1202
+ it('scheduler: lockfile holder pid dead → degraded with re-check hint', async () => {
1203
+ const fs: SchedulerFsImpl = {
1204
+ exists: (p) => p === '/state/agent/scheduler.lock',
1205
+ readFile: () => '99999\n',
1206
+ mtimeMs: () => 0,
1207
+ }
1208
+ const r = await probeScheduler('klanker', {
1209
+ dockerMode: true,
1210
+ fs,
1211
+ lockPath: '/state/agent/scheduler.lock',
1212
+ jsonlPath: '/state/agent/scheduler.jsonl',
1213
+ isAlive: () => false,
1214
+ now: () => Date.now(),
1215
+ containerBootTimeMs: null,
1216
+ })
1217
+ expect(r.status).toBe('degraded')
1218
+ expect(r.nextStep).toMatch(/re-check/i)
1219
+ })
1220
+ })
1015
1221
  })
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Pin the three-invariant contract for `finalizeCallback`. Every other
3
+ * inline-keyboard callback handler in the gateway routes through this
4
+ * helper, so a regression here breaks the audit-wide button UX
5
+ * (#1150 + follow-ups).
6
+ *
7
+ * 1. Visible press feedback (answerCallbackQuery with text).
8
+ * 2. Keyboard collapses + status line appended (one atomic edit).
9
+ * 3. synthInbound fires AFTER the message edit lands, errors
10
+ * swallowed but logged.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import { finalizeCallback, type FinalizeCallbackContext } from '../inline-keyboard-callbacks.js'
15
+
16
+ interface Capture {
17
+ acks: Array<{ text?: string; show_alert?: boolean }>
18
+ edits: Array<{ text: string; opts: Record<string, unknown> }>
19
+ ackThrows?: Error
20
+ editThrows?: Error
21
+ }
22
+
23
+ function mkCtx(cap: Capture): FinalizeCallbackContext {
24
+ return {
25
+ answerCallbackQuery: async (opts) => {
26
+ cap.acks.push(opts ?? {})
27
+ if (cap.ackThrows) throw cap.ackThrows
28
+ return true
29
+ },
30
+ editMessageText: async (text, opts) => {
31
+ cap.edits.push({ text, opts: opts ?? {} })
32
+ if (cap.editThrows) throw cap.editThrows
33
+ return { message_id: 1 }
34
+ },
35
+ }
36
+ }
37
+
38
+ describe('finalizeCallback — three-invariant contract', () => {
39
+ it('invariant 1: acks the callback with the supplied toast text', async () => {
40
+ const cap: Capture = { acks: [], edits: [] }
41
+ await finalizeCallback(mkCtx(cap), {
42
+ ackText: 'Approved',
43
+ newText: 'Original prompt\n\n✓ Approved by @op',
44
+ })
45
+ expect(cap.acks).toHaveLength(1)
46
+ expect(cap.acks[0]?.text).toBe('Approved')
47
+ expect(cap.acks[0]?.show_alert).toBeUndefined()
48
+ })
49
+
50
+ it('invariant 1: alert=true renders as full modal (show_alert: true)', async () => {
51
+ const cap: Capture = { acks: [], edits: [] }
52
+ await finalizeCallback(mkCtx(cap), {
53
+ ackText: 'Vault grant revoked',
54
+ alert: true,
55
+ newText: '...',
56
+ })
57
+ expect(cap.acks[0]?.show_alert).toBe(true)
58
+ })
59
+
60
+ it('invariant 2: strips reply_markup AND edits the body in one atomic call', async () => {
61
+ const cap: Capture = { acks: [], edits: [] }
62
+ await finalizeCallback(mkCtx(cap), {
63
+ ackText: 'Approved',
64
+ newText: '✓ Approved\n\nGrant minted at 22:38 UTC',
65
+ parseMode: 'HTML',
66
+ })
67
+ expect(cap.edits).toHaveLength(1)
68
+ expect(cap.edits[0]?.text).toBe('✓ Approved\n\nGrant minted at 22:38 UTC')
69
+ expect(cap.edits[0]?.opts.reply_markup).toEqual({ inline_keyboard: [] })
70
+ expect(cap.edits[0]?.opts.parse_mode).toBe('HTML')
71
+ // link_preview_options disabled by default — keeps the edited
72
+ // status line from rendering a stale preview card.
73
+ expect(cap.edits[0]?.opts.link_preview_options).toEqual({ is_disabled: true })
74
+ })
75
+
76
+ it('invariant 2: omits parse_mode when not specified (plain text)', async () => {
77
+ const cap: Capture = { acks: [], edits: [] }
78
+ await finalizeCallback(mkCtx(cap), { ackText: 'ok', newText: 'plain' })
79
+ expect(cap.edits[0]?.opts.parse_mode).toBeUndefined()
80
+ })
81
+
82
+ it('invariant 3: synthInbound fires AFTER editMessageText resolves', async () => {
83
+ const order: string[] = []
84
+ const ctx: FinalizeCallbackContext = {
85
+ answerCallbackQuery: async () => { order.push('ack'); return true },
86
+ editMessageText: async () => {
87
+ order.push('edit-start')
88
+ await new Promise((r) => setTimeout(r, 5))
89
+ order.push('edit-end')
90
+ return { message_id: 1 }
91
+ },
92
+ }
93
+ await finalizeCallback(ctx, {
94
+ ackText: 'ok',
95
+ newText: '...',
96
+ synthInbound: () => { order.push('synth') },
97
+ })
98
+ // ack is fire-and-forget so its position is "no later than edit-start"
99
+ // but we don't pin its exact position. Pin that synth comes AFTER
100
+ // edit-end — that's the guarantee callers need.
101
+ const editEndIdx = order.indexOf('edit-end')
102
+ const synthIdx = order.indexOf('synth')
103
+ expect(editEndIdx).toBeGreaterThanOrEqual(0)
104
+ expect(synthIdx).toBeGreaterThan(editEndIdx)
105
+ })
106
+
107
+ it('invariant 3: async synthInbound is awaited', async () => {
108
+ let synthResolved = false
109
+ const cap: Capture = { acks: [], edits: [] }
110
+ await finalizeCallback(mkCtx(cap), {
111
+ ackText: 'ok',
112
+ newText: '...',
113
+ synthInbound: async () => {
114
+ await new Promise((r) => setTimeout(r, 5))
115
+ synthResolved = true
116
+ },
117
+ })
118
+ expect(synthResolved).toBe(true)
119
+ })
120
+
121
+ it('invariant 3: synthInbound errors are caught + logged, never propagated', async () => {
122
+ const logs: string[] = []
123
+ const cap: Capture = { acks: [], edits: [] }
124
+ await expect(
125
+ finalizeCallback(mkCtx(cap), {
126
+ ackText: 'ok',
127
+ newText: '...',
128
+ synthInbound: () => { throw new Error('inject_inbound IPC closed') },
129
+ log: (l) => logs.push(l),
130
+ }),
131
+ ).resolves.toBeUndefined()
132
+ expect(logs.some((l) => l.includes('inject_inbound IPC closed'))).toBe(true)
133
+ })
134
+
135
+ it('robustness: editMessageText failure does NOT block synthInbound', async () => {
136
+ // Operator deleted the card between tap and our edit — Telegram
137
+ // returns MESSAGE_TO_EDIT_NOT_FOUND. The model still needs to wake
138
+ // up: a stale/missing card is preferred to a stuck conversation.
139
+ const logs: string[] = []
140
+ let synthFired = false
141
+ const cap: Capture = {
142
+ acks: [],
143
+ edits: [],
144
+ editThrows: new Error('Bad Request: message to edit not found'),
145
+ }
146
+ await finalizeCallback(mkCtx(cap), {
147
+ ackText: 'Approved',
148
+ newText: '...',
149
+ synthInbound: () => { synthFired = true },
150
+ log: (l) => logs.push(l),
151
+ })
152
+ expect(synthFired).toBe(true)
153
+ expect(logs.some((l) => l.includes('message to edit not found'))).toBe(true)
154
+ })
155
+
156
+ it('robustness: answerCallbackQuery failure does NOT block edit or synth', async () => {
157
+ // Telegram rejects the ack (e.g. the callback_query is already
158
+ // older than the 60s timeout). The edit + synth still must proceed.
159
+ const logs: string[] = []
160
+ let synthFired = false
161
+ const cap: Capture = {
162
+ acks: [],
163
+ edits: [],
164
+ ackThrows: new Error('query is too old'),
165
+ }
166
+ await finalizeCallback(mkCtx(cap), {
167
+ ackText: 'Approved',
168
+ newText: 'edited body',
169
+ synthInbound: () => { synthFired = true },
170
+ log: (l) => logs.push(l),
171
+ })
172
+ expect(cap.edits).toHaveLength(1)
173
+ expect(cap.edits[0]?.text).toBe('edited body')
174
+ expect(synthFired).toBe(true)
175
+ // ack fire-and-forget — its catch fires asynchronously; give it a tick
176
+ // so the log assertion is stable across runs.
177
+ await new Promise((r) => setTimeout(r, 0))
178
+ expect(logs.some((l) => l.includes('query is too old'))).toBe(true)
179
+ })
180
+
181
+ it('synthInbound is optional — surfaces with no model in the loop just ack + edit', async () => {
182
+ const cap: Capture = { acks: [], edits: [] }
183
+ await finalizeCallback(mkCtx(cap), {
184
+ ackText: 'Dismissed',
185
+ newText: '✗ Dismissed',
186
+ })
187
+ expect(cap.acks).toHaveLength(1)
188
+ expect(cap.edits).toHaveLength(1)
189
+ })
190
+ })
@@ -88,6 +88,32 @@ describe('validateGatewayMessage', () => {
88
88
  it('rejects missing requestId', () => {
89
89
  expect(validateGatewayMessage({ type: 'permission', behavior: 'allow' })).toBe(false)
90
90
  })
91
+
92
+ // #1138: optional `rule` field on Always-allow broadcasts. Must be
93
+ // accepted when present + non-empty, accepted when absent, rejected
94
+ // when present-but-malformed (the bridge stashes the value verbatim
95
+ // into a Set<string> — empty strings or wrong types would poison
96
+ // the matcher).
97
+ it('accepts an optional rule when behavior is allow', () => {
98
+ expect(validateGatewayMessage({
99
+ type: 'permission', requestId: 'r', behavior: 'allow', rule: 'Edit',
100
+ })).toBe(true)
101
+ expect(validateGatewayMessage({
102
+ type: 'permission', requestId: 'r', behavior: 'allow', rule: 'Skill(mail)',
103
+ })).toBe(true)
104
+ })
105
+
106
+ it('rejects an empty-string rule', () => {
107
+ expect(validateGatewayMessage({
108
+ type: 'permission', requestId: 'r', behavior: 'allow', rule: '',
109
+ })).toBe(false)
110
+ })
111
+
112
+ it('rejects a non-string rule', () => {
113
+ expect(validateGatewayMessage({
114
+ type: 'permission', requestId: 'r', behavior: 'allow', rule: 42,
115
+ })).toBe(false)
116
+ })
91
117
  })
92
118
 
93
119
  describe('status', () => {
@@ -68,7 +68,13 @@ describe('gateway secret-detect intercept — structural wiring', () => {
68
68
  // Rewrites effectiveText so the broadcast carries the redacted text.
69
69
  expect(tail).toMatch(/effectiveText = pipeRes\.rewritten_text/)
70
70
  // Deletes the original Telegram message containing the raw bytes.
71
- expect(tail).toMatch(/bot\.api\.deleteMessage\(chat_id, msgId\)/)
71
+ // 2026-05-12: migrated from raw `bot.api.deleteMessage` to the
72
+ // `deleteSensitiveMessage` helper so failures surface to the
73
+ // operator via in-chat warning instead of being silently
74
+ // swallowed. See
75
+ // tests/secret-detect-delete-must-surface-failures.test.ts for
76
+ // the full contract pin.
77
+ expect(tail).toMatch(/deleteSensitiveMessage\(chat_id, msgId, 'detected secret'\)/)
72
78
  // Tells the user what was captured (masked).
73
79
  expect(tail).toMatch(/captured \$\{pipeRes\.stored\.length\} secret/)
74
80
  // Surfaces the masked form (s.masked is computed via maskToken in the pipeline).
@@ -92,8 +98,11 @@ describe('gateway secret-detect intercept — structural wiring', () => {
92
98
  expect(tail).toMatch(/deferredSecrets\.set\(/)
93
99
  expect(tail).toMatch(/suggested_slug:/)
94
100
  // 2. The original message is deleted (so the raw bytes are scrubbed
95
- // from the chat client even before the user reacts).
96
- expect(tail).toMatch(/bot\.api\.deleteMessage\(chat_id, msgId\)/)
101
+ // from the chat client even before the user reacts). 2026-05-12:
102
+ // migrated to deleteSensitiveMessage so failures surface to the
103
+ // operator via in-chat warning. See
104
+ // tests/secret-detect-delete-must-surface-failures.test.ts.
105
+ expect(tail).toMatch(/deleteSensitiveMessage\(chat_id, msgId, 'detected secret'\)/)
97
106
  // 4. The new inline keyboard helper is used in lieu of the legacy
98
107
  // plain-text "run /vault list" warning.
99
108
  expect(tail).toMatch(/buildDeferredSecretKeyboard\(/)