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
@@ -0,0 +1,639 @@
1
+ // Card-tool handlers — the agent-facing MCP tool INVOCATION side of the
2
+ // approval-card families, extracted verbatim from gateway.ts (#2996 P5-tail).
3
+ //
4
+ // This module owns the `execute*` handlers that STAGE an approval card in
5
+ // response to an agent tool call. The operator-tap RESOLUTION side (the
6
+ // `callback_query:data` handlers) already lives in callback-query-handlers.ts
7
+ // (#3013 / #3329); this is its sibling — the propose/stage half:
8
+ //
9
+ // - executeVaultRequestSave — agent HAS a value, asks to persist it (vrs:)
10
+ // - executeVaultRequestAccess — agent needs read/write ACL on a key (vra:)
11
+ // - executeRequestSecret — agent needs a value it does NOT have (vsp:)
12
+ // - executeMentalModelPropose — agent proposes a standing mental model (mmp:)
13
+ //
14
+ // Deliberately NOT moved (they stay in gateway.ts and are INJECTED here as the
15
+ // ONE shared singleton — Amendment 1): the pending-state stores
16
+ // (pendingVaultRequestSaves / -Accesses / pendingSecretRequests /
17
+ // pendingMentalModelProposes / pendingCardStore), the secret WRITE path
18
+ // (writeRequestedSecret / captureProvidedSecret — shared with handleInbound),
19
+ // the sweepSecretRequests reaper hook, the per-agent propose rate limiter
20
+ // (checkMentalModelProposeRate + mentalModelProposeTimes[]),
21
+ // readLiveSwitchroomConfigText, and the gateway wrappers (robustApiCall,
22
+ // lockedBot, assertAllowedChat). Config constants are injected under their
23
+ // exact source names so bodies stay byte-identical.
24
+ //
25
+ // Style: factory over a deps object, following callback-query-handlers.ts /
26
+ // outbound-send-path.ts. Function bodies are byte-identical to the
27
+ // pre-extraction gateway.ts text (behavior-preserving #2996). None of these
28
+ // handlers reads the `currentTurn` global (Amendment 9 satisfied trivially —
29
+ // they take only `args`).
30
+
31
+ import { randomBytes } from 'crypto'
32
+ import { richMessage } from '../rich-send.js'
33
+ import { retryWithThreadFallback, type RetryCallOpts } from '../retry-api-call.js'
34
+ import {
35
+ renderVaultRequestSaveCard,
36
+ buildVaultRequestSaveKeyboard,
37
+ } from './vault-request-save-card.js'
38
+ import {
39
+ renderVaultRequestAccessCard,
40
+ buildVaultRequestAccessKeyboard,
41
+ } from './vault-request-access-card.js'
42
+ import {
43
+ renderSecretRequestCard,
44
+ buildSecretRequestKeyboard,
45
+ } from './secret-request-card.js'
46
+ import {
47
+ renderMentalModelProposeCard,
48
+ buildMentalModelProposeKeyboard,
49
+ } from './mental-model-propose-card.js'
50
+ import { readDeclaredMentalModelNames } from './mental-model-propose-diff.js'
51
+ import type {
52
+ PendingVaultRequestSave,
53
+ PendingVaultRequestAccess,
54
+ PendingSecretRequest,
55
+ PendingMentalModelPropose,
56
+ } from './callback-query-handlers.js'
57
+ import type { SweepableCardStore } from './approval-card-stores.js'
58
+ import type { PendingCardStore } from './pending-card-store.js'
59
+
60
+ // Moved with executeMentalModelPropose (its only user) — the propose-name slug
61
+ // gate, distinct from the vault-key regex.
62
+ const MENTAL_MODEL_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/
63
+
64
+ // ─── Bot shape the handlers touch (grammy Bot / chat-locked wrapper) ────────
65
+
66
+ /** Minimal bot surface the card senders touch. */
67
+ export interface CardToolBotApi {
68
+ api: {
69
+ sendRichMessage: (
70
+ chat_id: number | string,
71
+ rich_message: unknown,
72
+ other?: Record<string, unknown>,
73
+ ) => Promise<{ message_id: number }>
74
+ }
75
+ }
76
+
77
+ // ─── Deps ────────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Everything the card-tool handlers read from gateway module scope. Stores are
81
+ * the consolidated #3008/#3011 surfaces (never raw Maps) and are the ONE shared
82
+ * singleton — never re-`new`'d here (Amendment 1). Config constants are injected
83
+ * under their exact source-name so handler bodies stay byte-identical.
84
+ */
85
+ export interface CardToolHandlersDeps {
86
+ /** The chat-lock-wrapped bot (serialized sends). */
87
+ lockedBot: unknown
88
+ /** Flood-wait-aware retry wrapper (gateway's `robustApiCall`). */
89
+ robustApiCall: <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T>
90
+ /** Gateway allowFrom gate — throws if chat_id is not permitted. */
91
+ assertAllowedChat: (chat_id: string | number) => void
92
+ /** Gateway-side vault-key shape gate (UX gate, not a security boundary). */
93
+ VAULT_KEY_REGEX: RegExp
94
+ VAULT_KEY_REGEX_LABEL: string
95
+ // Pending-state stores (consolidated surfaces; the ONE shared instance).
96
+ pendingVaultRequestSaves: SweepableCardStore<PendingVaultRequestSave>
97
+ pendingVaultRequestAccesses: SweepableCardStore<PendingVaultRequestAccess>
98
+ pendingSecretRequests: SweepableCardStore<PendingSecretRequest>
99
+ /** Durable card-metadata store (restart survival). */
100
+ pendingCardStore: PendingCardStore
101
+ /**
102
+ * Reaper hook that sweeps BOTH pendingSecretRequests and the transient
103
+ * armedSecretCaptures store. Injected (not re-derived) so executeRequestSecret
104
+ * stays byte-identical and armedSecretCaptures — which is shared with the
105
+ * handleInbound capture path — stays the ONE instance in gateway.ts.
106
+ */
107
+ sweepSecretRequests: (now?: number) => void
108
+ /**
109
+ * Broker no-token `list` probe AS THIS AGENT (path-as-identity) — the
110
+ * authoritative standing-ACL coverage check in executeVaultRequestAccess
111
+ * (Fix B, #1487 follow-up). Injected (rather than imported) so the
112
+ * fail-open probe path and the ALREADY-covered short-circuit are
113
+ * deterministic under test; gateway wires the real
114
+ * src/vault/broker/client.js listViaBroker.
115
+ */
116
+ listViaBroker: () => Promise<string[] | null>
117
+ /** VAULT_REQUEST_ACCESS_TTL_MS (config-driven approval-card lifetime). */
118
+ VAULT_REQUEST_ACCESS_TTL_MS: number
119
+ // ── executeMentalModelPropose deps ────────────────────────────────────────
120
+ /** The ONE pending-propose store (gateway singleton; expiry/restore stay there). */
121
+ pendingMentalModelProposes: SweepableCardStore<PendingMentalModelPropose>
122
+ /**
123
+ * The rate-limit window log — the SAME array instance the gateway-side
124
+ * checkMentalModelProposeRate mutates. The handler pushes onto it only
125
+ * after a card actually posts (validation errors / dupes don't consume
126
+ * budget); injected as the array (not an accessor) to keep the push
127
+ * byte-identical.
128
+ */
129
+ mentalModelProposeTimes: number[]
130
+ /** Per-agent propose rate limiter (window state lives in gateway). */
131
+ checkMentalModelProposeRate: (now?: number) => { ok: true } | { ok: false; retryAtMs: number }
132
+ /** Live switchroom.yaml bytes for the duplicate-name pre-check. */
133
+ readLiveSwitchroomConfigText: () => string
134
+ /** memory.mental_models[] schema caps (src/config/schema.ts), injected under source names. */
135
+ MENTAL_MODEL_SOURCE_QUERY_MAX: number
136
+ MENTAL_MODEL_MAX_TOKENS_CAP: number
137
+ MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW: number
138
+ }
139
+
140
+ /**
141
+ * Build the card-tool handler family over the injected gateway deps. Bodies are
142
+ * verbatim from gateway.ts — behavior-preserving (#2996 P5-tail).
143
+ */
144
+ export function createCardToolHandlers(deps: CardToolHandlersDeps) {
145
+ const lockedBot = deps.lockedBot as CardToolBotApi
146
+ const {
147
+ robustApiCall,
148
+ assertAllowedChat,
149
+ VAULT_KEY_REGEX,
150
+ VAULT_KEY_REGEX_LABEL,
151
+ pendingVaultRequestSaves,
152
+ pendingVaultRequestAccesses,
153
+ pendingSecretRequests,
154
+ pendingCardStore,
155
+ sweepSecretRequests,
156
+ listViaBroker,
157
+ VAULT_REQUEST_ACCESS_TTL_MS,
158
+ pendingMentalModelProposes,
159
+ mentalModelProposeTimes,
160
+ checkMentalModelProposeRate,
161
+ readLiveSwitchroomConfigText,
162
+ MENTAL_MODEL_SOURCE_QUERY_MAX,
163
+ MENTAL_MODEL_MAX_TOKENS_CAP,
164
+ MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW,
165
+ } = deps
166
+
167
+ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
168
+ const chat_id = String(args.chat_id ?? '')
169
+ if (!chat_id) throw new Error('vault_request_save: chat_id is required')
170
+ const key = args.key as string
171
+ if (!key || typeof key !== 'string') throw new Error('vault_request_save: key is required')
172
+ const value = args.value as string
173
+ if (typeof value !== 'string' || value.length === 0) {
174
+ throw new Error('vault_request_save: value is required and must be a non-empty string')
175
+ }
176
+ const why = typeof args.why === 'string' ? args.why : undefined
177
+ const kindRaw = typeof args.kind === 'string' ? args.kind : 'string'
178
+ if (kindRaw !== 'string' && kindRaw !== 'binary') {
179
+ throw new Error('vault_request_save: kind must be "string" or "binary"')
180
+ }
181
+ assertAllowedChat(chat_id)
182
+
183
+ // Validate slug shape — vault keys must match a tight charset so the
184
+ // host CLI hints render cleanly and reference resolution stays
185
+ // predictable. Includes `/` so the canonical namespaced shape
186
+ // (`fatsecret/client_id`, `mff/agent-private-key`, ...) is
187
+ // accepted — issue #1047. The broker itself has no key regex
188
+ // (just `z.string().min(1)` in protocol.ts); this gateway-side
189
+ // gate is a UX guard, not a security boundary.
190
+ if (!VAULT_KEY_REGEX.test(key)) {
191
+ throw new Error(`vault_request_save: key must match ${VAULT_KEY_REGEX_LABEL}`)
192
+ }
193
+
194
+ const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
195
+
196
+ // Stage the request server-side. The value never leaves gateway memory
197
+ // until the user approves.
198
+ const stageId = randomBytes(4).toString('hex')
199
+ const pending: PendingVaultRequestSave = {
200
+ agent: agentSlug,
201
+ chat_id,
202
+ key,
203
+ kind: kindRaw,
204
+ value,
205
+ why,
206
+ staged_at: Date.now(),
207
+ }
208
+ pendingVaultRequestSaves.set(stageId, pending)
209
+ pendingVaultRequestSaves.sweep(Date.now())
210
+
211
+ // Send the approval card. #1075: route through retryWithThreadFallback
212
+ // so a deleted topic still lands the card on the main chat instead of
213
+ // crashing the tool call.
214
+ const text = renderVaultRequestSaveCard(pending, agentSlug)
215
+ const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
216
+ // Remember the agent's working topic so the save-outcome inbound resumes in it.
217
+ if (threadId != null) pending.threadId = threadId
218
+ const sent = await retryWithThreadFallback<{ message_id: number }>(
219
+ robustApiCall,
220
+ (tid) =>
221
+ lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
222
+ reply_markup: buildVaultRequestSaveKeyboard(stageId),
223
+ ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
224
+ }),
225
+ { threadId, chat_id, verb: 'vault_request_save.card' },
226
+ )
227
+ pending.card_message_id = sent.message_id
228
+ // Persist card METADATA (never the staged `value` — secrets hygiene) so a
229
+ // gateway restart doesn't strand the parked agent. A restored Save tap can't
230
+ // complete (value is gone) and degrades to a "value lost to restart" wake-up.
231
+ pendingCardStore.add({
232
+ family: 'vault_request_save',
233
+ stageId,
234
+ agent: pending.agent,
235
+ chatId: pending.chat_id,
236
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
237
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
238
+ key: pending.key,
239
+ kind: pending.kind,
240
+ ...(pending.why != null ? { why: pending.why } : {}),
241
+ stagedAt: pending.staged_at,
242
+ })
243
+
244
+ return {
245
+ content: [
246
+ {
247
+ type: 'text',
248
+ text: `vault_request_save: card sent (stage_id=${stageId}, key=${key}). The user must tap a button before the secret is persisted; do not assume success until you see the user's next message confirming the outcome.`,
249
+ },
250
+ ],
251
+ }
252
+ }
253
+
254
+ /**
255
+ * `vault_request_access` tool — agent surfaces an approval card asking
256
+ * the operator to grant a vault ACL it doesn't yet have. See #1012.
257
+ * Auth boundary: only operators on the gateway allowFrom list can tap;
258
+ * the agent itself can only REQUEST.
259
+ */
260
+ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
261
+ const chat_id = String(args.chat_id ?? '')
262
+ if (!chat_id) throw new Error('vault_request_access: chat_id is required')
263
+ const key = args.key as string
264
+ if (!key || typeof key !== 'string') throw new Error('vault_request_access: key is required')
265
+ if (!VAULT_KEY_REGEX.test(key)) {
266
+ throw new Error(`vault_request_access: key must match ${VAULT_KEY_REGEX_LABEL}`)
267
+ }
268
+ const scopeRaw = typeof args.scope === 'string' ? args.scope : 'read'
269
+ if (scopeRaw !== 'read' && scopeRaw !== 'write') {
270
+ throw new Error('vault_request_access: scope must be "read" or "write"')
271
+ }
272
+ // Accept `why` as an alias for `reason`: the sibling tool
273
+ // vault_request_save uses `why`, and agents cross-contaminate the two
274
+ // schemas — without the alias the rationale silently drops off the
275
+ // approval card and the operator sees "why: not provided".
276
+ const reason =
277
+ typeof args.reason === 'string' ? args.reason : typeof args.why === 'string' ? args.why : undefined
278
+ // Duration: accept a "30d" / "12h" string from the agent OR default
279
+ // to 30 days. Cap at 90 days — beyond that the operator should use
280
+ // the host CLI and pick the lifetime explicitly. Refuse "never"
281
+ // requests outright; agent-initiated grants must have a sunset.
282
+ let ttl_seconds = 30 * 24 * 60 * 60
283
+ const durRaw = args.duration
284
+ if (typeof durRaw === 'string' && durRaw.length > 0) {
285
+ const m = durRaw.match(/^(\d+)([dh])$/)
286
+ if (!m) {
287
+ throw new Error('vault_request_access: duration must look like "30d" or "12h"')
288
+ }
289
+ const n = Number(m[1])
290
+ const unit = m[2]
291
+ const parsed = unit === 'd' ? n * 86400 : n * 3600
292
+ const NINETY_DAYS = 90 * 86400
293
+ if (parsed <= 0 || parsed > NINETY_DAYS) {
294
+ throw new Error('vault_request_access: duration must be > 0 and <= 90d')
295
+ }
296
+ ttl_seconds = parsed
297
+ }
298
+ assertAllowedChat(chat_id)
299
+
300
+ const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
301
+
302
+ // Fix B (#1487 follow-up): if this agent's STANDING ACL already
303
+ // covers the key, do NOT render a card or mint a grant. Minting
304
+ // writes a `.vault-token` that — pre-#1487 — *shadowed* the standing
305
+ // ACL (the exact gymbro trap) and is simply redundant post-#1487.
306
+ // Determine coverage AUTHORITATIVELY by probing the broker AS THIS
307
+ // AGENT (no-token list over the per-agent socket — path-as-identity;
308
+ // the gateway runs in the agent's container so the broker attributes
309
+ // it to this agent). NOT a gateway-side config read: the gateway can
310
+ // see newer config than the broker has loaded, so a config-derived
311
+ // "covered" could be wrong where the broker still denies. `list`
312
+ // returns only ACL-visible key NAMES — never secret values. Read
313
+ // scope only: schedule.secrets[] confers read, not write.
314
+ if (scopeRaw === 'read') {
315
+ try {
316
+ const visible = await listViaBroker()
317
+ if (visible !== null && visible.includes(key)) {
318
+ return {
319
+ content: [
320
+ {
321
+ type: 'text',
322
+ text:
323
+ `vault_request_access: '${key}' is ALREADY covered by ${agentSlug}'s ` +
324
+ `standing ACL (schedule.secrets[]). No approval card or grant is needed — ` +
325
+ `read it directly: \`switchroom vault get ${key}\`. Do NOT request a grant ` +
326
+ `for this key (a minted token would shadow the standing ACL). If a read ` +
327
+ `still returns VAULT-BROKER-DENIED, the broker likely needs a restart to ` +
328
+ `pick up a recent config change — tell the operator; don't re-request.`,
329
+ },
330
+ ],
331
+ }
332
+ }
333
+ } catch {
334
+ // Probe failed (broker unreachable / transient): fall through to
335
+ // the normal card flow. Fail-open is correct here — a redundant
336
+ // card is harmless; suppressing a needed card is not.
337
+ }
338
+ }
339
+
340
+ const stageId = randomBytes(4).toString('hex')
341
+ const pending: PendingVaultRequestAccess = {
342
+ agent: agentSlug,
343
+ chat_id,
344
+ key,
345
+ scope: scopeRaw,
346
+ reason,
347
+ ttl_seconds,
348
+ staged_at: Date.now(),
349
+ }
350
+ pendingVaultRequestAccesses.set(stageId, pending)
351
+ pendingVaultRequestAccesses.sweep(Date.now())
352
+
353
+ // renderVaultRequestAccessCard self-hardens its field line breaks (this card
354
+ // is sent direct, bypassing the switchroomReply chokepoint).
355
+ const text = renderVaultRequestAccessCard(pending)
356
+ const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
357
+ // Remember the agent's working topic so the grant-outcome inbound resumes in it.
358
+ if (threadId != null) pending.threadId = threadId
359
+ // #1075: deleted-topic safe — fall back to main chat.
360
+ const sent = await retryWithThreadFallback<{ message_id: number }>(
361
+ robustApiCall,
362
+ (tid) =>
363
+ lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
364
+ reply_markup: buildVaultRequestAccessKeyboard(stageId),
365
+ ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
366
+ }),
367
+ { threadId, chat_id, verb: 'vault_request_access.card' },
368
+ )
369
+ pending.card_message_id = sent.message_id
370
+ // Persist card metadata (no secret material — this flow stages only the ACL
371
+ // request) so a gateway restart doesn't strand the parked agent.
372
+ pendingCardStore.add({
373
+ family: 'vault_request_access',
374
+ stageId,
375
+ agent: pending.agent,
376
+ chatId: pending.chat_id,
377
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
378
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
379
+ key: pending.key,
380
+ scope: pending.scope,
381
+ ...(pending.reason != null ? { reason: pending.reason } : {}),
382
+ ttlSeconds: pending.ttl_seconds,
383
+ stagedAt: pending.staged_at,
384
+ })
385
+
386
+ return {
387
+ content: [
388
+ {
389
+ type: 'text',
390
+ text: `vault_request_access: card sent (stage_id=${stageId}, key=${key}, scope=${scopeRaw}). Wait for the operator to tap Approve or Deny — do not retry the vault read until you see a confirmation message. If the card times out (${Math.round(VAULT_REQUEST_ACCESS_TTL_MS / 60000)} min) you can re-request.`,
391
+ },
392
+ ],
393
+ }
394
+ }
395
+
396
+ /**
397
+ * `request_secret` tool — agent surfaces a card asking the operator to
398
+ * provide a missing secret. No `value` arg: the value arrives via secure
399
+ * capture (the operator's next message after they tap [Provide securely]).
400
+ */
401
+ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
402
+ const chat_id = String(args.chat_id ?? '')
403
+ if (!chat_id) throw new Error('request_secret: chat_id is required')
404
+ const key = args.key as string
405
+ if (!key || typeof key !== 'string') throw new Error('request_secret: key is required')
406
+ const reason = typeof args.reason === 'string' ? args.reason : undefined
407
+ assertAllowedChat(chat_id)
408
+ if (!VAULT_KEY_REGEX.test(key)) {
409
+ throw new Error(`request_secret: key must match ${VAULT_KEY_REGEX_LABEL}`)
410
+ }
411
+ const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
412
+
413
+ // Dedupe: one open request per (chat, key). Drop any prior stage for
414
+ // the same target so the operator never sees stacked cards.
415
+ for (const [sid, p] of pendingSecretRequests) {
416
+ if (p.chat_id === chat_id && p.key === key) {
417
+ pendingSecretRequests.delete(sid)
418
+ pendingCardStore.remove(sid)
419
+ }
420
+ }
421
+
422
+ const stageId = randomBytes(4).toString('hex')
423
+ const pending: PendingSecretRequest = { agent: agentSlug, chat_id, key, reason, staged_at: Date.now() }
424
+ pendingSecretRequests.set(stageId, pending)
425
+ sweepSecretRequests()
426
+
427
+ const text = renderSecretRequestCard(pending)
428
+ const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
429
+ // Remember the agent's working topic so the provide/decline/fail inbound resumes in it.
430
+ if (threadId != null) pending.threadId = threadId
431
+ const sent = await retryWithThreadFallback<{ message_id: number }>(
432
+ robustApiCall,
433
+ (tid) =>
434
+ lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
435
+ reply_markup: buildSecretRequestKeyboard(stageId),
436
+ ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
437
+ }),
438
+ { threadId, chat_id, verb: 'request_secret.card' },
439
+ )
440
+ pending.card_message_id = sent.message_id
441
+ // Persist card metadata so a gateway restart doesn't strand the parked
442
+ // agent. request_secret holds NO value at staging time (the value arrives
443
+ // after the operator taps [Provide securely]), so nothing sensitive lands
444
+ // on disk here.
445
+ pendingCardStore.add({
446
+ family: 'request_secret',
447
+ stageId,
448
+ agent: pending.agent,
449
+ chatId: pending.chat_id,
450
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
451
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
452
+ key: pending.key,
453
+ ...(pending.reason != null ? { reason: pending.reason } : {}),
454
+ stagedAt: pending.staged_at,
455
+ })
456
+
457
+ return {
458
+ content: [
459
+ {
460
+ type: 'text',
461
+ text: `request_secret: card sent (stage_id=${stageId}, key=${key}). END YOUR TURN now and wait — a fresh inbound message arrives once the operator provides (or declines) the secret. Do NOT ask them to paste it as a normal message; the card handles it securely.`,
462
+ },
463
+ ],
464
+ }
465
+ }
466
+
467
+ /**
468
+ * `mental_model_propose` tool (hindsight Phase 5) — the agent surfaces a
469
+ * candidate mental model for the operator to approve. Mirrors the
470
+ * `vault_request_access` shape: the agent can only PROPOSE; the [Approve]/[Deny]
471
+ * tap is operator-gated (handleMentalModelProposeCallback), so an agent can
472
+ * never self-approve. On Approve the proposal is DECLARED — appended to the
473
+ * agent's memory.mental_models[] via the operator-approved config-edit path
474
+ * (reusing config_propose_edit apply+reconcile) — and ensured in the bank. On
475
+ * Deny nothing is written. Guardrails enforced here BEFORE any card:
476
+ * duplicate-name rejection against the agent's already-declared models, and a
477
+ * per-agent rate limit so proposals stay non-spammy.
478
+ */
479
+ async function executeMentalModelPropose(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
480
+ const chat_id = String(args.chat_id ?? '')
481
+ if (!chat_id) throw new Error('mental_model_propose: chat_id is required')
482
+ const name = typeof args.name === 'string' ? args.name.trim() : ''
483
+ if (!name) throw new Error('mental_model_propose: name is required')
484
+ if (!MENTAL_MODEL_NAME_REGEX.test(name)) {
485
+ throw new Error('mental_model_propose: name must be a slug (letters/digits/_/-, ≤64 chars, e.g. `training-plan-state`)')
486
+ }
487
+ const source_query = typeof args.source_query === 'string' ? args.source_query.trim() : ''
488
+ if (!source_query) throw new Error('mental_model_propose: source_query is required')
489
+ // Enforce the memory.mental_models[] schema cap (src/config/schema.ts) up-front
490
+ // so the operator never approves a card that then fails hostd config validation.
491
+ // The 2000-char ceiling also keeps the rendered card under Telegram's 4096-char
492
+ // message limit.
493
+ if (source_query.length > MENTAL_MODEL_SOURCE_QUERY_MAX) {
494
+ throw new Error(
495
+ `mental_model_propose: source_query is ${source_query.length} chars; the schema caps it at ${MENTAL_MODEL_SOURCE_QUERY_MAX} (a standing reflection query, not a document). Shorten it.`,
496
+ )
497
+ }
498
+ // Accept `why` as an alias for `reason` (mirrors the vault tools).
499
+ const reason =
500
+ typeof args.reason === 'string' ? args.reason : typeof args.why === 'string' ? args.why : undefined
501
+ let refresh_after_consolidation: boolean | undefined
502
+ if (args.refresh_after_consolidation !== undefined) {
503
+ if (typeof args.refresh_after_consolidation !== 'boolean') {
504
+ throw new Error('mental_model_propose: refresh_after_consolidation must be a boolean')
505
+ }
506
+ refresh_after_consolidation = args.refresh_after_consolidation
507
+ }
508
+ let max_tokens: number | undefined
509
+ if (args.max_tokens !== undefined) {
510
+ const n = Number(args.max_tokens)
511
+ if (!Number.isInteger(n) || n <= 0) {
512
+ throw new Error('mental_model_propose: max_tokens must be a positive integer')
513
+ }
514
+ // Enforce the schema ceiling (src/config/schema.ts) here so the card can't be
515
+ // approved into a config-validation failure downstream.
516
+ if (n > MENTAL_MODEL_MAX_TOKENS_CAP) {
517
+ throw new Error(
518
+ `mental_model_propose: max_tokens ${n} exceeds the schema cap of ${MENTAL_MODEL_MAX_TOKENS_CAP} (a mental model is a standing summary, not a corpus).`,
519
+ )
520
+ }
521
+ max_tokens = n
522
+ }
523
+ assertAllowedChat(chat_id)
524
+
525
+ const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
526
+
527
+ // Rate limit: a proposal is a rare, deliberate curation act — throttle so a
528
+ // looping agent can never spam the operator with cards.
529
+ const rate = checkMentalModelProposeRate()
530
+ if (!rate.ok) {
531
+ const retryAtIso = new Date(rate.retryAtMs).toISOString()
532
+ return {
533
+ content: [
534
+ {
535
+ type: 'text',
536
+ text:
537
+ `mental_model_propose: RATE-LIMITED (max ${MENTAL_MODEL_PROPOSE_MAX_PER_WINDOW} proposals/hour). ` +
538
+ `No card was posted. Next slot opens at ${retryAtIso}. Proposing mental models is meant to be ` +
539
+ `rare — batch or wait rather than re-firing.`,
540
+ },
541
+ ],
542
+ }
543
+ }
544
+
545
+ // Duplicate-name guard: reject a proposal for a model already DECLARED for
546
+ // this agent, BEFORE posting a card (the name is the idempotent-ensure key).
547
+ try {
548
+ const configText = readLiveSwitchroomConfigText()
549
+ const declared = readDeclaredMentalModelNames(configText, agentSlug)
550
+ if (declared.includes(name)) {
551
+ return {
552
+ content: [
553
+ {
554
+ type: 'text',
555
+ text:
556
+ `mental_model_propose: '${name}' is ALREADY a declared mental model for ${agentSlug} ` +
557
+ `(memory.mental_models[]). No card was posted — it already exists and is ensured in your ` +
558
+ `bank. Pick a different name if you meant a NEW model, or just use the existing one.`,
559
+ },
560
+ ],
561
+ }
562
+ }
563
+ } catch (err) {
564
+ // Config read failed (transient) — fall through to the card. The approve
565
+ // path re-reads and re-checks (dupe guard is defense-in-depth), so a
566
+ // redundant card is harmless; suppressing a needed card is not.
567
+ process.stderr.write(`telegram gateway: mental_model_propose dup pre-check read failed: ${(err as Error).message}\n`)
568
+ }
569
+
570
+ const stageId = randomBytes(4).toString('hex')
571
+ const pending: PendingMentalModelPropose = {
572
+ agent: agentSlug,
573
+ chat_id,
574
+ spec: {
575
+ name,
576
+ source_query,
577
+ ...(refresh_after_consolidation !== undefined ? { refresh_after_consolidation } : {}),
578
+ ...(max_tokens !== undefined ? { max_tokens } : {}),
579
+ },
580
+ ...(reason ? { reason } : {}),
581
+ staged_at: Date.now(),
582
+ }
583
+ pendingMentalModelProposes.set(stageId, pending)
584
+ pendingMentalModelProposes.sweep(Date.now())
585
+
586
+ const text = renderMentalModelProposeCard({
587
+ agent: agentSlug,
588
+ name,
589
+ source_query,
590
+ ...(reason ? { reason } : {}),
591
+ ...(refresh_after_consolidation !== undefined ? { refresh_after_consolidation } : {}),
592
+ })
593
+ const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
594
+ if (threadId != null) pending.threadId = threadId
595
+ const sent = await retryWithThreadFallback<{ message_id: number }>(
596
+ robustApiCall,
597
+ (tid) =>
598
+ lockedBot.api.sendRichMessage(chat_id, richMessage(text), {
599
+ reply_markup: buildMentalModelProposeKeyboard(stageId),
600
+ ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
601
+ }),
602
+ { threadId, chat_id, verb: 'mental_model_propose.card' },
603
+ )
604
+ pending.card_message_id = sent.message_id
605
+ // Persist card metadata (the proposed DECLARATION is not secret material) so
606
+ // a gateway restart doesn't strand the parked agent.
607
+ pendingCardStore.add({
608
+ family: 'mental_model_propose',
609
+ stageId,
610
+ agent: pending.agent,
611
+ chatId: pending.chat_id,
612
+ ...(pending.card_message_id != null ? { cardMessageId: pending.card_message_id } : {}),
613
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
614
+ spec: pending.spec,
615
+ ...(pending.reason != null ? { reason: pending.reason } : {}),
616
+ stagedAt: pending.staged_at,
617
+ })
618
+ // Only count a proposal against the rate budget once its card actually
619
+ // posted (validation errors / dupes don't consume the budget).
620
+ mentalModelProposeTimes.push(Date.now())
621
+
622
+ return {
623
+ content: [
624
+ {
625
+ type: 'text',
626
+ text:
627
+ `mental_model_propose: card sent (stage_id=${stageId}, name=${name}). Wait for the operator to tap ` +
628
+ `Approve or Deny — END YOUR TURN cleanly. A fresh inbound arrives with the outcome ` +
629
+ `(source=mental_model_proposal_applied / mental_model_proposal_denied). Do NOT re-propose this ` +
630
+ `model while the card is open.`,
631
+ },
632
+ ],
633
+ }
634
+ }
635
+
636
+ return { executeVaultRequestSave, executeVaultRequestAccess, executeRequestSecret, executeMentalModelPropose }
637
+ }
638
+
639
+ export type CardToolHandlers = ReturnType<typeof createCardToolHandlers>