switchroom 0.18.32 → 0.19.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 (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -32,6 +32,8 @@ import {
32
32
  type CallbackQueryHandlersDeps,
33
33
  type PendingVaultRequestAccess,
34
34
  type PendingVaultRequestSave,
35
+ type PendingSecretRequest,
36
+ type ArmedSecretCapture,
35
37
  type PendingMentalModelPropose,
36
38
  type DeferredSecret,
37
39
  type PendingVaultOp,
@@ -200,6 +202,12 @@ function makeDeps(overrides: Partial<CallbackQueryHandlersDeps> = {}) {
200
202
  expire: () => () => {},
201
203
  log: () => () => {},
202
204
  }),
205
+ pendingSecretRequests: createSweepableCardStore<PendingSecretRequest>({
206
+ isExpired: () => false,
207
+ expire: () => () => {},
208
+ log: () => () => {},
209
+ }),
210
+ armedSecretCaptures: createSweepableStore<ArmedSecretCapture>(() => false),
203
211
  pendingMentalModelProposes: createSweepableCardStore<PendingMentalModelPropose>({
204
212
  isExpired: () => false,
205
213
  expire: () => () => {},
@@ -455,6 +463,98 @@ describe('handleVaultDeferCallback', () => {
455
463
  })
456
464
  })
457
465
 
466
+ // ── vsp:* — request_secret (agent asks operator to PROVIDE a secret) ─────
467
+
468
+ function stagedSecretRequest(over: Partial<PendingSecretRequest> = {}): PendingSecretRequest {
469
+ return {
470
+ agent: 'worker',
471
+ chat_id: '111',
472
+ card_message_id: 42,
473
+ key: 'openai/api_key',
474
+ reason: 'need it to call the API',
475
+ staged_at: Date.now(),
476
+ ...over,
477
+ }
478
+ }
479
+
480
+ describe('handleSecretRequestCallback', () => {
481
+ it('refuses a sender not on allowFrom (never touches state)', async () => {
482
+ const { deps } = makeDeps()
483
+ deps.pendingSecretRequests.set('s1', stagedSecretRequest())
484
+ const h = createCallbackQueryHandlers(deps)
485
+ const { ctx, raw } = makeCtx({ senderId: '999' })
486
+ await h.handleSecretRequestCallback(ctx, 'vsp:provide:s1')
487
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
488
+ // Auth gate runs before any capture is armed.
489
+ expect(deps.armedSecretCaptures.has('111')).toBe(false)
490
+ })
491
+
492
+ // Race pin (#2996 P5 mandate — verdict-during-expiry): a tap that lands after
493
+ // the card's TTL sweep already dropped the pending request must degrade to
494
+ // the expired toast, NOT arm a capture or wake the agent.
495
+ it('a tap on an already-expired/unknown stage toasts "This request expired." and changes nothing', async () => {
496
+ const { deps, injected } = makeDeps()
497
+ const h = createCallbackQueryHandlers(deps)
498
+ const { ctx, raw } = makeCtx()
499
+ await h.handleSecretRequestCallback(ctx, 'vsp:provide:gone')
500
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'This request expired.' })
501
+ expect(deps.armedSecretCaptures.has('111')).toBe(false)
502
+ expect(injected).toHaveLength(0)
503
+ })
504
+
505
+ it('provide: arms the capture keyed by chat_id and swaps the card to the send-now prompt', async () => {
506
+ const { deps } = makeDeps()
507
+ deps.pendingSecretRequests.set('s1', stagedSecretRequest({ threadId: 7 }))
508
+ const h = createCallbackQueryHandlers(deps)
509
+ const { ctx, raw } = makeCtx()
510
+ await h.handleSecretRequestCallback(ctx, 'vsp:provide:s1')
511
+ const armed = deps.armedSecretCaptures.get('111')
512
+ expect(armed?.key).toBe('openai/api_key')
513
+ expect(armed?.stageId).toBe('s1')
514
+ expect(armed?.threadId).toBe(7)
515
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({
516
+ text: 'Send the value now — it auto-deletes.',
517
+ })
518
+ // Card edited to the "send the value" prompt with the keyboard stripped.
519
+ expect(raw.api.editMessageText).toHaveBeenCalled()
520
+ // The pending request survives until the value actually arrives.
521
+ expect(deps.pendingSecretRequests.has('s1')).toBe(true)
522
+ })
523
+
524
+ it('decline: drops the stage, clears any armed capture, edits the card, wakes the agent', async () => {
525
+ const { deps, injected } = makeDeps()
526
+ deps.pendingSecretRequests.set('s1', stagedSecretRequest())
527
+ deps.armedSecretCaptures.set('111', {
528
+ key: 'openai/api_key',
529
+ agent: 'worker',
530
+ stageId: 's1',
531
+ armed_at: Date.now(),
532
+ })
533
+ const h = createCallbackQueryHandlers(deps)
534
+ const { ctx, raw } = makeCtx()
535
+ await h.handleSecretRequestCallback(ctx, 'vsp:decline:s1')
536
+ expect(deps.pendingSecretRequests.has('s1')).toBe(false)
537
+ expect(deps.pendingCardStore.remove).toHaveBeenCalledWith('s1')
538
+ expect(deps.armedSecretCaptures.has('111')).toBe(false)
539
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Declined.' })
540
+ expect(raw.api.editMessageText).toHaveBeenCalled()
541
+ expect(injected).toHaveLength(1)
542
+ expect(injected[0]!.agent).toBe('worker')
543
+ expect(injected[0]!.text).toContain('openai/api_key')
544
+ })
545
+
546
+ it('an unknown action just acknowledges without mutating state', async () => {
547
+ const { deps, injected } = makeDeps()
548
+ deps.pendingSecretRequests.set('s1', stagedSecretRequest())
549
+ const h = createCallbackQueryHandlers(deps)
550
+ const { ctx, raw } = makeCtx()
551
+ await h.handleSecretRequestCallback(ctx, 'vsp:bogus:s1')
552
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith()
553
+ expect(deps.pendingSecretRequests.has('s1')).toBe(true)
554
+ expect(injected).toHaveLength(0)
555
+ })
556
+ })
557
+
458
558
  // ── mmp:* — mental-model proposal ───────────────────────────────────────
