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,76 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { classifyInbound } from '../inbound-classifier.js'
3
+
4
+ describe('inbound-classifier — status query', () => {
5
+ describe('positive matches (status_query=true)', () => {
6
+ const positives = [
7
+ '?',
8
+ '??',
9
+ '???',
10
+ 'status',
11
+ 'Status',
12
+ 'STATUS',
13
+ 'status?',
14
+ 'status ?',
15
+ 'update',
16
+ 'update?',
17
+ 'any update',
18
+ 'any update?',
19
+ 'still there',
20
+ 'still there?',
21
+ 'Still There?',
22
+ 'still working',
23
+ 'still working?',
24
+ 'are you there',
25
+ 'are you there?',
26
+ 'you there',
27
+ 'you there?',
28
+ 'hello?',
29
+ 'Hello??',
30
+ 'hey?',
31
+ // surrounding whitespace
32
+ ' status? ',
33
+ '\nstill there?\n',
34
+ ]
35
+ for (const text of positives) {
36
+ it(`matches: ${JSON.stringify(text)}`, () => {
37
+ expect(classifyInbound(text).isStatusQuery).toBe(true)
38
+ })
39
+ }
40
+ })
41
+
42
+ describe('negative matches (status_query=false)', () => {
43
+ const negatives = [
44
+ '',
45
+ ' ',
46
+ 'hello',
47
+ 'hi',
48
+ 'what is the status of the deploy',
49
+ 'status of the deploy?',
50
+ 'are you there with the report',
51
+ 'what update did you see',
52
+ 'i need an update on the metrics',
53
+ // Plausible but rejected — message too long to be a standalone ping
54
+ 'status? also can you check the deployment script for the lint errors please',
55
+ // Punctuation-shaped but not a query
56
+ '.',
57
+ '!',
58
+ '!?',
59
+ ]
60
+ for (const text of negatives) {
61
+ it(`does not match: ${JSON.stringify(text)}`, () => {
62
+ expect(classifyInbound(text).isStatusQuery).toBe(false)
63
+ })
64
+ }
65
+ })
66
+
67
+ it('handles null/undefined safely', () => {
68
+ expect(classifyInbound(null).isStatusQuery).toBe(false)
69
+ expect(classifyInbound(undefined).isStatusQuery).toBe(false)
70
+ })
71
+
72
+ it('does not match messages over 40 chars even if they start with a status word', () => {
73
+ const longPretendStatusQuery = 'status? but actually i wanted to ask about deploys'
74
+ expect(classifyInbound(longPretendStatusQuery).isStatusQuery).toBe(false)
75
+ })
76
+ })
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Structural tests for the 13 previously-silent inbound message types
3
+ * registered on `bot.on('message:<type>')` in the gateway (#1077).
4
+ *
5
+ * Why structural: gateway/gateway.ts wires every handler inline against
6
+ * the live `bot` instance — none of these closures are exported, so a
7
+ * functional invocation would require booting the full grammy runtime
8
+ * against a real or mocked Bot API. The existing gateway test suite
9
+ * settled on file-level grep assertions (see
10
+ * `gateway-secret-detect.test.ts`) for exactly this reason: cheap,
11
+ * deterministic, and they catch the regression we actually care about
12
+ * — a future hand mistakenly deleting a handler or skipping the
13
+ * gate/ack call that gives the user feedback.
14
+ *
15
+ * Decision matrix being enforced here (issue #1077):
16
+ *
17
+ * forward → contact, location, venue, poll, web_app_data,
18
+ * users_shared, chat_shared
19
+ * ack-only → dice, game, story, paid_media, successful_payment
20
+ * refuse (DENY) → passport_data
21
+ *
22
+ * The contract per-type:
23
+ * - A `bot.on('message:<type>', …)` registration exists.
24
+ * - Forwarding handlers call `handleInbound(`.
25
+ * - Ack-only handlers call `handleAckOnly(`.
26
+ * - The refusal handler calls `handleRefusal(` and does NOT call
27
+ * `handleInbound(` (passport data must never reach the agent).
28
+ * - Every handler logs a stderr line so operators can see the
29
+ * event landed.
30
+ * - Every handler is wrapped in try/catch (or delegates to a helper
31
+ * that is) so a malformed payload cannot tear down the dispatcher.
32
+ */
33
+
34
+ import { describe, it, expect } from 'vitest'
35
+ import { readFileSync } from 'node:fs'
36
+
37
+ const SRC = readFileSync(
38
+ new URL('../gateway/gateway.ts', import.meta.url),
39
+ 'utf8',
40
+ )
41
+
42
+ // ─── Helpers ─────────────────────────────────────────────────────────────
43
+
44
+ /**
45
+ * Extract the body of a `bot.on('message:<kind>', …)` handler. Returns
46
+ * the substring from the `bot.on(` line up to the matching closing
47
+ * `})` at the outer scope. Good enough for grepping — not a full
48
+ * AST parse.
49
+ */
50
+ function handlerBody(kind: string): string {
51
+ const needle = `bot.on('message:${kind}'`
52
+ const start = SRC.indexOf(needle)
53
+ expect(start, `handler bot.on('message:${kind}') not found`).toBeGreaterThan(0)
54
+ // Find the matching close — naive depth count of {/} from the first `{`.
55
+ const firstBrace = SRC.indexOf('{', start)
56
+ let depth = 0
57
+ for (let i = firstBrace; i < SRC.length; i++) {
58
+ const c = SRC[i]
59
+ if (c === '{') depth++
60
+ else if (c === '}') {
61
+ depth--
62
+ if (depth === 0) return SRC.slice(start, i + 1)
63
+ }
64
+ }
65
+ throw new Error(`could not find end of handler ${kind}`)
66
+ }
67
+
68
+ // ─── Registration completeness ───────────────────────────────────────────
69
+
70
+ const ALL_KINDS = [
71
+ 'contact',
72
+ 'location',
73
+ 'venue',
74
+ 'dice',
75
+ 'poll',
76
+ 'game',
77
+ 'story',
78
+ 'paid_media',
79
+ 'successful_payment',
80
+ 'passport_data',
81
+ 'web_app_data',
82
+ 'users_shared',
83
+ 'chat_shared',
84
+ ] as const
85
+
86
+ describe('inbound message-type handlers: registration', () => {
87
+ for (const kind of ALL_KINDS) {
88
+ it(`registers a bot.on('message:${kind}') handler`, () => {
89
+ expect(SRC).toContain(`bot.on('message:${kind}'`)
90
+ })
91
+ }
92
+ })
93
+
94
+ // ─── Forwarding handlers ─────────────────────────────────────────────────
95
+
96
+ const FORWARDING = [
97
+ 'contact',
98
+ 'location',
99
+ 'venue',
100
+ 'poll',
101
+ 'web_app_data',
102
+ 'users_shared',
103
+ 'chat_shared',
104
+ ] as const
105
+
106
+ describe('inbound message-type handlers: forwarding decisions', () => {
107
+ for (const kind of FORWARDING) {
108
+ it(`${kind} forwards via handleInbound and logs to stderr`, () => {
109
+ const body = handlerBody(kind)
110
+ expect(body).toMatch(/handleInbound\(ctx,/)
111
+ expect(body).toMatch(/process\.stderr\.write/)
112
+ // Must not divert to the ack-only path — that would silently
113
+ // hide the payload from the agent.
114
+ expect(body).not.toMatch(/handleAckOnly\(/)
115
+ expect(body).not.toMatch(/handleRefusal\(/)
116
+ })
117
+ }
118
+
119
+ it('forwarding envelopes describe the payload kind in the text', () => {
120
+ // Each handler builds a `(<kind>: …)` text envelope so the agent
121
+ // sees what category of payload arrived without having to decode
122
+ // the meta block.
123
+ expect(handlerBody('contact')).toContain('(contact:')
124
+ expect(handlerBody('location')).toContain('(location:')
125
+ expect(handlerBody('venue')).toContain('(venue:')
126
+ expect(handlerBody('poll')).toContain('(poll:')
127
+ expect(handlerBody('web_app_data')).toContain('(web_app_data:')
128
+ expect(handlerBody('users_shared')).toContain('(users_shared:')
129
+ expect(handlerBody('chat_shared')).toContain('(chat_shared:')
130
+ })
131
+
132
+ it('web_app_data caps untrusted payload length before forwarding', () => {
133
+ // web_app_data.data is arbitrary mini-app output — a malicious
134
+ // mini-app could otherwise flood the agent. Same defence as #553
135
+ // applied to text coalescing.
136
+ const body = handlerBody('web_app_data')
137
+ expect(body).toMatch(/slice\(0,\s*4096\)/)
138
+ expect(body).toMatch(/truncated/)
139
+ })
140
+ })
141
+
142
+ // ─── Ack-only handlers ───────────────────────────────────────────────────
143
+
144
+ const ACK_ONLY = ['dice', 'game', 'story', 'paid_media', 'successful_payment'] as const
145
+
146
+ describe('inbound message-type handlers: ack-only decisions', () => {
147
+ for (const kind of ACK_ONLY) {
148
+ it(`${kind} uses handleAckOnly (no forward to agent)`, () => {
149
+ const body = handlerBody(kind)
150
+ expect(body).toMatch(/handleAckOnly\(/)
151
+ expect(body).toMatch(/process\.stderr\.write/)
152
+ // Ack-only must NOT call handleInbound — the whole point is
153
+ // we don't bother the agent for these.
154
+ expect(body).not.toMatch(/handleInbound\(/)
155
+ // Nor should it pretend to refuse.
156
+ expect(body).not.toMatch(/handleRefusal\(/)
157
+ })
158
+ }
159
+
160
+ it('dice uses a 🎲 reaction for situational feedback', () => {
161
+ expect(handlerBody('dice')).toContain("emoji: '🎲'")
162
+ })
163
+
164
+ it('paid_media and successful_payment are marked warn:true', () => {
165
+ expect(handlerBody('paid_media')).toMatch(/warn:\s*true/)
166
+ expect(handlerBody('successful_payment')).toMatch(/warn:\s*true/)
167
+ })
168
+
169
+ it('successful_payment logs the structured payment fields', () => {
170
+ // Money-flow events need a reconciliation-friendly stderr line.
171
+ const body = handlerBody('successful_payment')
172
+ expect(body).toContain('currency=')
173
+ expect(body).toContain('total_amount=')
174
+ expect(body).toContain('telegram_charge=')
175
+ })
176
+ })
177
+
178
+ // ─── Refusal handler ─────────────────────────────────────────────────────
179
+
180
+ describe('inbound message-type handlers: passport_data refusal', () => {
181
+ it('passport_data uses handleRefusal and NEVER calls handleInbound', () => {
182
+ const body = handlerBody('passport_data')
183
+ expect(body).toMatch(/handleRefusal\(/)
184
+ // Critical: passport data is regulated identity material. Even a
185
+ // diagnostic forward path would leak it onto the agent's wire.
186
+ expect(body).not.toMatch(/handleInbound\(/)
187
+ expect(body).not.toMatch(/ipcServer\.broadcast/)
188
+ })
189
+
190
+ it('passport_data refusal text mentions Telegram Passport', () => {
191
+ // The user gets a polite explanation so they don't think the
192
+ // message was simply dropped on the floor.
193
+ const body = handlerBody('passport_data')
194
+ expect(body).toMatch(/Telegram Passport/)
195
+ })
196
+ })
197
+
198
+ // ─── Shared helper invariants ────────────────────────────────────────────
199
+
200
+ describe('inbound message-type helpers: handleAckOnly + handleRefusal', () => {
201
+ it('handleAckOnly is declared and gates before reacting', () => {
202
+ // The function must consult gate() so non-allowlisted senders
203
+ // don't get a reaction (which would confirm the bot exists to
204
+ // a stranger).
205
+ expect(SRC).toMatch(/async function handleAckOnly\(/)
206
+ const fnStart = SRC.indexOf('async function handleAckOnly(')
207
+ const fnSlice = SRC.slice(fnStart, fnStart + 2000)
208
+ expect(fnSlice).toContain('gate(ctx)')
209
+ expect(fnSlice).toContain('setMessageReaction')
210
+ })
211
+
212
+ it('handleAckOnly drops non-allowlisted senders silently', () => {
213
+ const fnStart = SRC.indexOf('async function handleAckOnly(')
214
+ const fnSlice = SRC.slice(fnStart, fnStart + 2000)
215
+ expect(fnSlice).toMatch(/action === 'drop'/)
216
+ })
217
+
218
+ it('handleAckOnly is wrapped in try/catch', () => {
219
+ const fnStart = SRC.indexOf('async function handleAckOnly(')
220
+ const fnSlice = SRC.slice(fnStart, fnStart + 2000)
221
+ expect(fnSlice).toMatch(/try\s*\{/)
222
+ expect(fnSlice).toMatch(/catch\s*\(/)
223
+ })
224
+
225
+ it('handleRefusal is declared and sends a reply via sendMessage', () => {
226
+ expect(SRC).toMatch(/async function handleRefusal\(/)
227
+ const fnStart = SRC.indexOf('async function handleRefusal(')
228
+ const fnSlice = SRC.slice(fnStart, fnStart + 2000)
229
+ expect(fnSlice).toContain('gate(ctx)')
230
+ expect(fnSlice).toContain('sendMessage')
231
+ // SECURITY-tagged log line so operators see the refusal in
232
+ // their stderr scrape.
233
+ expect(fnSlice).toMatch(/SECURITY/)
234
+ })
235
+
236
+ it('handleRefusal sits behind the gate (no leak to strangers)', () => {
237
+ // Mirrors handleAckOnly — we don't even confirm the bot exists
238
+ // to a sender who isn't allowlisted.
239
+ const fnStart = SRC.indexOf('async function handleRefusal(')
240
+ const fnSlice = SRC.slice(fnStart, fnStart + 2000)
241
+ expect(fnSlice).toMatch(/action === 'drop'/)
242
+ })
243
+ })
244
+
245
+ // ─── Decision-matrix completeness ────────────────────────────────────────
246
+
247
+ describe('inbound message-type decisions cover all 13 types from #1077', () => {
248
+ it('every kind in the matrix has exactly one decision', () => {
249
+ const forwardSet = new Set<string>(FORWARDING)
250
+ const ackSet = new Set<string>(ACK_ONLY)
251
+ const refusalSet = new Set<string>(['passport_data'])
252
+ for (const kind of ALL_KINDS) {
253
+ const inForward = forwardSet.has(kind)
254
+ const inAck = ackSet.has(kind)
255
+ const inRefusal = refusalSet.has(kind)
256
+ const count = Number(inForward) + Number(inAck) + Number(inRefusal)
257
+ expect(count, `${kind} should appear in exactly one bucket, got ${count}`).toBe(1)
258
+ }
259
+ })
260
+
261
+ it('matrix totals: 7 forward, 5 ack-only, 1 refuse', () => {
262
+ expect(FORWARDING.length).toBe(7)
263
+ expect(ACK_ONLY.length).toBe(5)
264
+ // All 13 covered, no extras dropped.
265
+ expect(FORWARDING.length + ACK_ONLY.length + 1).toBe(ALL_KINDS.length)
266
+ })
267
+ })
@@ -493,3 +493,52 @@ describe("createIssuesCardHandle — persistence (#472 #19)", () => {
493
493
  }
494
494
  });
495
495
  });
496
+
497
+ // ─── #1075 — THREAD_NOT_FOUND propagation ───────────────────────────────────
498
+ //
499
+ // In production the gateway wires the issues-card's `bot` adapter through
500
+ // `robustApiCall`, which throws a wrapped error with `message ==
501
+ // 'THREAD_NOT_FOUND'` when the underlying GrammyError matches "thread not
502
+ // found". The card module's job is to NOT crash — it should log and
503
+ // re-post on the next refresh (the same path it uses for "message not
504
+ // found" stale-edit recovery). Both shapes are tested here.
505
+
506
+ describe("createIssuesCardHandle — #1075 thread-deleted resilience", () => {
507
+ it("re-posts the card when an edit throws THREAD_NOT_FOUND", async () => {
508
+ const bot = makeFakeBot();
509
+ const handle = createIssuesCardHandle({
510
+ agentName: "klanker",
511
+ chatId: "1",
512
+ bot,
513
+ now: () => 1_000_000,
514
+ });
515
+ await handle.refresh([makeEvent({ summary: "first" })]);
516
+ expect(bot.sent).toHaveLength(1);
517
+
518
+ // Mid-flight, the topic gets deleted. The wrapped adapter throws
519
+ // the special THREAD_NOT_FOUND error on the next edit.
520
+ bot.editMessageText = async () => {
521
+ throw Object.assign(new Error("THREAD_NOT_FOUND"), { original: null });
522
+ };
523
+ await handle.refresh([makeEvent({ summary: "after thread delete" })]);
524
+ // Card was forced to re-post (sent twice).
525
+ expect(bot.sent).toHaveLength(2);
526
+ // And it should NOT have crashed — handle still operates.
527
+ expect(handle.messageId()).not.toBeNull();
528
+ });
529
+
530
+ it("does not crash when sendMessage itself throws THREAD_NOT_FOUND", async () => {
531
+ const bot = makeFakeBot();
532
+ bot.sendMessage = async () => {
533
+ throw Object.assign(new Error("THREAD_NOT_FOUND"), { original: null });
534
+ };
535
+ const handle = createIssuesCardHandle({
536
+ agentName: "klanker",
537
+ chatId: "1",
538
+ bot,
539
+ });
540
+ // First refresh — should swallow the THREAD_NOT_FOUND, no crash.
541
+ await expect(handle.refresh([makeEvent({})])).resolves.toBeUndefined();
542
+ expect(handle.messageId()).toBeNull();
543
+ });
544
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Pin the per-agent inbound buffer that closes the #1150 root cause:
3
+ * if the gateway tries to deliver a synthetic inbound while the agent's
4
+ * bridge isn't connected (mid-reconnect, claude-session bouncing, etc),
5
+ * the inbound used to be silently dropped. Now it's buffered and
6
+ * drained on the next bridge-register.
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest'
10
+ import { createPendingInboundBuffer, DEFAULT_PENDING_INBOUND_CAP } from '../gateway/pending-inbound-buffer.js'
11
+ import type { InboundMessage } from '../gateway/ipc-protocol.js'
12
+
13
+ function inbound(source: string, ts = Date.now()): InboundMessage {
14
+ return {
15
+ type: 'inbound',
16
+ chatId: 'c1',
17
+ messageId: ts,
18
+ user: 'vault-broker',
19
+ userId: 0,
20
+ ts,
21
+ text: `synthetic ${source}`,
22
+ meta: { source },
23
+ }
24
+ }
25
+
26
+ describe('pending-inbound-buffer', () => {
27
+ it('push + drain — FIFO order per agent', () => {
28
+ const buf = createPendingInboundBuffer({ log: () => {} })
29
+ buf.push('a', inbound('vault_grant_approved', 1))
30
+ buf.push('a', inbound('cron', 2))
31
+ buf.push('a', inbound('reaction', 3))
32
+ const drained = buf.drain('a')
33
+ expect(drained.map((m) => m.meta?.source)).toEqual([
34
+ 'vault_grant_approved',
35
+ 'cron',
36
+ 'reaction',
37
+ ])
38
+ })
39
+
40
+ it('drain is idempotent — second call returns empty', () => {
41
+ const buf = createPendingInboundBuffer({ log: () => {} })
42
+ buf.push('a', inbound('x'))
43
+ expect(buf.drain('a')).toHaveLength(1)
44
+ expect(buf.drain('a')).toHaveLength(0)
45
+ })
46
+
47
+ it('drain only affects the named agent', () => {
48
+ const buf = createPendingInboundBuffer({ log: () => {} })
49
+ buf.push('a', inbound('x'))
50
+ buf.push('b', inbound('y'))
51
+ expect(buf.drain('a').map((m) => m.meta?.source)).toEqual(['x'])
52
+ expect(buf.depth('b')).toBe(1)
53
+ expect(buf.drain('b').map((m) => m.meta?.source)).toEqual(['y'])
54
+ })
55
+
56
+ it('respects per-agent cap — oldest evicted when full', () => {
57
+ const buf = createPendingInboundBuffer({ capPerAgent: 3, log: () => {} })
58
+ // Push 1 .. 5; cap is 3 so 1, 2 should be evicted.
59
+ buf.push('a', inbound('m1', 1))
60
+ buf.push('a', inbound('m2', 2))
61
+ buf.push('a', inbound('m3', 3))
62
+ buf.push('a', inbound('m4', 4))
63
+ buf.push('a', inbound('m5', 5))
64
+ expect(buf.depth('a')).toBe(3)
65
+ const drained = buf.drain('a')
66
+ expect(drained.map((m) => m.meta?.source)).toEqual(['m3', 'm4', 'm5'])
67
+ })
68
+
69
+ it('push returns false when eviction occurred', () => {
70
+ const buf = createPendingInboundBuffer({ capPerAgent: 2, log: () => {} })
71
+ expect(buf.push('a', inbound('m1'))).toBe(true)
72
+ expect(buf.push('a', inbound('m2'))).toBe(true)
73
+ expect(buf.push('a', inbound('m3'))).toBe(false) // evicted m1
74
+ })
75
+
76
+ it('default cap is 32', () => {
77
+ expect(DEFAULT_PENDING_INBOUND_CAP).toBe(32)
78
+ const buf = createPendingInboundBuffer({ log: () => {} })
79
+ for (let i = 0; i < 32; i++) buf.push('a', inbound(`m${i}`, i))
80
+ expect(buf.depth('a')).toBe(32)
81
+ buf.push('a', inbound('m33', 33))
82
+ expect(buf.depth('a')).toBe(32) // still at cap
83
+ })
84
+
85
+ it('logs on eviction', () => {
86
+ const logs: string[] = []
87
+ const buf = createPendingInboundBuffer({ capPerAgent: 1, log: (l) => logs.push(l) })
88
+ buf.push('a', inbound('m1', 1))
89
+ buf.push('a', inbound('m2', 2)) // evicts m1
90
+ expect(logs.some((l) => l.includes('cap=1') && l.includes('dropped oldest'))).toBe(true)
91
+ expect(logs.some((l) => l.includes('m1'))).toBe(true)
92
+ })
93
+
94
+ it('logs on push (depth tracking visibility)', () => {
95
+ const logs: string[] = []
96
+ const buf = createPendingInboundBuffer({ log: (l) => logs.push(l) })
97
+ buf.push('a', inbound('vault_grant_approved'))
98
+ expect(logs.some((l) => l.includes('agent=a buffered source=vault_grant_approved depth_after=1'))).toBe(true)
99
+ })
100
+
101
+ it('logs on drain with source listing', () => {
102
+ const logs: string[] = []
103
+ const buf = createPendingInboundBuffer({ log: (l) => logs.push(l) })
104
+ buf.push('a', inbound('vault_grant_approved'))
105
+ buf.push('a', inbound('cron'))
106
+ logs.length = 0
107
+ buf.drain('a')
108
+ expect(logs.some((l) => l.includes('drained agent=a count=2'))).toBe(true)
109
+ expect(logs.some((l) => l.includes('sources=[vault_grant_approved,cron]'))).toBe(true)
110
+ })
111
+
112
+ it('drain on empty agent does not log', () => {
113
+ const logs: string[] = []
114
+ const buf = createPendingInboundBuffer({ log: (l) => logs.push(l) })
115
+ expect(buf.drain('never-pushed')).toEqual([])
116
+ expect(logs).toEqual([])
117
+ })
118
+
119
+ it('depth and totalDepth track correctly across agents', () => {
120
+ const buf = createPendingInboundBuffer({ log: () => {} })
121
+ expect(buf.totalDepth()).toBe(0)
122
+ buf.push('a', inbound('x'))
123
+ buf.push('a', inbound('y'))
124
+ buf.push('b', inbound('z'))
125
+ expect(buf.depth('a')).toBe(2)
126
+ expect(buf.depth('b')).toBe(1)
127
+ expect(buf.depth('c')).toBe(0)
128
+ expect(buf.totalDepth()).toBe(3)
129
+ buf.drain('a')
130
+ expect(buf.totalDepth()).toBe(1)
131
+ })
132
+ })
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { describe, it, expect } from 'vitest'
15
- import { resolveAlwaysAllowRule } from '../permission-rule.js'
15
+ import { resolveAlwaysAllowRule, matchesAllowRule } from '../permission-rule.js'
16
16
 
17
17
  describe('resolveAlwaysAllowRule — Skill', () => {
18
18
  it('returns Skill(name) for a typical skill input', () => {
@@ -119,3 +119,82 @@ describe('resolveAlwaysAllowRule — fallback', () => {
119
119
  expect(resolveAlwaysAllowRule('', undefined)).toBeNull()
120
120
  })
121
121
  })
122
+
123
+ describe('matchesAllowRule — bare tool names', () => {
124
+ // The whole point of #1138: a cached `Edit` rule covers every Edit
125
+ // call from the parent claude AND from sub-agents dispatched via the
126
+ // Task tool, no matter the file path.
127
+ it('matches any invocation of the same tool', () => {
128
+ expect(matchesAllowRule('Edit', 'Edit', undefined)).toBe(true)
129
+ expect(matchesAllowRule('Edit', 'Edit', JSON.stringify({ file_path: '/tmp/a' }))).toBe(true)
130
+ expect(matchesAllowRule('Edit', 'Edit', JSON.stringify({ file_path: '/etc/passwd' }))).toBe(true)
131
+ })
132
+
133
+ it('does not bleed into other tools', () => {
134
+ expect(matchesAllowRule('Edit', 'Write', undefined)).toBe(false)
135
+ expect(matchesAllowRule('Read', 'Edit', undefined)).toBe(false)
136
+ expect(matchesAllowRule('Bash', 'BashOutput', undefined)).toBe(false)
137
+ })
138
+
139
+ it.each(['Bash', 'Read', 'Write', 'MultiEdit', 'Glob', 'Grep', 'WebFetch', 'TodoWrite'])(
140
+ 'roundtrips through resolve → match for %s',
141
+ (tool) => {
142
+ const resolved = resolveAlwaysAllowRule(tool, undefined)
143
+ expect(resolved).not.toBeNull()
144
+ expect(matchesAllowRule(resolved!.rule, tool, undefined)).toBe(true)
145
+ },
146
+ )
147
+ })
148
+
149
+ describe('matchesAllowRule — Skill(name)', () => {
150
+ it('matches only the specific skill', () => {
151
+ expect(matchesAllowRule('Skill(mail)', 'Skill', JSON.stringify({ skill: 'mail' }))).toBe(true)
152
+ expect(matchesAllowRule('Skill(mail)', 'Skill', JSON.stringify({ skill: 'calendar' }))).toBe(false)
153
+ })
154
+
155
+ it('uses the same field fallback chain as the resolver', () => {
156
+ expect(matchesAllowRule('Skill(mail)', 'Skill', JSON.stringify({ skill_name: 'mail' }))).toBe(true)
157
+ expect(matchesAllowRule('Skill(mail)', 'Skill', JSON.stringify({ skillName: 'mail' }))).toBe(true)
158
+ expect(matchesAllowRule('Skill(mail)', 'Skill', JSON.stringify({ name: 'mail' }))).toBe(true)
159
+ expect(matchesAllowRule(
160
+ 'Skill(coolify)',
161
+ 'Skill',
162
+ JSON.stringify({ path: 'skills/coolify/SKILL.md' }),
163
+ )).toBe(true)
164
+ })
165
+
166
+ it('does not match a different tool with the same arg', () => {
167
+ expect(matchesAllowRule('Skill(mail)', 'Bash', JSON.stringify({ skill: 'mail' }))).toBe(false)
168
+ })
169
+
170
+ it('returns false on malformed Skill input', () => {
171
+ expect(matchesAllowRule('Skill(mail)', 'Skill', undefined)).toBe(false)
172
+ expect(matchesAllowRule('Skill(mail)', 'Skill', 'not-json')).toBe(false)
173
+ expect(matchesAllowRule('Skill(mail)', 'Skill', JSON.stringify({ unrelated: 'x' }))).toBe(false)
174
+ })
175
+ })
176
+
177
+ describe('matchesAllowRule — MCP tools', () => {
178
+ it('matches the exact namespaced tool', () => {
179
+ expect(matchesAllowRule(
180
+ 'mcp__switchroom-telegram__reply',
181
+ 'mcp__switchroom-telegram__reply',
182
+ undefined,
183
+ )).toBe(true)
184
+ })
185
+
186
+ it('does not match a different MCP tool on the same server', () => {
187
+ expect(matchesAllowRule(
188
+ 'mcp__switchroom-telegram__reply',
189
+ 'mcp__switchroom-telegram__stream_reply',
190
+ undefined,
191
+ )).toBe(false)
192
+ })
193
+ })
194
+
195
+ describe('matchesAllowRule — defensive', () => {
196
+ it('returns false for empty inputs', () => {
197
+ expect(matchesAllowRule('', 'Edit', undefined)).toBe(false)
198
+ expect(matchesAllowRule('Edit', '', undefined)).toBe(false)
199
+ })
200
+ })
@@ -103,4 +103,35 @@ describe('summarizeToolForTitle (#186)', () => {
103
103
  const input = JSON.stringify({ skill: 'mail', name: 'wrong' })
104
104
  expect(summarizeToolForTitle('Skill', input)).toBe('Skill (mail)')
105
105
  })
106
+
107
+ test('MCP curated: agent-config tools render as human verb-phrases (#1215)', () => {
108
+ expect(summarizeToolForTitle('mcp__agent-config__skill_list', undefined)).toBe(
109
+ 'List its own installed skills',
110
+ )
111
+ expect(summarizeToolForTitle('mcp__agent-config__cron_list', undefined)).toBe(
112
+ 'List its own scheduled tasks',
113
+ )
114
+ expect(summarizeToolForTitle('mcp__agent-config__peers_list', undefined)).toBe(
115
+ 'List the other agents on this instance',
116
+ )
117
+ })
118
+
119
+ test('MCP curated: hostd tools render as human verb-phrases (#1215)', () => {
120
+ expect(summarizeToolForTitle('mcp__hostd__agent_logs', undefined)).toBe(
121
+ "Read another agent's container logs",
122
+ )
123
+ expect(summarizeToolForTitle('mcp__hostd__agent_exec', undefined)).toBe(
124
+ 'Run a read-only inspection inside another agent',
125
+ )
126
+ })
127
+
128
+ test('MCP fallback: unknown mcp tool renders as `<server>: <verb with spaces>`', () => {
129
+ expect(summarizeToolForTitle('mcp__some-server__do_thing', undefined)).toBe(
130
+ 'some-server: do thing',
131
+ )
132
+ })
133
+
134
+ test('MCP malformed: bare mcp__ prefix without __<server>__<verb> shape is left alone', () => {
135
+ expect(summarizeToolForTitle('mcp__bad', undefined)).toBe('mcp__bad')
136
+ })
106
137
  })