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,1133 @@
1
+ /**
2
+ * Inbound intercept gauntlet (switchroom#2996 P7).
3
+ *
4
+ * The side-effecting early-return checks that sit at the head of
5
+ * `handleInbound`, extracted one-per-PR out of gateway.ts into pure,
6
+ * unit-testable functions that take (a) the per-message routing facts captured
7
+ * in `handleInbound` before the gauntlet and (b) an `InboundInterceptorDeps`
8
+ * object holding LIVE references to the gateway's collaborators (functions,
9
+ * Maps, the bound `bot.api` surface) — never value snapshots. Each returns an
10
+ * `InterceptOutcome`; when `handled` is true the caller must return immediately
11
+ * (the intercept has fully serviced the message and it is never forwarded).
12
+ *
13
+ * Ordering is load-bearing and preserved by the caller: the functions here fire
14
+ * in the same sequence they did inline. The characterization harness
15
+ * (tests/inbound-router-characterization.test.ts) is the parity oracle.
16
+ */
17
+
18
+ import type { Context } from 'grammy'
19
+ import type { ReactionTypeEmoji } from 'grammy/types'
20
+ import { parseStopKeyword, buildStopReply } from './stop-command.js'
21
+ import { decideInterruptTiming, resolveSafeBoundaryEnabled } from './interrupt-defer.js'
22
+ import { naturalAction } from '../permission-title.js'
23
+ import { richMessage } from '../rich-send.js'
24
+ import { renderVaultRequestSaveCard, buildVaultRequestSaveKeyboard } from './vault-request-save-card.js'
25
+ import { runPipeline } from '../secret-detect/pipeline.js'
26
+ import { maskToken } from '../secret-detect/mask.js'
27
+ import { defaultVaultWrite, defaultVaultList } from '../secret-detect/vault-write.js'
28
+ import { parseVaultCliError, renderVaultCliError } from '../secret-detect/vault-error.js'
29
+ import { detectSecrets } from '../secret-detect/index.js'
30
+ import type { StagingMap } from '../secret-detect/staging.js'
31
+ import type { PendingAuthAddFlow } from './auth-add-flow.js'
32
+ import type {
33
+ createCallbackQueryHandlers,
34
+ PendingVaultOp,
35
+ PendingVaultRequestSave,
36
+ PendingVaultRequestAccess,
37
+ DeferredSecret,
38
+ } from './callback-query-handlers.js'
39
+ import type { InlineKeyboard } from 'grammy'
40
+ import {
41
+ pendingLoopbackFlows,
42
+ submitLoopbackRedirect,
43
+ cancelLoopbackFlow,
44
+ shouldConsumeLoopbackPaste,
45
+ } from './auth-loopback-relay.js'
46
+ import type { AddAccountCredentials } from '../../src/auth/broker/client.js'
47
+ import type { parseInterruptMarker } from '../interrupt-marker.js'
48
+ import type { RetryCallOpts } from '../retry-api-call.js'
49
+ import type { AttachmentMeta } from './gateway.js'
50
+
51
+ /**
52
+ * Result of an intercept. `handled: true` means the intercept fully serviced
53
+ * the message and the caller must `return` (never forward to the agent).
54
+ */
55
+ export interface InterceptOutcome {
56
+ handled: boolean
57
+ }
58
+
59
+ /**
60
+ * Live collaborators injected from gateway.ts module scope. Grows as each P7
61
+ * stage moves an intercept across this boundary. Every field is a live
62
+ * reference (function / Map / object) — never a captured value snapshot.
63
+ *
64
+ * `botApi` is a LAZY accessor for the gateway's bound `bot.api` surface — the
65
+ * grammY `bot` is assigned late (only on the prod boot path), so the deref is
66
+ * deferred to call time. Injecting it as `deps.botApi().sendMessage(...)` also
67
+ * keeps the raw Telegram send a false-positive-free non-`bot.api.` literal that
68
+ * still rides the same `swallowingApiCall` retry wrapper it did inline.
69
+ */
70
+ export interface InboundInterceptorDeps {
71
+ pendingSessionCommand: { list: () => Array<{ kind: string; targetLabel: string }> }
72
+ turnInFlightForGate: () => boolean
73
+ sendReaction: (
74
+ chatId: string,
75
+ messageId: number,
76
+ emoji: ReactionTypeEmoji['emoji'],
77
+ ) => Promise<unknown>
78
+ executeHaltNow: (origin: string) => Promise<void>
79
+ swallowingApiCall: <T>(fn: () => Promise<T>, opts?: RetryCallOpts) => Promise<T | undefined>
80
+ botApi: () => {
81
+ sendMessage: (
82
+ chatId: string,
83
+ text: string,
84
+ other?: { message_thread_id?: number },
85
+ ) => Promise<unknown>
86
+ deleteMessage: (chatId: string, messageId: number) => Promise<unknown>
87
+ }
88
+ log: (line: string) => void
89
+ // --- interrupt-marker (P7 PR-3) collaborators ---
90
+ loadAccess: () => { interruptSafeBoundary?: boolean; allowFrom: string[] }
91
+ toolFlightTracker: { isMidToolCall: () => boolean; inFlightCount: () => number }
92
+ cancelInterruptedObligation: () => void
93
+ // --- permission-reply (P7 PR-4) collaborators ---
94
+ dispatchPermissionVerdict: (ev: {
95
+ type: 'permission'
96
+ requestId: string
97
+ behavior: 'allow' | 'deny'
98
+ }) => void
99
+ resumeReactionAfterVerdict: () => void
100
+ pendingPermissions: {
101
+ get: (requestId: string) => { tool_name: string; input_preview: string } | undefined
102
+ }
103
+ postPermissionResumeMessage: (opts: { behavior: 'allow' | 'deny'; action: string }) => void
104
+ // --- auth-add paste-back (P7 PR-5) collaborators ---
105
+ pendingAuthAddFlows: {
106
+ get: (key: string) => PendingAuthAddFlow | undefined
107
+ delete: (key: string) => boolean
108
+ }
109
+ looksLikeAuthCode: (text: string) => boolean
110
+ /** REAUTH_INTERCEPT_TTL_MS — a fixed constant, safe to cross as a value. */
111
+ reauthInterceptTtlMs: number
112
+ submitAccountAuthCode: (flow: PendingAuthAddFlow, code: string) => Promise<AddAccountCredentials>
113
+ addAccountViaBroker: (
114
+ label: string,
115
+ credentials: AddAccountCredentials,
116
+ opts: { replace?: boolean },
117
+ ) => Promise<unknown>
118
+ cleanAuthAddScratchDir: (scratchDir: string) => void
119
+ cancelAccountAuthSession: (flow: PendingAuthAddFlow) => void
120
+ switchroomReply: (
121
+ ctx: Context,
122
+ text: string,
123
+ options?: { html?: boolean; reply_markup?: InlineKeyboard },
124
+ ) => Promise<unknown>
125
+ escapeHtmlForTg: (text: string) => string
126
+ /** Closure over redactAuthCodeMessage + the gateway's redactAuthCodeApi (#488). */
127
+ redactAuthCode: (chatId: string, msgId: number | null) => void
128
+ // --- reauth paste-back (P7 PR-7) collaborators ---
129
+ pendingReauthFlows: {
130
+ get: (key: string) => { agent: string; startedAt: number } | undefined
131
+ delete: (key: string) => boolean
132
+ }
133
+ execAuthCode: (
134
+ agent: string,
135
+ code: string,
136
+ ) =>
137
+ | { result: { outcome: unknown; instructions: string[] }; errorText: null }
138
+ | { result: null; errorText: string }
139
+ renderAuthCodeOutcome: (outcome: unknown) => string | null
140
+ preBlock: (text: string) => string
141
+ formatSwitchroomOutput: (output: string) => string
142
+ formatAuthOutputForTelegram: (output: string) => { text: string; url: string | null }
143
+ // --- vault intercept (P7 PR-8) collaborators ---
144
+ pendingVaultOps: {
145
+ get: (chatId: string) => PendingVaultOp | undefined
146
+ delete: (chatId: string) => boolean
147
+ set: (chatId: string, v: PendingVaultOp) => void
148
+ }
149
+ /** VAULT_INPUT_TTL_MS / VAULT_PASSPHRASE_TTL_MS — fixed constants. */
150
+ vaultInputTtlMs: number
151
+ vaultPassphraseTtlMs: number
152
+ vaultPassphraseCache: {
153
+ get: (chatId: string) => { passphrase: string; expiresAt: number } | undefined
154
+ set: (chatId: string, v: { passphrase: string; expiresAt: number }) => void
155
+ }
156
+ deleteSensitiveMessage: (chatId: string, msgId: number, reason: string) => Promise<void>
157
+ executeVaultOp: (
158
+ ctx: Context,
159
+ chatId: string,
160
+ op: 'list' | 'get' | 'set' | 'delete',
161
+ key: string | undefined,
162
+ passphrase: string,
163
+ setValue: string | undefined,
164
+ ) => Promise<void>
165
+ unlockViaBroker: (passphrase: string) => Promise<{ ok: boolean; msg?: string }>
166
+ /** LAZY accessor — callbackQueryHandlers is a boot-assigned `let` in gateway.ts. */
167
+ callbackQueryHandlers: () => Pick<
168
+ ReturnType<typeof createCallbackQueryHandlers>,
169
+ | 'executeDeferredSecretSave'
170
+ | 'performVaultAccessApproval'
171
+ | 'parseGrantDuration'
172
+ | 'grantWizardConfirm'
173
+ >
174
+ pendingVaultRequestAccesses: {
175
+ get: (stageId: string) => PendingVaultRequestAccess | undefined
176
+ }
177
+ pendingVaultRequestSaves: {
178
+ get: (stageId: string) => PendingVaultRequestSave | undefined
179
+ }
180
+ vaultKeyRegex: RegExp
181
+ vaultKeyRegexLabel: string
182
+ // --- secret-detect (P7 PR-9) collaborators ---
183
+ secretStaging: StagingMap
184
+ awaitingAuthCodeAt: { get: (chatId: string) => number | undefined; delete: (chatId: string) => boolean }
185
+ /** AUTH_CODE_CONTEXT_TTL_MS — fixed constant. */
186
+ authCodeContextTtlMs: number
187
+ deferredSecrets: { set: (key: string, v: DeferredSecret) => void }
188
+ deferredKey: (chatId: string, messageId: number) => string
189
+ mintDeferredSecretKernelRequest: (slug: string, approverSet: string[]) => Promise<string | null>
190
+ buildDeferredSecretKeyboard: (deferKey: string) => InlineKeyboard
191
+ }
192
+
193
+ /**
194
+ * Per-message routing facts captured in `handleInbound` before the gauntlet.
195
+ * `extraAttachments` is only length-inspected here, so it is typed structurally
196
+ * to avoid pulling gateway-local attachment types across the boundary.
197
+ */
198
+ export interface StopKeywordParams {
199
+ text: string
200
+ chat_id: string
201
+ msgId: number | undefined
202
+ messageThreadId: number | undefined
203
+ downloadImage: (() => Promise<string | undefined>) | undefined
204
+ attachment: AttachmentMeta | undefined
205
+ extraAttachments: readonly unknown[] | undefined
206
+ }
207
+
208
+ /**
209
+ * #3020 — bare operator "stop": the kill switch without the `!` marker.
210
+ * Exact-word only (`parseStopKeyword`): "stop the build" flows to the agent as
211
+ * a normal turn. Intercepted here (never forwarded) so it fires mid-turn
212
+ * instead of queueing behind the very turn it's trying to cancel. Pure text
213
+ * only (7b): a photo/attachment captioned "stop" is content for the agent, not
214
+ * a halt request — it flows through as a normal turn.
215
+ *
216
+ * Authorization: the same allowFrom gate as any inbound (the caller's `gate()`
217
+ * runs before this) — unauthorized senders never reach here.
218
+ */
219
+ export async function interceptStopKeyword(
220
+ p: StopKeywordParams,
221
+ deps: InboundInterceptorDeps,
222
+ ): Promise<InterceptOutcome> {
223
+ if (
224
+ !(
225
+ parseStopKeyword(p.text) &&
226
+ p.downloadImage == null &&
227
+ p.attachment == null &&
228
+ (p.extraAttachments == null || p.extraAttachments.length === 0)
229
+ )
230
+ ) {
231
+ return { handled: false }
232
+ }
233
+
234
+ const queuedLabels = deps.pendingSessionCommand.list().map(c => `/${c.kind} ${c.targetLabel}`)
235
+ const inFlight = deps.turnInFlightForGate()
236
+ deps.log(
237
+ `telegram gateway: stop-keyword received chat_id=${p.chat_id} in_flight_turn=${inFlight} ` +
238
+ `queued_cmds=${queuedLabels.length}\n`,
239
+ )
240
+ if (inFlight) {
241
+ if (p.msgId != null) {
242
+ void deps.sendReaction(p.chat_id, p.msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
243
+ }
244
+ await deps.executeHaltNow('stop-keyword')
245
+ }
246
+ const stopReply = buildStopReply(inFlight, queuedLabels)
247
+ // #1075: thread-id-bearing — swallow so a deleted topic can't crash us.
248
+ await deps.swallowingApiCall(
249
+ () =>
250
+ deps.botApi().sendMessage(
251
+ p.chat_id,
252
+ stopReply.text,
253
+ p.messageThreadId != null ? { message_thread_id: p.messageThreadId } : {},
254
+ ),
255
+ {
256
+ chat_id: p.chat_id,
257
+ verb: 'stop-keyword-reply',
258
+ ...(p.messageThreadId != null ? { threadId: p.messageThreadId } : {}),
259
+ },
260
+ )
261
+ return { handled: true }
262
+ }
263
+
264
+ /** Per-message facts for the interrupt-marker intercept (P7 PR-3). */
265
+ export interface InterruptMarkerParams {
266
+ /** The parse result — the caller keeps `parseInterruptMarker(text)` inline
267
+ * because `interrupt.isInterrupt` also feeds the downstream envelope/delivery
268
+ * path; the same value object crosses here so parse happens exactly once. */
269
+ interrupt: ReturnType<typeof parseInterruptMarker>
270
+ chat_id: string
271
+ msgId: number | undefined
272
+ messageThreadId: number | undefined
273
+ }
274
+
275
+ /**
276
+ * Outcome of the interrupt-marker intercept. `handled: true` — empty `!` fully
277
+ * serviced (halt fired + follow-up sent), caller returns. Otherwise the caller
278
+ * continues the pipeline with `replacedText` (the `!` body for an interrupt,
279
+ * the original text untouched for a non-interrupt) and `deferInterrupt` — an
280
+ * AT-RECEIPT captured VALUE the delivery site consumes (Problem B: when true,
281
+ * the synchronous SIGINT was skipped and the built inbound is stashed at the
282
+ * delivery site to fire at a safe boundary).
283
+ */
284
+ export type InterruptMarkerOutcome =
285
+ | { handled: true }
286
+ | { handled: false; deferInterrupt: boolean; replacedText: string | null }
287
+
288
+ /**
289
+ * `!`-prefix interrupt (#575): SIGINT the in-flight turn and replace the
290
+ * inbound with the marker body. Empty `!` is a pure halt (#3020) — same shared
291
+ * `executeHaltNow` sequence as /stop. Safe-boundary deferral (Problem B): a
292
+ * non-empty `!` mid-tool-call defers the SIGINT to the delivery site instead
293
+ * of firing synchronously.
294
+ *
295
+ * The tmux SIGINT keeps its dynamic `import('../../src/agents/tmux.js')`
296
+ * exactly as inline — the gateway runs INSIDE the agent container in docker
297
+ * mode, so `sendAgentInterrupt` (tmux send-keys, local socket) is used instead
298
+ * of the docker-inspect PID probe (UAT 2026-05-13 discovery).
299
+ */
300
+ export async function interceptInterruptMarker(
301
+ p: InterruptMarkerParams,
302
+ deps: InboundInterceptorDeps,
303
+ ): Promise<InterruptMarkerOutcome> {
304
+ const { interrupt } = p
305
+ if (!interrupt.isInterrupt) {
306
+ return { handled: false, deferInterrupt: false, replacedText: null }
307
+ }
308
+
309
+ const agentName = process.env.SWITCHROOM_AGENT_NAME
310
+ const access = deps.loadAccess()
311
+ const deferInterrupt =
312
+ !interrupt.emptyBody &&
313
+ decideInterruptTiming({
314
+ safeBoundaryEnabled: resolveSafeBoundaryEnabled(access.interruptSafeBoundary),
315
+ midToolCall: deps.toolFlightTracker.isMidToolCall(),
316
+ }) === 'defer'
317
+ deps.log(
318
+ `telegram gateway: interrupt-marker received chat_id=${p.chat_id} agent=${agentName ?? '-'} ` +
319
+ `body_len=${interrupt.body.length} empty=${interrupt.emptyBody} defer=${deferInterrupt} ` +
320
+ `in_flight=${deps.toolFlightTracker.inFlightCount()}\n`,
321
+ )
322
+ if (p.msgId != null) {
323
+ void deps.sendReaction(p.chat_id, p.msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
324
+ }
325
+ if (interrupt.emptyBody) {
326
+ // #3020: empty `!` is a pure halt (no replacement body) — same shared
327
+ // sequence as /stop: safe-boundary deferral, tmux C-c, obligation
328
+ // cancel, deterministic busy release.
329
+ await deps.executeHaltNow('bang-empty')
330
+ // #1075: thread-id-bearing — route through swallowingApiCall so
331
+ // a deleted topic doesn't crash the gateway; the reaction
332
+ // already acked the user so a missing follow-up is tolerable.
333
+ await deps.swallowingApiCall(
334
+ () =>
335
+ deps.botApi().sendMessage(
336
+ p.chat_id,
337
+ '⚡ Interrupted. Send your replacement instruction now.',
338
+ p.messageThreadId != null ? { message_thread_id: p.messageThreadId } : {},
339
+ ),
340
+ {
341
+ chat_id: p.chat_id,
342
+ verb: 'interrupt-empty-body',
343
+ ...(p.messageThreadId != null ? { threadId: p.messageThreadId } : {}),
344
+ },
345
+ )
346
+ return { handled: true }
347
+ }
348
+ if (agentName && !deferInterrupt) {
349
+ try {
350
+ // The gateway runs INSIDE the agent container in docker mode,
351
+ // so calling `interruptAgent` (which probes `docker inspect`
352
+ // for the PID) always returns "no running PID" — the host's
353
+ // docker socket isn't visible to us. Skip the PID round-trip
354
+ // entirely and use tmux send-keys directly: the tmux socket
355
+ // is local to our container. Discovered during UAT overnight
356
+ // 2026-05-13 — pre-fix every `!` interrupt produced an
357
+ // "Agent has no running PID" stderr line and the user got
358
+ // ghosted because the SIGINT never fired.
359
+ const { sendAgentInterrupt } = await import('../../src/agents/tmux.js')
360
+ const r = sendAgentInterrupt({ agentName })
361
+ if ('ok' in r) {
362
+ deps.log(
363
+ `telegram gateway: interrupt-marker SIGINT delivered via tmux send-keys agent=${agentName}\n`,
364
+ )
365
+ } else {
366
+ deps.log(
367
+ `telegram gateway: interrupt-marker SIGINT via tmux failed agent=${agentName}: ${r.error}\n`,
368
+ )
369
+ }
370
+ } catch (err) {
371
+ deps.log(`telegram gateway: interrupt-marker SIGINT failed: ${(err as Error).message}\n`)
372
+ }
373
+ // The SIGINT just killed the in-flight turn — cancel its obligation so the
374
+ // interrupted (user-redirected) question isn't re-presented/escalated later.
375
+ deps.cancelInterruptedObligation()
376
+ }
377
+ // Replace the inbound text with the body and continue normal
378
+ // processing. The agent receives a fresh turn with no `!` prefix.
379
+ return { handled: false, deferInterrupt, replacedText: interrupt.body }
380
+ }
381
+
382
+ /**
383
+ * `y <id>` / `n <id>` permission verdict reply. The 5-char request id
384
+ * deliberately excludes `l` (ambiguity with `1`/`I` on phone keyboards).
385
+ * Moved verbatim from gateway.ts (P7 PR-4).
386
+ */
387
+ const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
388
+
389
+ /** Per-message facts for the permission-reply intercept (P7 PR-4). */
390
+ export interface PermissionReplyParams {
391
+ text: string
392
+ chat_id: string
393
+ msgId: number | undefined
394
+ }
395
+
396
+ /**
397
+ * Text-reply permission verdict (`y ab3de` / `no ab3de`): forward the verdict
398
+ * to the connected bridge, un-park the status reaction, post the
399
+ * "continuing…" resume line, and ack with ✅/❌. Fully intercepts — the
400
+ * verdict text is never forwarded to the agent as a turn.
401
+ */
402
+ export function interceptPermissionReply(
403
+ p: PermissionReplyParams,
404
+ deps: InboundInterceptorDeps,
405
+ ): InterceptOutcome {
406
+ const permMatch = PERMISSION_REPLY_RE.exec(p.text)
407
+ if (!permMatch) return { handled: false }
408
+ // Forward permission reply to connected bridge
409
+ const behavior = permMatch[1]!.toLowerCase().startsWith('y') ? 'allow' : 'deny'
410
+ const request_id = permMatch[2]!.toLowerCase()
411
+ deps.dispatchPermissionVerdict({
412
+ type: 'permission',
413
+ requestId: request_id,
414
+ behavior,
415
+ })
416
+ deps.resumeReactionAfterVerdict()
417
+ const ftDetails = deps.pendingPermissions.get(request_id)
418
+ deps.postPermissionResumeMessage({
419
+ behavior,
420
+ action: ftDetails ? naturalAction(ftDetails.tool_name, ftDetails.input_preview) : '',
421
+ })
422
+ if (p.msgId != null) {
423
+ const emoji = behavior === 'allow' ? '✅' : '❌'
424
+ void deps.sendReaction(p.chat_id, p.msgId, emoji as ReactionTypeEmoji['emoji']).catch(() => {})
425
+ }
426
+ return { handled: true }
427
+ }
428
+
429
+ /** Per-message facts for the auth-add paste-back intercept (P7 PR-5). */
430
+ export interface AuthAddParams {
431
+ ctx: Context
432
+ text: string
433
+ chat_id: string
434
+ msgId: number | undefined
435
+ /** chatKey(chat, thread) — computed once in handleInbound and shared by the
436
+ * auth-add / loopback / reauth / vault intercepts (cross-topic isolation). */
437
+ interceptKey: string
438
+ }
439
+
440
+ /**
441
+ * `/auth add` paste-back intercept — sibling to pendingReauthFlows. Both
442
+ * intercepts are deliberate so the LLM never sees the OAuth code (it doesn't
443
+ * need to + plaintext OAuth in chat history is bad hygiene). The add-flow
444
+ * intercept comes first because /auth add creates fresh credentials at the
445
+ * broker layer, vs /reauth which mutates an existing agent's slot — different
446
+ * success paths.
447
+ *
448
+ * PR3 supergroup-mode: keyed by chatKey(chat, thread) so an OAuth code pasted
449
+ * into topic A isn't intercepted when topic B has a separate /auth add flow
450
+ * pending (security: prevents cross-topic credential mis-attribution).
451
+ *
452
+ * A stale (TTL-expired) pending entry is dropped but the message falls through
453
+ * to the later intercepts (`handled: false`), exactly as inline.
454
+ */
455
+ export async function interceptAuthAdd(
456
+ p: AuthAddParams,
457
+ deps: InboundInterceptorDeps,
458
+ ): Promise<InterceptOutcome> {
459
+ const pendingAdd = deps.pendingAuthAddFlows.get(p.interceptKey)
460
+ if (!(pendingAdd && deps.looksLikeAuthCode(p.text))) return { handled: false }
461
+ const elapsed = Date.now() - pendingAdd.startedAt
462
+ if (elapsed < deps.reauthInterceptTtlMs) {
463
+ deps.pendingAuthAddFlows.delete(p.interceptKey)
464
+ try {
465
+ const credentials = await deps.submitAccountAuthCode(pendingAdd, p.text.trim())
466
+ try {
467
+ await deps.addAccountViaBroker(pendingAdd.label, credentials, { replace: false })
468
+ // success — wipe scratch dir now that the broker owns the creds
469
+ deps.cleanAuthAddScratchDir(pendingAdd.scratchDir)
470
+ await deps.switchroomReply(
471
+ p.ctx,
472
+ `✓ Account \`${pendingAdd.label}\` added.\n` +
473
+ `The fleet's active account hasn't changed. Send ` +
474
+ `\`/auth use ${deps.escapeHtmlForTg(pendingAdd.label)}\` to switch to it.`,
475
+ { html: true },
476
+ )
477
+ } catch (brokerErr) {
478
+ // Broker rejected (e.g. label already exists). Wipe scratch
479
+ // either way — the credentials are useless without broker
480
+ // bookkeeping.
481
+ deps.cleanAuthAddScratchDir(pendingAdd.scratchDir)
482
+ await deps.switchroomReply(
483
+ p.ctx,
484
+ `**/auth add failed at broker:** ${deps.escapeHtmlForTg((brokerErr as Error)?.message ?? String(brokerErr))}`,
485
+ { html: true },
486
+ )
487
+ }
488
+ } catch (err) {
489
+ // submitAccountAuthCode wiped the scratch dir on its own
490
+ // failure paths (timeout, child exit, stdin broken).
491
+ await deps.switchroomReply(
492
+ p.ctx,
493
+ `**/auth add code failed:** ${deps.escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
494
+ { html: true },
495
+ )
496
+ }
497
+ // Redact the OAuth code paste from chat history (#488).
498
+ deps.redactAuthCode(p.chat_id, p.msgId ?? null)
499
+ return { handled: true }
500
+ }
501
+ // Stale — drop the pending entry but let the message fall through
502
+ // to other intercepts (defensively wipe scratch).
503
+ deps.cancelAccountAuthSession(pendingAdd)
504
+ deps.pendingAuthAddFlows.delete(p.interceptKey)
505
+ return { handled: false }
506
+ }
507
+
508
+ /**
509
+ * Loopback OAuth relay paste-back intercept (issue #2582) — sibling to the
510
+ * /auth add intercept above. When a Google/Microsoft loopback relay is pending
511
+ * for this chat and the operator pastes their `127.0.0.1:<port>` redirect URL,
512
+ * validate `state` and hand the code to the waiting CLI listener. The LLM
513
+ * never sees the code (same hygiene rationale as auth-add). Consume-gate is
514
+ * deliberately narrow (PR #3100 review finding 1): only a message that
515
+ * actually parses as a loopback redirect — carrying a code, or a provider
516
+ * `error` param — is consumed and deleted; unrelated chatter that merely
517
+ * mentions localhost/127.0.0.1 flows through untouched.
518
+ *
519
+ * Includes the fail-safe redaction tail (security audit #3084, F2/F3): a
520
+ * loopback-looking paste with no live flow (stale TTL, malformed, no flow at
521
+ * all) is redacted and dropped — it must NEVER reach the (prompt-injectable)
522
+ * agent session while carrying a possibly-live credential.
523
+ *
524
+ * The loopback state/functions are imported directly from
525
+ * auth-loopback-relay.ts (they already own their state module — same live Map
526
+ * instance the gateway uses).
527
+ */
528
+ export async function interceptLoopbackRelay(
529
+ p: AuthAddParams,
530
+ deps: InboundInterceptorDeps,
531
+ ): Promise<InterceptOutcome> {
532
+ const pendingLoop = pendingLoopbackFlows.get(p.interceptKey)
533
+ if (pendingLoop && shouldConsumeLoopbackPaste(p.text)) {
534
+ const elapsed = Date.now() - pendingLoop.startedAt
535
+ if (elapsed < deps.reauthInterceptTtlMs) {
536
+ if (pendingLoop.submitting) {
537
+ // A submit is already in flight (double-paste race). Don't call
538
+ // submitLoopbackRedirect — it would answer a non-retryable "already
539
+ // completed" and we'd delete the entry out from under the first
540
+ // submit. Benign ack; still redact (the paste carries a live code).
541
+ await deps.switchroomReply(
542
+ p.ctx,
543
+ '_Still finishing the previous paste — one moment._',
544
+ { html: true },
545
+ )
546
+ deps.redactAuthCode(p.chat_id, p.msgId ?? null)
547
+ return { handled: true }
548
+ }
549
+ const result = await submitLoopbackRedirect(pendingLoop, p.text.trim())
550
+ if (result.ok) {
551
+ pendingLoopbackFlows.delete(p.interceptKey)
552
+ await deps.switchroomReply(
553
+ p.ctx,
554
+ `✓ ${pendingLoop.provider === 'google' ? 'Google' : 'Microsoft'} account ` +
555
+ `\`${deps.escapeHtmlForTg(pendingLoop.email)}\` registered with the auth-broker.`,
556
+ { html: true },
557
+ )
558
+ // Redact the pasted redirect (carries the OAuth code) from history.
559
+ deps.redactAuthCode(p.chat_id, p.msgId ?? null)
560
+ return { handled: true }
561
+ }
562
+ if (result.retryable) {
563
+ // Keep the flow pending so the operator can paste again.
564
+ await deps.switchroomReply(
565
+ p.ctx,
566
+ `**Paste not accepted:** ${deps.escapeHtmlForTg(result.reason)}\n` +
567
+ `Re-open the consent URL, approve, and paste the full ` +
568
+ `\`127.0.0.1\` URL from your address bar. \`/auth ${pendingLoop.provider} cancel\` to abort.`,
569
+ { html: true },
570
+ )
571
+ // Redact even a rejected paste — it may still carry a live code.
572
+ deps.redactAuthCode(p.chat_id, p.msgId ?? null)
573
+ return { handled: true }
574
+ }
575
+ // Non-retryable — the flow is spent. Kill the CLI child before
576
+ // dropping the entry (re-review finding, PR #3100): on the attempts-
577
+ // exhausted path the child is still alive with a bound 127.0.0.1
578
+ // listener and nothing else would ever reap it. cancelLoopbackFlow is
579
+ // idempotent — safe on the already-exited / timed-out paths too.
580
+ cancelLoopbackFlow(pendingLoop)
581
+ pendingLoopbackFlows.delete(p.interceptKey)
582
+ await deps.switchroomReply(
583
+ p.ctx,
584
+ `**/auth ${pendingLoop.provider} add failed:** ${deps.escapeHtmlForTg(result.reason)}`,
585
+ { html: true },
586
+ )
587
+ deps.redactAuthCode(p.chat_id, p.msgId ?? null)
588
+ return { handled: true }
589
+ }
590
+ // Stale — the intercept window has closed. Kill the child and drop the
591
+ // entry, then fall through to the fail-safe below. We deliberately do NOT
592
+ // let the paste reach the agent: the pasted `code` may still be live
593
+ // (security audit #3084, F3), so the fail-safe redacts it.
594
+ cancelLoopbackFlow(pendingLoop)
595
+ pendingLoopbackFlows.delete(p.interceptKey)
596
+ }
597
+
598
+ // Fail-safe redaction (security audit #3084, F2/F3). A message that looks
599
+ // like a loopback OAuth redirect/code — even one too malformed to parse
600
+ // cleanly, or one that arrived just after the intercept TTL closed, or one
601
+ // with no active flow at all — must NEVER reach the (prompt-injectable)
602
+ // agent session or linger unredacted in chat while carrying a possibly-live
603
+ // credential. shouldConsumeLoopbackPaste is narrow (requires a loopback host
604
+ // reference AND a code/error param), so ordinary chatter mentioning
605
+ // localhost flows through untouched. Redact and drop rather than forward.
606
+ if (shouldConsumeLoopbackPaste(p.text)) {
607
+ deps.redactAuthCode(p.chat_id, p.msgId ?? null)
608
+ await deps.switchroomReply(
609
+ p.ctx,
610
+ '_That looked like an OAuth redirect/code, so I removed it from chat and did not forward it. ' +
611
+ 'If a Google/Microsoft account add is in progress, re-run the add command and paste the fresh ' +
612
+ '`127.0.0.1` URL — the previous code may have expired._',
613
+ { html: true },
614
+ )
615
+ return { handled: true }
616
+ }
617
+ return { handled: false }
618
+ }
619
+
620
+ /**
621
+ * /reauth OAuth-code paste-back intercept — the elder sibling of auth-add
622
+ * (mutates an existing agent's slot via `switchroom auth code`, vs /auth add
623
+ * which creates fresh credentials at the broker). Runs AFTER auth-add /
624
+ * loopback (load-bearing order). Same chatKey-scoped isolation + TTL window;
625
+ * the code paste is redacted from history either way (#488). A stale entry is
626
+ * dropped and the message falls through (`handled: false`).
627
+ */
628
+ export async function interceptReauth(
629
+ p: AuthAddParams,
630
+ deps: InboundInterceptorDeps,
631
+ ): Promise<InterceptOutcome> {
632
+ const pendingReauth = deps.pendingReauthFlows.get(p.interceptKey)
633
+ if (!(pendingReauth && deps.looksLikeAuthCode(p.text))) return { handled: false }
634
+ const elapsed = Date.now() - pendingReauth.startedAt
635
+ if (elapsed < deps.reauthInterceptTtlMs) {
636
+ deps.pendingReauthFlows.delete(p.interceptKey)
637
+ const { result, errorText } = deps.execAuthCode(pendingReauth.agent, p.text.trim())
638
+ if (errorText) {
639
+ await deps.switchroomReply(
640
+ p.ctx,
641
+ `**auth code failed:**\n${deps.preBlock(deps.formatSwitchroomOutput(errorText))}`,
642
+ { html: true },
643
+ )
644
+ } else if (result) {
645
+ const outcomeMsg = deps.renderAuthCodeOutcome(result.outcome)
646
+ if (outcomeMsg) {
647
+ await deps.switchroomReply(p.ctx, outcomeMsg, { html: true })
648
+ } else {
649
+ // success or no structured outcome — fall back to formatted text
650
+ const output = result.instructions.join('\n')
651
+ const formatted = deps.formatAuthOutputForTelegram(output)
652
+ await deps.switchroomReply(p.ctx, formatted.text, { html: true })
653
+ }
654
+ }
655
+ // Redact the OAuth code paste from chat history (#488).
656
+ // Single-use code so a third party can't replay it after exchange,
657
+ // but plaintext OAuth tokens in chat history are still poor
658
+ // hygiene. The helper handles delete + 🔑 reaction silently.
659
+ deps.redactAuthCode(p.chat_id, p.msgId ?? null)
660
+ return { handled: true }
661
+ }
662
+ deps.pendingReauthFlows.delete(p.interceptKey)
663
+ return { handled: false }
664
+ }
665
+
666
+
667
+ /** Per-message facts for the vault intercept (P7 PR-8). Keyed by chat_id
668
+ * (a vault op is chat-scoped, not topic-scoped — unlike the auth intercepts). */
669
+ export interface VaultInterceptParams {
670
+ ctx: Context
671
+ text: string
672
+ chat_id: string
673
+ msgId: number | undefined
674
+ }
675
+
676
+ /**
677
+ * Vault pending-op intercept: the text reply that completes a /vault flow
678
+ * (passphrase, broker unlock, deferred-secret save, access-approve passphrase
679
+ * drain, grant-wizard duration, secret value, rename-vault-save). Fully
680
+ * intercepts any fresh pending entry (`handled: true` — the reply text is a
681
+ * passphrase or secret, never forwarded to the agent); a TTL-stale entry is
682
+ * dropped and the message falls through.
683
+ *
684
+ * Moved VERBATIM from gateway.ts (#2996 P7 PR-8). The two raw
685
+ * `p.ctx.api\n.editMessageText` calls keep their inline line-split shape and
686
+ * `.catch(() => {})` best-effort semantics exactly as before.
687
+ */
688
+ export async function interceptVault(
689
+ p: VaultInterceptParams,
690
+ deps: InboundInterceptorDeps,
691
+ ): Promise<InterceptOutcome> {
692
+ const pendingVault = deps.pendingVaultOps.get(p.chat_id)
693
+ if (pendingVault) {
694
+ const elapsed = Date.now() - pendingVault.startedAt
695
+ if (elapsed > deps.vaultInputTtlMs) {
696
+ deps.pendingVaultOps.delete(p.chat_id)
697
+ } else {
698
+ deps.pendingVaultOps.delete(p.chat_id)
699
+ if (pendingVault.kind === 'passphrase') {
700
+ const passphrase = p.text.trim()
701
+ if (!passphrase) {
702
+ await deps.switchroomReply(p.ctx, 'Passphrase cannot be empty. Try /vault again.', { html: true })
703
+ return { handled: true }
704
+ }
705
+ deps.vaultPassphraseCache.set(p.chat_id, { passphrase, expiresAt: Date.now() + deps.vaultPassphraseTtlMs })
706
+ if (p.msgId != null) await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'vault passphrase')
707
+ await deps.executeVaultOp(p.ctx, p.chat_id, pendingVault.op, pendingVault.key, passphrase, undefined)
708
+ } else if (pendingVault.kind === 'unlock') {
709
+ // Issue #158: passphrase for /vault unlock — sent directly to the
710
+ // broker unlock socket, never cached or logged.
711
+ const passphrase = p.text.trim()
712
+ if (!passphrase) {
713
+ await deps.switchroomReply(p.ctx, 'Passphrase cannot be empty. Try /vault unlock again.', { html: true })
714
+ return { handled: true }
715
+ }
716
+ if (p.msgId != null) await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'vault unlock passphrase')
717
+ const result = await deps.unlockViaBroker(passphrase)
718
+ if (result.ok) {
719
+ await deps.switchroomReply(p.ctx, '🔓 Vault broker unlocked.', { html: true })
720
+ } else {
721
+ await deps.switchroomReply(p.ctx, `**vault unlock failed:** ${deps.escapeHtmlForTg(result.msg ?? 'unknown error')}`, { html: true })
722
+ }
723
+ } else if (pendingVault.kind === 'passphrase-for-deferred') {
724
+ // Issue #44: passphrase entered after tapping "🔓 Unlock vault &
725
+ // save" on the deferred-secret card. Cache the passphrase then
726
+ // auto-write the held secret directly — no re-paste required.
727
+ // The passphrase message itself is deleted so it doesn't linger
728
+ // in chat history (same protection as the original secret got).
729
+ const passphrase = p.text.trim()
730
+ if (!passphrase) {
731
+ await deps.switchroomReply(p.ctx, 'Passphrase cannot be empty. Tap the unlock button again.', { html: true })
732
+ return { handled: true }
733
+ }
734
+ deps.vaultPassphraseCache.set(p.chat_id, { passphrase, expiresAt: Date.now() + deps.vaultPassphraseTtlMs })
735
+ if (p.msgId != null) await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'vault passphrase')
736
+ await deps.callbackQueryHandlers().executeDeferredSecretSave(p.ctx, pendingVault.deferKey, passphrase, pendingVault.cardMessageId)
737
+ } else if (pendingVault.kind === 'passphrase-for-access-approve') {
738
+ // #1012 Phase 2 follow-up + #1051: operator tapped Approve on
739
+ // one or more vault_request_access cards without first
740
+ // unlocking. We captured the next message as the passphrase,
741
+ // cache it, delete the chat copy, and resume the approve
742
+ // flow for EVERY queued stage (#1051 — without the queue, a
743
+ // concurrent second tap orphaned the first stage). Wrong
744
+ // passphrase surfaces via the broker's
745
+ // DENIED:passphrase-mismatch path and edits each card to the
746
+ // mint_grant-failed message (see performVaultAccessApproval).
747
+ const passphrase = p.text.trim()
748
+ if (!passphrase) {
749
+ await deps.switchroomReply(p.ctx, 'Passphrase cannot be empty. Ask the agent to re-issue the request card.', { html: true })
750
+ return { handled: true }
751
+ }
752
+ deps.vaultPassphraseCache.set(p.chat_id, { passphrase, expiresAt: Date.now() + deps.vaultPassphraseTtlMs })
753
+ if (p.msgId != null) await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'vault passphrase')
754
+ // Drain the queued items SEQUENTIALLY. The grant-union step
755
+ // inside performVaultAccessApproval lists existing grants
756
+ // before minting; sequential processing means the union
757
+ // grows monotonically (item 1 mints grant for [keyA]; item 2
758
+ // lists, finds [keyA], unions with keyB → mints [keyA, keyB]).
759
+ // Parallel processing would race on the list-and-merge.
760
+ for (const item of pendingVault.items) {
761
+ const stagedAccess = deps.pendingVaultRequestAccesses.get(item.stageId)
762
+ if (!stagedAccess) {
763
+ // Stage expired or denied between tap and passphrase reply.
764
+ // Edit its card to a clean explanation; don't break the
765
+ // loop (sibling cards may still be valid).
766
+ await p.ctx.api
767
+ .editMessageText(
768
+ item.cardChatId,
769
+ item.cardMessageId,
770
+ richMessage(`⌛ _Vault unlocked, but this access-request card expired or was denied before you replied. Ask the agent to re-issue if still needed._`),
771
+ { reply_markup: { inline_keyboard: [] } },
772
+ )
773
+ .catch(() => {})
774
+ continue
775
+ }
776
+ await deps.callbackQueryHandlers().performVaultAccessApproval(p.ctx, stagedAccess, item.stageId, item.senderId, { kind: 'passphrase', passphrase })
777
+ }
778
+ } else if (pendingVault.kind === 'grant-wizard' && pendingVault.awaitingCustomDuration) {
779
+ // Issue #227: custom duration text reply for grant wizard
780
+ const input = p.text.trim()
781
+ const ttlSeconds = deps.callbackQueryHandlers().parseGrantDuration(input)
782
+ if (ttlSeconds === null) {
783
+ // Re-set state so user can try again
784
+ deps.pendingVaultOps.set(p.chat_id, { ...pendingVault, startedAt: Date.now() })
785
+ await deps.switchroomReply(p.ctx, '⚠️ Invalid duration. Use \`Nd\` (days) or \`Nh\` (hours), e.g. \`30d\` or \`12h\`.', { html: true })
786
+ return { handled: true }
787
+ }
788
+ const newState = { ...pendingVault, ttlSeconds, awaitingCustomDuration: false }
789
+ await deps.callbackQueryHandlers().grantWizardConfirm(p.ctx, p.chat_id, newState)
790
+ } else if (pendingVault.kind === 'grant-wizard') {
791
+ // Text received mid-wizard but not awaiting custom duration — ignore and re-set
792
+ deps.pendingVaultOps.set(p.chat_id, { ...pendingVault, startedAt: Date.now() })
793
+ } else if (pendingVault.kind === 'value') {
794
+ let value = p.text
795
+ const codeBlockMatch = /^```[\w]*\n?([\s\S]*?)```$/m.exec(p.text)
796
+ if (codeBlockMatch) value = codeBlockMatch[1]!
797
+ if (p.msgId != null) await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'vault secret value')
798
+ await deps.executeVaultOp(p.ctx, p.chat_id, 'set', pendingVault.key, pendingVault.passphrase, value.trim())
799
+ } else if (pendingVault.kind === 'rename-vault-save') {
800
+ // Issue #969 P1a: user tapped Rename on a vault_request_save
801
+ // card and is now telling us the new key name. Validate the
802
+ // slug, update the staged entry, refresh the card.
803
+ const newKey = p.text.trim()
804
+ const staged = deps.pendingVaultRequestSaves.get(pendingVault.stageId)
805
+ if (!staged) {
806
+ await deps.switchroomReply(p.ctx, '⌛ That save card expired before you renamed. Ask the agent to re-issue.', { html: true })
807
+ return { handled: true }
808
+ }
809
+ if (!deps.vaultKeyRegex.test(newKey)) {
810
+ // Re-arm the pending state so the user can try again.
811
+ deps.pendingVaultOps.set(p.chat_id, { ...pendingVault, startedAt: Date.now() })
812
+ await deps.switchroomReply(p.ctx, `⚠️ Key must match \`${deps.vaultKeyRegexLabel}\`. Send a different name.`, { html: true })
813
+ return { handled: true }
814
+ }
815
+ staged.key = newKey
816
+ if (p.msgId != null) await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'vault rename input')
817
+ // Edit the card in place with the new suggested key + same buttons.
818
+ if (staged.card_message_id != null) {
819
+ await p.ctx.api
820
+ .editMessageText(
821
+ staged.chat_id,
822
+ staged.card_message_id,
823
+ richMessage(renderVaultRequestSaveCard(staged, staged.agent)),
824
+ { reply_markup: buildVaultRequestSaveKeyboard(pendingVault.stageId) },
825
+ )
826
+ .catch(() => {})
827
+ }
828
+ }
829
+ return { handled: true }
830
+ }
831
+ }
832
+ return { handled: false }
833
+ }
834
+
835
+
836
+ /** Per-message facts for the secret-detect intercepts (P7 PR-9). */
837
+ export interface SecretStagingCommandParams {
838
+ ctx: Context
839
+ text: string
840
+ chat_id: string
841
+ msgId: number | undefined
842
+ }
843
+
844
+ /**
845
+ * Secret-detect follow-up command intercept: `stash NAME` / `ignore` /
846
+ * `rename X` / `forget` on a pending ambiguous detection. The user is replying
847
+ * to our "looks like a high-entropy string — reply `stash NAME` or `ignore`"
848
+ * prompt; we look up the most recent staged detection for this chat and act on
849
+ * it. With no staged entry the message falls through to normal handling.
850
+ * Moved verbatim from gateway.ts (#2996 P7 PR-9).
851
+ */
852
+ export async function interceptSecretStagingCommand(
853
+ p: SecretStagingCommandParams,
854
+ deps: InboundInterceptorDeps,
855
+ ): Promise<InterceptOutcome> {
856
+ // --- Secret-detect follow-up command intercept ---
857
+ // `stash NAME` / `ignore` / `rename X` / `forget` on a pending ambiguous
858
+ // detection. The user is replying to our "looks like a high-entropy
859
+ // string — reply `stash NAME` or `ignore`" prompt. We look up the most
860
+ // recent staged detection for this chat and act on it.
861
+ const stagedMatch = /^\s*(stash|ignore|rename|forget)\b\s*(\S+)?/i.exec(p.text)
862
+ if (stagedMatch) {
863
+ const staged = deps.secretStaging.latestForChat(p.chat_id)
864
+ if (staged != null) {
865
+ const verb = stagedMatch[1]!.toLowerCase()
866
+ const arg = stagedMatch[2]?.trim()
867
+ if (verb === 'ignore' || verb === 'forget') {
868
+ deps.secretStaging.delete(staged.chat_id, staged.message_id)
869
+ await deps.switchroomReply(p.ctx, 'ok — ignored. nothing stored.', { html: true })
870
+ // #1075: operates on a message in a (possibly-deleted) thread.
871
+ if (p.msgId != null) {
872
+ const delMsgId = p.msgId
873
+ void deps.swallowingApiCall(
874
+ () => deps.botApi().deleteMessage(p.chat_id, delMsgId),
875
+ { chat_id: p.chat_id, verb: 'staged-secret.delete-ignored' },
876
+ )
877
+ }
878
+ return { handled: true }
879
+ }
880
+ if (verb === 'stash' || verb === 'rename') {
881
+ const cached = deps.vaultPassphraseCache.get(p.chat_id)
882
+ if (!cached || cached.expiresAt <= Date.now()) {
883
+ await deps.switchroomReply(p.ctx, 'No vault passphrase cached. Run \`/vault list\` first (or any /vault command) to unlock, then re-send \`stash NAME\`.', { html: true })
884
+ return { handled: true }
885
+ }
886
+ const slugBase = arg && arg.length > 0 ? arg : staged.detection.suggested_slug
887
+ const listed = defaultVaultList(cached.passphrase)
888
+ const existing = new Set(listed.ok ? listed.keys : [])
889
+ let slug = slugBase
890
+ let n = 2
891
+ while (existing.has(slug)) slug = `${slugBase}_${n++}`
892
+ const write = defaultVaultWrite(slug, staged.detection.matched_text, cached.passphrase)
893
+ if (!write.ok) {
894
+ // Route P0a markers (#969) through the structured renderer so
895
+ // a "new key, needs operator approval" failure surfaces a
896
+ // host-CLI hint instead of a raw stderr blob.
897
+ const parsed = parseVaultCliError(write.output)
898
+ const rendered = renderVaultCliError(parsed, { verb: 'save', key: slug })
899
+ const body = rendered.suppressRaw
900
+ ? rendered.html
901
+ : `**vault write failed:**\n${deps.preBlock(write.output)}`
902
+ await deps.switchroomReply(p.ctx, body, { html: true })
903
+ return { handled: true }
904
+ }
905
+ deps.secretStaging.delete(staged.chat_id, staged.message_id)
906
+ if (p.msgId != null) await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'stash command')
907
+ await deps.deleteSensitiveMessage(p.chat_id, staged.message_id, 'detected secret')
908
+ await deps.switchroomReply(p.ctx, `✅ stored as \`vault:${slug}\` (masked: \`${maskToken(staged.detection.matched_text)}\`)`, { html: true })
909
+ return { handled: true }
910
+ }
911
+ }
912
+ // No staged entry to act on — fall through to normal handling.
913
+ }
914
+ return { handled: false }
915
+ }
916
+
917
+ /** Params for the secret-detect pipeline intercept (P7 PR-9). `effectiveText`
918
+ * is the steer/queue-prefix-stripped text captured in handleInbound; the
919
+ * scrub may REWRITE it, so it crosses back in the outcome as a value. */
920
+ export interface SecretDetectPipelineParams {
921
+ ctx: Context
922
+ chat_id: string
923
+ msgId: number | undefined
924
+ effectiveText: string
925
+ }
926
+
927
+ /** Outcome of the pipeline intercept: `handled: true` drops/consumes the
928
+ * message; otherwise the caller MUST adopt `effectiveText` (possibly rewritten
929
+ * with vault refs) for everything downstream — history, envelope, IPC. */
930
+ export type SecretDetectPipelineOutcome =
931
+ | { handled: true }
932
+ | { handled: false; effectiveText: string }
933
+
934
+ /**
935
+ * Secret detection + vault-scrub (moved verbatim, #2996 P7 PR-9). If the user
936
+ * pasted a secret, intercept BEFORE recording to history or broadcasting to
937
+ * the agent: write to vault, delete the Telegram message, rewrite the prompt
938
+ * so the downstream session .jsonl, Hindsight memory, and IPC payload never
939
+ * see the raw bytes. No cached passphrase → high-confidence hits are deferred
940
+ * behind the one-tap unlock+save card (#44).
941
+ *
942
+ * FAIL-CLOSED: if the pipeline throws, drop the message and warn the user —
943
+ * never fall through to recordInbound/broadcast with raw bytes. See
944
+ * gateway-secret-detect.test.ts and secret-detect-fail-closed.test.ts.
945
+ */
946
+ export async function interceptSecretDetectPipeline(
947
+ p: SecretDetectPipelineParams,
948
+ deps: InboundInterceptorDeps,
949
+ ): Promise<SecretDetectPipelineOutcome> {
950
+ let effectiveText = p.effectiveText
951
+ // --- Secret detection + vault-scrub ---
952
+ // If the user pasted a secret, intercept BEFORE we record to history or
953
+ // broadcast to the agent: write to vault, delete the Telegram message,
954
+ // rewrite the prompt so the downstream session .jsonl, Hindsight memory,
955
+ // and IPC payload never see the raw bytes. If there's no cached vault
956
+ // passphrase, high-confidence hits are deferred and the user is asked to
957
+ // unlock first.
958
+ //
959
+ // FAIL-CLOSED: if the pipeline throws, drop the message and warn the user
960
+ // — never fall through to recordInbound/broadcast with raw bytes. See
961
+ // gateway-secret-detect.test.ts and secret-detect-fail-closed.test.ts.
962
+ try {
963
+ // Channel B context rule: if we emitted "Paste the browser code here"
964
+ // recently in this chat, treat the inbound as auth-flow-sensitive —
965
+ // high-confidence secret detection regardless of pattern match. This
966
+ // survives Anthropic changing their token format because it tracks the
967
+ // gateway's own prompt, not the token shape.
968
+ const authCodeSentAt = deps.awaitingAuthCodeAt.get(p.chat_id)
969
+ const isAuthFlowContext =
970
+ authCodeSentAt !== undefined && Date.now() - authCodeSentAt < deps.authCodeContextTtlMs
971
+ if (isAuthFlowContext) {
972
+ process.stderr.write(`[secret-detect] auth-flow context rule active for chat ${p.chat_id}\n`)
973
+ }
974
+
975
+ const cachedPp = deps.vaultPassphraseCache.get(p.chat_id)
976
+ const passphrase = cachedPp && cachedPp.expiresAt > Date.now() ? cachedPp.passphrase : null
977
+ if (passphrase) {
978
+ const pipeRes = runPipeline({
979
+ chat_id: p.chat_id,
980
+ message_id: p.msgId ?? null,
981
+ text: effectiveText,
982
+ passphrase,
983
+ vaultWrite: defaultVaultWrite,
984
+ vaultList: defaultVaultList,
985
+ })
986
+ if (pipeRes.stored.length > 0) {
987
+ effectiveText = pipeRes.rewritten_text
988
+ if (isAuthFlowContext) {
989
+ deps.awaitingAuthCodeAt.delete(p.chat_id) // consume: one message per prompt
990
+ }
991
+ // 2026-05-12: route through deps.deleteSensitiveMessage so a
992
+ // failed delete posts an in-chat warning naming the leaked
993
+ // message id, instead of only logging to stderr (invisible
994
+ // on mobile). Previously the operator saw "we deleted it
995
+ // from chat" while the raw secret stayed visible. See
996
+ // tests/secret-detect-delete-must-surface-failures.test.ts.
997
+ if (p.msgId != null) {
998
+ await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'detected secret')
999
+ }
1000
+ const lines = pipeRes.stored.map((s) =>
1001
+ `• \`${s.masked}\` → \`vault:${s.actual_slug}\``,
1002
+ )
1003
+ await deps.switchroomReply(
1004
+ p.ctx,
1005
+ [`🔒 captured ${pipeRes.stored.length} secret${pipeRes.stored.length === 1 ? '' : 's'}:`, ...lines, '', 'reply \`rename X\` or \`forget\`.'].join('\n'),
1006
+ { html: true },
1007
+ )
1008
+ for (const s of pipeRes.stored) {
1009
+ deps.secretStaging.set({
1010
+ chat_id: p.chat_id,
1011
+ message_id: p.msgId ?? 0,
1012
+ detection: { ...s.detection, suggested_slug: s.actual_slug },
1013
+ staged_at: Date.now(),
1014
+ })
1015
+ }
1016
+ } else if (isAuthFlowContext && pipeRes.stored.length === 0) {
1017
+ // Channel B fallback: pattern didn't fire (Anthropic may have changed
1018
+ // the token format) but we know this is an auth code paste because we
1019
+ // prompted for it. Delete + stage + warn so no raw bytes leak.
1020
+ deps.awaitingAuthCodeAt.delete(p.chat_id) // consume: one message per prompt
1021
+ // 2026-05-12: route through deps.deleteSensitiveMessage. See
1022
+ // tests/secret-detect-delete-must-surface-failures.test.ts.
1023
+ if (p.msgId != null) {
1024
+ await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'auth-flow secret')
1025
+ }
1026
+ // Issue #44: even with passphrase cached we hit this branch when the
1027
+ // pattern didn't fire — but at this point a vault write would still
1028
+ // need the user's intent. Stash with a one-tap unlock+save card so
1029
+ // the post-context flow stays seamless.
1030
+ const dKey = deps.deferredKey(p.chat_id, p.msgId ?? 0)
1031
+ const cachedBranchDetection = detectSecrets(effectiveText).find((d) => d.confidence === 'high' && !d.suppressed)
1032
+ const cachedBranchSlug = cachedBranchDetection?.suggested_slug ?? (isAuthFlowContext ? 'anthropic_oauth_code' : 'secret')
1033
+ const cachedBranchKernelId = await deps.mintDeferredSecretKernelRequest(
1034
+ cachedBranchSlug,
1035
+ deps.loadAccess().allowFrom,
1036
+ )
1037
+ deps.deferredSecrets.set(dKey, {
1038
+ chat_id: p.chat_id,
1039
+ original_message_id: p.msgId ?? 0,
1040
+ text: effectiveText,
1041
+ staged_at: Date.now(),
1042
+ suggested_slug: cachedBranchSlug,
1043
+ kernel_request_id: cachedBranchKernelId ?? undefined,
1044
+ })
1045
+ await deps.switchroomReply(
1046
+ p.ctx,
1047
+ '🔒 auth-flow secret detected. we deleted it from chat. tap below to save it to the vault — no re-paste needed.',
1048
+ { html: true, reply_markup: deps.buildDeferredSecretKeyboard(dKey) },
1049
+ )
1050
+ return { handled: true }
1051
+ } else if (pipeRes.ambiguous.length > 0) {
1052
+ for (const d of pipeRes.ambiguous) {
1053
+ deps.secretStaging.set({ chat_id: p.chat_id, message_id: p.msgId ?? 0, detection: d, staged_at: Date.now() })
1054
+ }
1055
+ const top = pipeRes.ambiguous[0]!
1056
+ await deps.switchroomReply(
1057
+ p.ctx,
1058
+ `👀 looks like a high-entropy string (rule: \`${top.rule_id}\`). reply \`stash NAME\` to store in vault, or \`ignore\`.`,
1059
+ { html: true },
1060
+ )
1061
+ // For ambiguous, we do NOT delete the message or rewrite — let the
1062
+ // user confirm first.
1063
+ }
1064
+ } else {
1065
+ // No passphrase cached — detect, but defer. Issue #44 turned this
1066
+ // into a one-tap unlock-and-save flow: previously the user had to
1067
+ // run `/vault list`, type their passphrase, then re-paste the
1068
+ // original secret (six steps for a non-technical user). Now they
1069
+ // tap a button and re-enter the passphrase exactly once.
1070
+ const detections = detectSecrets(effectiveText)
1071
+ const highConfDetection = detections.find((d) => d.confidence === 'high' && !d.suppressed)
1072
+ const hasHigh = highConfDetection !== undefined || isAuthFlowContext
1073
+ if (hasHigh) {
1074
+ if (isAuthFlowContext) {
1075
+ deps.awaitingAuthCodeAt.delete(p.chat_id) // consume: one message per prompt
1076
+ }
1077
+ // Capture the slug at defer-time so the post-unlock auto-write
1078
+ // doesn't have to re-detect (which has a degenerate case for
1079
+ // Channel-B context defers where no pattern fired).
1080
+ const suggestedSlug =
1081
+ highConfDetection?.suggested_slug
1082
+ ?? (isAuthFlowContext ? 'anthropic_oauth_code' : 'secret')
1083
+ const dKey = deps.deferredKey(p.chat_id, p.msgId ?? 0)
1084
+ const noPassKernelId = await deps.mintDeferredSecretKernelRequest(
1085
+ suggestedSlug,
1086
+ deps.loadAccess().allowFrom,
1087
+ )
1088
+ deps.deferredSecrets.set(dKey, {
1089
+ chat_id: p.chat_id,
1090
+ original_message_id: p.msgId ?? 0,
1091
+ text: effectiveText,
1092
+ staged_at: Date.now(),
1093
+ suggested_slug: suggestedSlug,
1094
+ kernel_request_id: noPassKernelId ?? undefined,
1095
+ })
1096
+ // 2026-05-12: route through deps.deleteSensitiveMessage. See
1097
+ // tests/secret-detect-delete-must-surface-failures.test.ts.
1098
+ if (p.msgId != null) {
1099
+ await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'detected secret')
1100
+ }
1101
+ await deps.switchroomReply(
1102
+ p.ctx,
1103
+ '🔒 caught a secret. we deleted it from chat. tap below to unlock the vault and save it — no re-paste needed.',
1104
+ { html: true, reply_markup: deps.buildDeferredSecretKeyboard(dKey) },
1105
+ )
1106
+ return { handled: true }
1107
+ }
1108
+ }
1109
+ } catch (err) {
1110
+ // FAIL-CLOSED: if the detector throws, we must NOT fall through to
1111
+ // recordInbound() / ipcServer.broadcast() with the raw text — that
1112
+ // would stamp the secret into SQLite and emit it to the agent
1113
+ // unscrubbed. Drop the message on the floor and warn the user.
1114
+ process.stderr.write(`[secret-detect] pipeline error: ${(err as Error).message}\n`)
1115
+ try {
1116
+ await deps.switchroomReply(
1117
+ p.ctx,
1118
+ '⚠️ secret-detect pipeline crashed; this message was dropped for safety. please try again or check the agent log.',
1119
+ { html: true },
1120
+ )
1121
+ } catch {}
1122
+ // 2026-05-12: route through deps.deleteSensitiveMessage. The
1123
+ // pipeline-error fail-closed path is the LAST line of defence —
1124
+ // if delete fails here, the operator must know so they can
1125
+ // delete manually. See
1126
+ // tests/secret-detect-delete-must-surface-failures.test.ts.
1127
+ if (p.msgId != null) {
1128
+ await deps.deleteSensitiveMessage(p.chat_id, p.msgId, 'secret-detect pipeline-error fallback')
1129
+ }
1130
+ return { handled: true }
1131
+ }
1132
+ return { handled: false, effectiveText }
1133
+ }