459
559
 
460
560
  function stagedPropose(over: Partial<PendingMentalModelPropose> = {}): PendingMentalModelPropose {
@@ -748,6 +848,7 @@ describe('authorization gate parity', () => {
748
848
  ['vrd', (h, ctx) => h.handleVaultRecentDenialCallback(ctx, 'vrd:worker:k')],
749
849
  ['vra', (h, ctx) => h.handleVaultRequestAccessCallback(ctx, 'vra:approve:s1')],
750
850
  ['vrs', (h, ctx) => h.handleVaultRequestSaveCallback(ctx, 'vrs:save:s1')],
851
+ ['vsp', (h, ctx) => h.handleSecretRequestCallback(ctx, 'vsp:provide:s1')],
751
852
  ['vd', (h, ctx) => h.handleVaultDeferCallback(ctx, 'vd:unlock:111:7')],
752
853
  ['vg', (h, ctx) => h.handleVaultGrantCallback(ctx, 'vg:cancel')],
753
854
  ['mmp', (h, ctx) => h.handleMentalModelProposeCallback(ctx, 'mmp:approve:m1')],
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Behavior pins for card-tool-handlers.ts (#2996 P5-tail) — the agent-facing
3
+ * card-STAGING execute* tool handlers, extracted verbatim from gateway.ts.
4
+ *
5
+ * Unlike the source-inspection pins in pending-card-durability-wiring.test.ts,
6
+ * these INVOKE the extracted handlers through the DI factory with fake deps and
7
+ * assert OUTCOMES (Amendment-10 extracted-module oracle): the returned tool
8
+ * text, what lands in the durable card store, what NEVER lands (secret hygiene),
9
+ * and the validation/deny throw paths. The factory is only unit-invocable
10
+ * BECAUSE the handlers moved out of the un-importable gateway.ts.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import { createCardToolHandlers, type CardToolHandlersDeps } from '../gateway/card-tool-handlers.js'
15
+
16
+ const VAULT_KEY_REGEX = /^[A-Za-z0-9_./-]{1,200}$/
17
+ const VAULT_KEY_REGEX_LABEL = '[A-Za-z0-9_./-]{1,200}'
18
+
19
+ /** A minimal SweepableCardStore-shaped fake backed by a Map. */
20
+ function fakeCardStore<T>() {
21
+ const map = new Map<string, T>()
22
+ return {
23
+ map,
24
+ get: (id: string) => map.get(id),
25
+ set: (id: string, v: T) => void map.set(id, v),
26
+ delete: (id: string) => map.delete(id),
27
+ sweep: () => {},
28
+ [Symbol.iterator]: () => map[Symbol.iterator](),
29
+ } as unknown as CardToolHandlersDeps['pendingVaultRequestSaves'] & { map: Map<string, T> }
30
+ }
31
+
32
+ function makeDeps(overrides: Partial<CardToolHandlersDeps> = {}) {
33
+ const added: unknown[] = []
34
+ const removed: string[] = []
35
+ const allowedChats: string[] = []
36
+ const saves = fakeCardStore<Record<string, unknown>>()
37
+ const accesses = fakeCardStore<Record<string, unknown>>()
38
+ const secrets = fakeCardStore<Record<string, unknown>>()
39
+ const sweeps: number[] = []
40
+ const proposes = fakeCardStore<Record<string, unknown>>()
41
+ const proposeTimes: number[] = []
42
+ const deps: CardToolHandlersDeps = {
43
+ lockedBot: {
44
+ api: {
45
+ sendRichMessage: async () => ({ message_id: 4242 }),
46
+ },
47
+ },
48
+ robustApiCall: (<T>(fn: () => Promise<T>) => fn()) as CardToolHandlersDeps['robustApiCall'],
49
+ assertAllowedChat: (chat_id) => {
50
+ allowedChats.push(String(chat_id))
51
+ },
52
+ VAULT_KEY_REGEX,
53
+ VAULT_KEY_REGEX_LABEL,
54
+ pendingVaultRequestSaves: saves,
55
+ pendingVaultRequestAccesses: accesses,
56
+ pendingSecretRequests: secrets,
57
+ pendingCardStore: {
58
+ add: (e: unknown) => void added.push(e),
59
+ remove: (id: string) => void removed.push(id),
60
+ } as unknown as CardToolHandlersDeps['pendingCardStore'],
61
+ sweepSecretRequests: (now = Date.now()) => void sweeps.push(now),
62
+ // Default: broker sees no keys (no standing ACL) — normal card flow.
63
+ listViaBroker: async () => [],
64
+ VAULT_REQUEST_ACCESS_TTL_MS: 10 * 60_000,
65
+ pendingMentalModelProposes: proposes,
66
+ mentalModelProposeTimes: proposeTimes,
67
+ // Default: under budget.
68
+ checkMentalModelProposeRate: () => ({ ok: true }),
69
+ // Default: no agents block — dup pre-check finds nothing declared.
70
+ readLiveSwitchroomConfigText: () => 'agents: {}\n',
71
+ MENTAL_MODEL_SOURCE_QUERY_MAX: 2000,
72
+ MENTAL_MODEL_MAX_TOKENS_CAP: 8192,
73
+ MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW: 5,
74
+ ...overrides,
75
+ }
76
+ return { deps, added, removed, allowedChats, saves, accesses, secrets, sweeps, proposes, proposeTimes }
77
+ }
78
+
79
+ describe('executeVaultRequestSave', () => {
80
+ it('stages the card and returns a stage_id + key in the tool text', async () => {
81
+ const { deps, added, saves } = makeDeps()
82
+ const { executeVaultRequestSave } = createCardToolHandlers(deps)
83
+ const res = await executeVaultRequestSave({
84
+ chat_id: '123',
85
+ key: 'svc/token',
86
+ value: 'sekret-value',
87
+ })
88
+ const text = res.content[0]!.text
89
+ expect(text).toMatch(/vault_request_save: card sent/)
90
+ expect(text).toMatch(/key=svc\/token/)
91
+ // Exactly one entry staged in the in-memory store, holding the value.
92
+ expect(saves.map.size).toBe(1)
93
+ const staged = [...saves.map.values()][0] as Record<string, unknown>
94
+ expect(staged.value).toBe('sekret-value')
95
+ expect(staged.card_message_id).toBe(4242)
96
+ // One durable record persisted for the family.
97
+ expect(added).toHaveLength(1)
98
+ const rec = added[0] as Record<string, unknown>
99
+ expect(rec.family).toBe('vault_request_save')
100
+ expect(rec.key).toBe('svc/token')
101
+ })
102
+
103
+ it('NEVER persists the raw secret value into the durable store (hygiene)', async () => {
104
+ const { deps, added } = makeDeps()
105
+ const { executeVaultRequestSave } = createCardToolHandlers(deps)
106
+ await executeVaultRequestSave({ chat_id: '123', key: 'svc/token', value: 'top-secret' })
107
+ const rec = added[0] as Record<string, unknown>
108
+ expect(rec).not.toHaveProperty('value')
109
+ expect(JSON.stringify(rec)).not.toContain('top-secret')
110
+ })
111
+
112
+ it('enforces the allow-list before staging (deny path throws, nothing staged)', async () => {
113
+ const { deps, added, saves } = makeDeps({
114
+ assertAllowedChat: () => {
115
+ throw new Error('chat not allowed')
116
+ },
117
+ })
118
+ const { executeVaultRequestSave } = createCardToolHandlers(deps)
119
+ await expect(
120
+ executeVaultRequestSave({ chat_id: '999', key: 'svc/token', value: 'v' }),
121
+ ).rejects.toThrow(/chat not allowed/)
122
+ expect(saves.map.size).toBe(0)
123
+ expect(added).toHaveLength(0)
124
+ })
125
+
126
+ it('rejects a malformed vault key with the labelled charset error', async () => {
127
+ const { deps } = makeDeps()
128
+ const { executeVaultRequestSave } = createCardToolHandlers(deps)
129
+ await expect(
130
+ executeVaultRequestSave({ chat_id: '123', key: 'bad key!', value: 'v' }),
131
+ ).rejects.toThrow(/key must match/)
132
+ })
133
+
134
+ it('rejects an empty value and an invalid kind', async () => {
135
+ const { deps } = makeDeps()
136
+ const { executeVaultRequestSave } = createCardToolHandlers(deps)
137
+ await expect(
138
+ executeVaultRequestSave({ chat_id: '123', key: 'svc/token', value: '' }),
139
+ ).rejects.toThrow(/value is required/)
140
+ await expect(
141
+ executeVaultRequestSave({ chat_id: '123', key: 'svc/token', value: 'v', kind: 'nope' }),
142
+ ).rejects.toThrow(/kind must be/)
143
+ })
144
+ })
145
+
146
+ describe('executeVaultRequestAccess', () => {
147
+ it('stages the card and returns stage_id + key + scope; default scope=read, default ttl=30d', async () => {
148
+ const { deps, added, accesses } = makeDeps()
149
+ const { executeVaultRequestAccess } = createCardToolHandlers(deps)
150
+ const res = await executeVaultRequestAccess({ chat_id: '123', key: 'svc/token' })
151
+ const text = res.content[0]!.text
152
+ expect(text).toMatch(/vault_request_access: card sent/)
153
+ expect(text).toMatch(/key=svc\/token/)
154
+ expect(text).toMatch(/scope=read/)
155
+ expect(accesses.map.size).toBe(1)
156
+ const staged = [...accesses.map.values()][0] as Record<string, unknown>
157
+ expect(staged.scope).toBe('read')
158
+ expect(staged.ttl_seconds).toBe(30 * 24 * 60 * 60)
159
+ expect(added).toHaveLength(1)
160
+ const rec = added[0] as Record<string, unknown>
161
+ expect(rec.family).toBe('vault_request_access')
162
+ expect(rec.scope).toBe('read')
163
+ })
164
+
165
+ it('parses "12h"/"45d" durations and REFUSES > 90d and malformed/"never" shapes (threat model)', async () => {
166
+ const { deps, accesses } = makeDeps()
167
+ const { executeVaultRequestAccess } = createCardToolHandlers(deps)
168
+ await executeVaultRequestAccess({ chat_id: '1', key: 'a/b', duration: '12h' })
169
+ expect(([...accesses.map.values()].pop() as Record<string, unknown>).ttl_seconds).toBe(12 * 3600)
170
+ await executeVaultRequestAccess({ chat_id: '1', key: 'a/c', duration: '45d' })
171
+ expect(([...accesses.map.values()].pop() as Record<string, unknown>).ttl_seconds).toBe(45 * 86400)
172
+ await expect(
173
+ executeVaultRequestAccess({ chat_id: '1', key: 'a/d', duration: '91d' }),
174
+ ).rejects.toThrow(/<= 90d/)
175
+ await expect(
176
+ executeVaultRequestAccess({ chat_id: '1', key: 'a/e', duration: 'never' }),
177
+ ).rejects.toThrow(/must look like/)
178
+ await expect(
179
+ executeVaultRequestAccess({ chat_id: '1', key: 'a/f', duration: '0d' }),
180
+ ).rejects.toThrow(/> 0/)
181
+ })
182
+
183
+ it('rejects an invalid scope', async () => {
184
+ const { deps } = makeDeps()
185
+ const { executeVaultRequestAccess } = createCardToolHandlers(deps)
186
+ await expect(
187
+ executeVaultRequestAccess({ chat_id: '1', key: 'a/b', scope: 'admin' }),
188
+ ).rejects.toThrow(/scope must be/)
189
+ })
190
+
191
+ it('standing-ACL short-circuit: read scope + broker lists the key → NO card, NO staging, NO durable record', async () => {
192
+ const { deps, added, accesses } = makeDeps({
193
+ listViaBroker: async () => ['svc/token', 'other/key'],
194
+ })
195
+ let sends = 0
196
+ ;(deps.lockedBot as { api: { sendRichMessage: unknown } }).api.sendRichMessage = async () => {
197
+ sends++
198
+ return { message_id: 1 }
199
+ }
200
+ const { executeVaultRequestAccess } = createCardToolHandlers(deps)
201
+ const res = await executeVaultRequestAccess({ chat_id: '123', key: 'svc/token' })
202
+ expect(res.content[0]!.text).toMatch(/ALREADY covered/)
203
+ expect(sends).toBe(0)
204
+ expect(accesses.map.size).toBe(0)
205
+ expect(added).toHaveLength(0)
206
+ })
207
+
208
+ it('write scope does NOT consult the standing-ACL probe (read-only coverage)', async () => {
209
+ let probes = 0
210
+ const { deps, accesses } = makeDeps({
211
+ listViaBroker: async () => {
212
+ probes++
213
+ return ['svc/token']
214
+ },
215
+ })
216
+ const { executeVaultRequestAccess } = createCardToolHandlers(deps)
217
+ const res = await executeVaultRequestAccess({ chat_id: '123', key: 'svc/token', scope: 'write' })
218
+ expect(probes).toBe(0)
219
+ expect(res.content[0]!.text).toMatch(/card sent/)
220
+ expect(accesses.map.size).toBe(1)
221
+ })
222
+
223
+ it('broker probe failure fails OPEN to the normal card flow', async () => {
224
+ const { deps, accesses } = makeDeps({
225
+ listViaBroker: async () => {
226
+ throw new Error('broker unreachable')
227
+ },
228
+ })
229
+ const { executeVaultRequestAccess } = createCardToolHandlers(deps)
230
+ const res = await executeVaultRequestAccess({ chat_id: '123', key: 'svc/token' })
231
+ expect(res.content[0]!.text).toMatch(/card sent/)
232
+ expect(accesses.map.size).toBe(1)
233
+ })
234
+
235
+ it('accepts `why` as an alias for `reason` and derives the timeout copy from the injected TTL', async () => {
236
+ const { deps, accesses } = makeDeps({ VAULT_REQUEST_ACCESS_TTL_MS: 25 * 60_000 })
237
+ const { executeVaultRequestAccess } = createCardToolHandlers(deps)
238
+ const res = await executeVaultRequestAccess({ chat_id: '1', key: 'a/b', why: 'need it' })
239
+ const staged = [...accesses.map.values()][0] as Record<string, unknown>
240
+ expect(staged.reason).toBe('need it')
241
+ expect(res.content[0]!.text).toMatch(/times out \(25 min\)/)
242
+ })
243
+ })
244
+
245
+ describe('executeRequestSecret', () => {
246
+ it('stages the card and returns stage_id + key + the END-YOUR-TURN copy', async () => {
247
+ const { deps, added, secrets } = makeDeps()
248
+ const { executeRequestSecret } = createCardToolHandlers(deps)
249
+ const res = await executeRequestSecret({ chat_id: '123', key: 'svc/token', reason: 'need it' })
250
+ const text = res.content[0]!.text
251
+ expect(text).toMatch(/request_secret: card sent/)
252
+ expect(text).toMatch(/key=svc\/token/)
253
+ // The secure-capture contract: the agent must END ITS TURN and wait.
254
+ expect(text).toMatch(/END YOUR TURN/)
255
+ expect(secrets.map.size).toBe(1)
256
+ const staged = [...secrets.map.values()][0] as Record<string, unknown>
257
+ expect(staged.key).toBe('svc/token')
258
+ expect(staged.reason).toBe('need it')
259
+ expect(staged.card_message_id).toBe(4242)
260
+ expect(added).toHaveLength(1)
261
+ const rec = added[0] as Record<string, unknown>
262
+ expect(rec.family).toBe('request_secret')
263
+ expect(rec.key).toBe('svc/token')
264
+ })
265
+
266
+ it('NEVER stages or persists a secret VALUE — request_secret carries none at staging time', async () => {
267
+ // The whole point of request_secret (vs vault_request_save): the value
268
+ // arrives LATER via secure capture, so no value must exist on the pending
269
+ // record or in the durable store at staging time.
270
+ const { deps, added, secrets } = makeDeps()
271
+ const { executeRequestSecret } = createCardToolHandlers(deps)
272
+ await executeRequestSecret({ chat_id: '123', key: 'svc/token', value: 'should-be-ignored' })
273
+ const staged = [...secrets.map.values()][0] as Record<string, unknown>
274
+ expect(staged).not.toHaveProperty('value')
275
+ expect(JSON.stringify(staged)).not.toContain('should-be-ignored')
276
+ const rec = added[0] as Record<string, unknown>
277
+ expect(rec).not.toHaveProperty('value')
278
+ expect(JSON.stringify(rec)).not.toContain('should-be-ignored')
279
+ })
280
+
281
+ it('dedupes to one open request per (chat,key): a prior stage is dropped from BOTH stores', async () => {
282
+ const { deps, removed, secrets } = makeDeps()
283
+ const { executeRequestSecret } = createCardToolHandlers(deps)
284
+ await executeRequestSecret({ chat_id: '123', key: 'svc/token' })
285
+ const firstId = [...secrets.map.keys()][0]!
286
+ // Second request for the SAME (chat,key) drops the first stage.
287
+ await executeRequestSecret({ chat_id: '123', key: 'svc/token' })
288
+ expect(secrets.map.size).toBe(1)
289
+ expect(secrets.map.has(firstId)).toBe(false)
290
+ // The dropped stage's durable record is removed too (no stacked cards).
291
+ expect(removed).toContain(firstId)
292
+ })
293
+
294
+ it('does NOT dedupe across a different key or a different chat', async () => {
295
+ const { deps, secrets } = makeDeps()
296
+ const { executeRequestSecret } = createCardToolHandlers(deps)
297
+ await executeRequestSecret({ chat_id: '123', key: 'svc/token' })
298
+ await executeRequestSecret({ chat_id: '123', key: 'other/key' })
299
+ await executeRequestSecret({ chat_id: '456', key: 'svc/token' })
300
+ expect(secrets.map.size).toBe(3)
301
+ })
302
+
303
+ it('enforces the allow-list before staging (deny path throws, nothing staged)', async () => {
304
+ const { deps, added, secrets } = makeDeps({
305
+ assertAllowedChat: () => {
306
+ throw new Error('chat not allowed')
307
+ },
308
+ })
309
+ const { executeRequestSecret } = createCardToolHandlers(deps)
310
+ await expect(
311
+ executeRequestSecret({ chat_id: '999', key: 'svc/token' }),
312
+ ).rejects.toThrow(/chat not allowed/)
313
+ expect(secrets.map.size).toBe(0)
314
+ expect(added).toHaveLength(0)
315
+ })
316
+
317
+ it('rejects a malformed vault key with the labelled charset error', async () => {
318
+ const { deps, secrets } = makeDeps()
319
+ const { executeRequestSecret } = createCardToolHandlers(deps)
320
+ await expect(
321
+ executeRequestSecret({ chat_id: '123', key: 'bad key!' }),
322
+ ).rejects.toThrow(/key must match/)
323
+ expect(secrets.map.size).toBe(0)
324
+ })
325
+
326
+ it('rejects a missing chat_id and a missing key', async () => {
327
+ const { deps } = makeDeps()
328
+ const { executeRequestSecret } = createCardToolHandlers(deps)
329
+ await expect(executeRequestSecret({ key: 'svc/token' })).rejects.toThrow(/chat_id is required/)
330
+ await expect(executeRequestSecret({ chat_id: '123' })).rejects.toThrow(/key is required/)
331
+ })
332
+
333
+ it('runs the injected sweepSecretRequests reaper on the way through', async () => {
334
+ const { deps, sweeps } = makeDeps()
335
+ const { executeRequestSecret } = createCardToolHandlers(deps)
336
+ await executeRequestSecret({ chat_id: '123', key: 'svc/token' })
337
+ expect(sweeps.length).toBeGreaterThan(0)
338
+ })
339
+ })
340
+
341
+ describe('executeMentalModelPropose', () => {
342
+ // The dup-name pre-check keys on agents.<slug>.memory.mental_models[] where
343
+ // slug = SWITCHROOM_AGENT_NAME || 'agent' (read live by the handler).
344
+ const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
345
+ const declaredYaml =
346
+ `agents:\n ${agentSlug}:\n memory:\n mental_models:\n - name: existing-model\n source_query: standing query\n`
347
+
348
+ it('stages the card and returns stage_id + name + the END-YOUR-TURN copy; budget consumed AFTER post', async () => {
349
+ const { deps, added, proposes, proposeTimes } = makeDeps()
350
+ const { executeMentalModelPropose } = createCardToolHandlers(deps)
351
+ const res = await executeMentalModelPropose({
352
+ chat_id: '123',
353
+ name: 'training-plan-state',
354
+ source_query: 'what is the current training plan state',
355
+ reason: 'recurring question',
356
+ max_tokens: 512,
357
+ refresh_after_consolidation: true,
358
+ })
359
+ const text = res.content[0]!.text
360
+ expect(text).toMatch(/mental_model_propose: card sent/)
361
+ expect(text).toMatch(/name=training-plan-state/)
362
+ expect(text).toMatch(/END YOUR TURN/)
363
+ expect(proposes.map.size).toBe(1)
364
+ const staged = [...proposes.map.values()][0] as { spec: Record<string, unknown>; reason?: string; card_message_id?: number }
365
+ expect(staged.spec.name).toBe('training-plan-state')
366
+ expect(staged.spec.max_tokens).toBe(512)
367
+ expect(staged.spec.refresh_after_consolidation).toBe(true)
368
+ expect(staged.reason).toBe('recurring question')
369
+ expect(staged.card_message_id).toBe(4242)
370
+ expect(added).toHaveLength(1)
371
+ const rec = added[0] as Record<string, unknown>
372
+ expect(rec.family).toBe('mental_model_propose')
373
+ // Rate budget consumed exactly once, only because the card posted.
374
+ expect(proposeTimes).toHaveLength(1)
375
+ })
376
+
377
+ it('RATE-LIMITED path: no card send, no staging, no durable record, no budget consumed', async () => {
378
+ const retryAtMs = Date.now() + 30 * 60_000
379
+ const { deps, added, proposes, proposeTimes } = makeDeps({
380
+ checkMentalModelProposeRate: () => ({ ok: false, retryAtMs }),
381
+ })
382
+ let sends = 0
383
+ ;(deps.lockedBot as { api: { sendRichMessage: unknown } }).api.sendRichMessage = async () => {
384
+ sends++
385
+ return { message_id: 1 }
386
+ }
387
+ const { executeMentalModelPropose } = createCardToolHandlers(deps)
388
+ const res = await executeMentalModelPropose({ chat_id: '123', name: 'm', source_query: 'q' })
389
+ const text = res.content[0]!.text
390
+ expect(text).toMatch(/RATE-LIMITED \(max 5 proposals\/hour\)/)
391
+ expect(text).toContain(new Date(retryAtMs).toISOString())
392
+ expect(sends).toBe(0)
393
+ expect(proposes.map.size).toBe(0)
394
+ expect(added).toHaveLength(0)
395
+ expect(proposeTimes).toHaveLength(0)
396
+ })
397
+
398
+ it('duplicate-name short-circuit: declared model → NO card, NO staging, NO budget', async () => {
399
+ const { deps, added, proposes, proposeTimes } = makeDeps({
400
+ readLiveSwitchroomConfigText: () => declaredYaml,
401
+ })
402
+ const { executeMentalModelPropose } = createCardToolHandlers(deps)
403
+ const res = await executeMentalModelPropose({ chat_id: '123', name: 'existing-model', source_query: 'q' })
404
+ expect(res.content[0]!.text).toMatch(/ALREADY a declared mental model/)
405
+ expect(proposes.map.size).toBe(0)
406
+ expect(added).toHaveLength(0)
407
+ expect(proposeTimes).toHaveLength(0)
408
+ })
409
+
410
+ it('a DIFFERENT name passes the dup guard against the same declared config', async () => {
411
+ const { deps, proposes } = makeDeps({
412
+ readLiveSwitchroomConfigText: () => declaredYaml,
413
+ })
414
+ const { executeMentalModelPropose } = createCardToolHandlers(deps)
415
+ const res = await executeMentalModelPropose({ chat_id: '123', name: 'new-model', source_query: 'q' })
416
+ expect(res.content[0]!.text).toMatch(/card sent/)
417
+ expect(proposes.map.size).toBe(1)
418
+ })
419
+
420
+ it('config-read failure fails OPEN to the card flow (approve path re-checks)', async () => {
421
+ const { deps, proposes } = makeDeps({
422
+ readLiveSwitchroomConfigText: () => {
423
+ throw new Error('yaml unreadable')
424
+ },
425
+ })
426
+ const { executeMentalModelPropose } = createCardToolHandlers(deps)
427
+ const res = await executeMentalModelPropose({ chat_id: '123', name: 'm', source_query: 'q' })
428
+ expect(res.content[0]!.text).toMatch(/card sent/)
429
+ expect(proposes.map.size).toBe(1)
430
+ })
431
+
432
+ it('rejects an over-cap source_query with the schema-cap error; NO budget consumed', async () => {
433
+ const { deps, proposeTimes } = makeDeps({ MENTAL_MODEL_SOURCE_QUERY_MAX: 20 })
434
+ const { executeMentalModelPropose } = createCardToolHandlers(deps)
435
+ await expect(
436
+ executeMentalModelPropose({ chat_id: '1', name: 'm', source_query: 'x'.repeat(21) }),
437
+ ).rejects.toThrow(/schema caps it at 20/)
438
+ expect(proposeTimes).toHaveLength(0)
439
+ })
440
+
441
+ it('rejects over-cap and non-positive-integer max_tokens', async () => {
442
+ const { deps } = makeDeps({ MENTAL_MODEL_MAX_TOKENS_CAP: 100 })
443
+ const { executeMentalModelPropose } = createCardToolHandlers(deps)
444
+ await expect(
445
+ executeMentalModelPropose({ chat_id: '1', name: 'm', source_query: 'q', max_tokens: 101 }),
446
+ ).rejects.toThrow(/exceeds the schema cap of 100/)
447
+ await expect(
448
+ executeMentalModelPropose({ chat_id: '1', name: 'm', source_query: 'q', max_tokens: 1.5 }),
449
+ ).rejects.toThrow(/positive integer/)
450
+ await expect(
451
+ executeMentalModelPropose({ chat_id: '1', name: 'm', source_query: 'q', max_tokens: 0 }),
452
+ ).rejects.toThrow(/positive integer/)
453
+ })
454
+
455
+ it('rejects a non-slug name and missing required args', async () => {
456
+ const { deps } = makeDeps()
457
+ const { executeMentalModelPropose } = createCardToolHandlers(deps)
458
+ await expect(
459
+ executeMentalModelPropose({ chat_id: '1', name: 'bad name!', source_query: 'q' }),
460
+ ).rejects.toThrow(/name must be a slug/)
461
+ await expect(
462
+ executeMentalModelPropose({ chat_id: '1', source_query: 'q' }),
463
+ ).rejects.toThrow(/name is required/)
464
+ await expect(
465
+ executeMentalModelPropose({ chat_id: '1', name: 'm' }),
466
+ ).rejects.toThrow(/source_query is required/)
467
+ await expect(
468
+ executeMentalModelPropose({ name: 'm', source_query: 'q' }),
469
+ ).rejects.toThrow(/chat_id is required/)
470
+ })
471
+
472
+ it('accepts `why` as an alias for `reason` and a boolean-only refresh_after_consolidation', async () => {
473
+ const { deps, proposes } = makeDeps()
474
+ const { executeMentalModelPropose } = createCardToolHandlers(deps)
475
+ await executeMentalModelPropose({ chat_id: '1', name: 'm', source_query: 'q', why: 'because' })
476
+ const staged = [...proposes.map.values()][0] as { reason?: string }
477
+ expect(staged.reason).toBe('because')
478
+ await expect(
479
+ executeMentalModelPropose({ chat_id: '1', name: 'm2', source_query: 'q', refresh_after_consolidation: 'yes' }),
480
+ ).rejects.toThrow(/must be a boolean/)
481
+ })
482
+
483
+ it('enforces the allow-list before staging (deny throws, nothing staged, no budget)', async () => {
484
+ const { deps, added, proposes, proposeTimes } = makeDeps({
485
+ assertAllowedChat: () => {
486
+ throw new Error('chat not allowed')
487
+ },
488
+ })
489
+ const { executeMentalModelPropose } = createCardToolHandlers(deps)
490
+ await expect(
491
+ executeMentalModelPropose({ chat_id: '999', name: 'm', source_query: 'q' }),
492
+ ).rejects.toThrow(/chat not allowed/)
493
+ expect(proposes.map.size).toBe(0)
494
+ expect(added).toHaveLength(0)
495
+ expect(proposeTimes).toHaveLength(0)
496
+ })
497
+ })
@@ -256,9 +256,12 @@ describe('gateway.ts catch-all registration invariant', () => {
256
256
  expect(SRC.indexOf('installUnhandledMessageCatchAll(', installIdx + 1)).toBe(-1)
257
257
  })
258
258
 
259
- it('routes the catch-all through handleInboundCoalesced (same gating + forward-origin path)', () => {
259
+ it('routes the catch-all through routeInbound → handleInboundCoalesced (same gating + forward-origin path)', () => {
260
+ // P7 PR-1: the catch-all now delegates through the single `routeInbound`
261
+ // seam, which forwards to `handleInboundCoalesced` unchanged (pure
262
+ // indirection). Anchor on the seam call the registration actually makes.
260
263
  const installIdx = SRC.indexOf('installUnhandledMessageCatchAll(')
261
264
  const body = SRC.slice(installIdx, installIdx + 400)
262
- expect(body).toMatch(/handleInboundCoalesced\(ctx, text, undefined\)/)
265
+ expect(body).toMatch(/routeInbound\(ctx, text, undefined, undefined, inboundRouterDeps\)/)
263
266
  })
264
267
  })