switchroom 0.14.27 → 0.14.29

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 (28) hide show
  1. package/dist/cli/switchroom.js +20 -4
  2. package/dist/host-control/main.js +2 -2
  3. package/package.json +1 -1
  4. package/telegram-plugin/bridge/bridge.ts +15 -0
  5. package/telegram-plugin/card-format.ts +7 -4
  6. package/telegram-plugin/dist/bridge/bridge.js +18 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +2151 -1729
  8. package/telegram-plugin/dist/server.js +18 -0
  9. package/telegram-plugin/gateway/gateway.ts +464 -12
  10. package/telegram-plugin/history.ts +16 -4
  11. package/telegram-plugin/permission-title.ts +48 -0
  12. package/telegram-plugin/registry/subagents-schema.ts +35 -0
  13. package/telegram-plugin/registry/subagents.test.ts +78 -0
  14. package/telegram-plugin/secret-detect/patterns.ts +8 -0
  15. package/telegram-plugin/secret-detect/redact.ts +76 -0
  16. package/telegram-plugin/session-tail.ts +15 -0
  17. package/telegram-plugin/subagent-watcher.ts +19 -1
  18. package/telegram-plugin/tests/card-format.test.ts +16 -0
  19. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +80 -0
  20. package/telegram-plugin/tests/gateway-request-secret.test.ts +78 -0
  21. package/telegram-plugin/tests/history.test.ts +59 -0
  22. package/telegram-plugin/tests/permission-title.test.ts +68 -0
  23. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +35 -0
  24. package/telegram-plugin/tests/secret-detect-sanctum.test.ts +115 -0
  25. package/telegram-plugin/tests/session-tail.test.ts +43 -0
  26. package/telegram-plugin/tests/worker-activity-feed.test.ts +15 -0
  27. package/telegram-plugin/uat/scenarios/jtbd-request-secret-dm.test.ts +101 -0
  28. package/telegram-plugin/worker-activity-feed.ts +5 -2
@@ -17387,6 +17387,10 @@ function projectSubagentLine(line, agentId, state) {
17387
17387
  events.push({ kind: "sub_agent_text", agentId, text });
17388
17388
  }
17389
17389
  }
17390
+ const stopReason = message?.stop_reason;
17391
+ if (stopReason === "end_turn") {
17392
+ events.push({ kind: "sub_agent_turn_end", agentId });
17393
+ }
17390
17394
  return events;
17391
17395
  }
17392
17396
  if (type === "system" && obj.subtype === "turn_duration") {
@@ -24611,6 +24615,20 @@ var init_bridge = __esm(async () => {
24611
24615
  },
24612
24616
  required: ["chat_id", "key"]
24613
24617
  }
24618
+ },
24619
+ {
24620
+ name: "request_secret",
24621
+ description: "Ask the operator to PROVIDE a secret you do not have, securely \u2014 NEVER ask the user to paste a token/key/password as a normal chat message. Use this when you need a credential that is not in the vault (a `vault:<key>` reference is missing/empty, or you know an upcoming task needs one you lack). Renders a Telegram card with [Provide securely] [Decline]; on tap, the operator sends the value once and the gateway DELETES their message instantly and writes it straight to the vault \u2014 the raw value is never echoed back to you. You receive only `vault:<key>`. This is the sibling of `vault_request_save` (use that when the user already handed YOU a value to store) and `vault_request_access` (use that when the key exists but you lack read access). After firing this tool, END YOUR TURN cleanly \u2014 a fresh inbound message arrives once the operator provides (or declines) the secret. Do NOT call this for keys you already have, and do NOT spam (one open request per key; the operator sees every card).",
24622
+ inputSchema: {
24623
+ type: "object",
24624
+ properties: {
24625
+ chat_id: { type: "string", description: "Chat to render the card in (use the chat_id of the user message that triggered the workflow)." },
24626
+ key: { type: "string", description: "Vault key to store the provided secret under. Use lowercase namespaced snake_case, e.g. `coolify/api-token`." },
24627
+ reason: { type: "string", description: 'REQUIRED in practice \u2014 one-line human-readable rationale rendered on the card (e.g. "to trigger a redeploy on Coolify"). Omitting it makes the operator more likely to Decline.' },
24628
+ message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message if not specified." }
24629
+ },
24630
+ required: ["chat_id", "key"]
24631
+ }
24614
24632
  }
24615
24633
  ];
24616
24634
  mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_SCHEMAS }));
@@ -355,6 +355,7 @@ import { defaultVaultWrite, defaultVaultList, defaultVaultWritePosture } from '.
355
355
  import { parseVaultCliError, renderVaultCliError } from '../secret-detect/vault-error.js'
356
356
  import { recentDenialsFromAuditLog, type RecentDenial } from './recent-denials.js'
357
357
  import { detectSecrets } from '../secret-detect/index.js'
358
+ import { redact } from '../secret-detect/redact.js'
358
359
  import { classifyAdminGate } from '../admin-commands/index.js'
359
360
  import {
360
361
  startSubagentWatcher,
@@ -371,7 +372,7 @@ import { maybeRenderUpdateAnnouncement } from './update-announce.js'
371
372
  import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js'
372
373
  import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
373
374
  import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
374
- import { formatPermissionCardBody, describeGrant } from '../permission-title.js'
375
+ import { formatPermissionCardBody, describeGrant, naturalAction, formatPermissionResumeMessage } from '../permission-title.js'
375
376
  import { resolveScopedAllowChoices, isRulePersisted } from '../permission-rule.js'
376
377
  import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
377
378
  import {
@@ -2118,6 +2119,13 @@ function finalizeStatusReaction(
2118
2119
  * fired its `done`/`failed` onFinish no longer counts here.
2119
2120
  */
2120
2121
  function countRunningWorkers(): number {
2122
+ // Prefer the dispatch-time DB count: a background worker's row is INSERTed
2123
+ // `status='running'` when its `Agent` tool_use fires, i.e. BEFORE the parent
2124
+ // turn ends. The registry below is populated by on-disk file discovery, which
2125
+ // lags dispatch by a poll/fswatch tick — so a just-dispatched worker was
2126
+ // invisible to the deferred-done gate and the 👍 promoted prematurely.
2127
+ const dbCount = subagentWatcher?.countRunningBackgroundWorkers?.()
2128
+ if (dbCount != null) return dbCount
2121
2129
  const reg = subagentWatcher?.getRegistry()
2122
2130
  if (reg == null) return 0
2123
2131
  let n = 0
@@ -2158,6 +2166,60 @@ function resumeReactionAfterVerdict(): void {
2158
2166
  ?.setThinking()
2159
2167
  }
2160
2168
 
2169
+ /**
2170
+ * Post the agent-voiced "got your verdict — continuing" message the
2171
+ * instant the operator answers a permission card. Travels right beside
2172
+ * `resumeReactionAfterVerdict()` at every operator-driven verdict site so
2173
+ * the legible signal (a distinct message naming the work) can't drift away
2174
+ * from the reaction flip — the same 5-paths-drift hazard #2019 guards.
2175
+ *
2176
+ * The card edit + 🙏→working reaction are easy to miss (a reaction lands on
2177
+ * the turn's triggering message far up the chat; the card footnote is a
2178
+ * one-liner). This message is the thing the operator actually sees.
2179
+ *
2180
+ * Posts to the suspended turn's chat/thread (`currentTurn` still points at
2181
+ * it mid-gate — the same controller `resumeReactionAfterVerdict` addresses)
2182
+ * so it lands in the conversation the gate belongs to; falls back to the
2183
+ * configured operator chats when there's no active turn (e.g. a swept turn
2184
+ * at TTL-sweep time). Kill-switch: `SWITCHROOM_RESUME_MSG=0`.
2185
+ */
2186
+ function postPermissionResumeMessage(opts: {
2187
+ behavior: 'allow' | 'deny'
2188
+ action: string
2189
+ timeoutMinutes?: number
2190
+ }): void {
2191
+ if (process.env.SWITCHROOM_RESUME_MSG === '0') return
2192
+ const text = formatPermissionResumeMessage({
2193
+ agentName: process.env.SWITCHROOM_AGENT_NAME ?? null,
2194
+ behavior: opts.behavior,
2195
+ action: opts.action,
2196
+ timeoutMinutes: opts.timeoutMinutes,
2197
+ })
2198
+ const turn = currentTurn
2199
+ const targets: Array<{ chatId: string; threadId: number | undefined }> =
2200
+ turn != null
2201
+ ? [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }]
2202
+ : loadAccess().allowFrom.map(chatId => ({
2203
+ chatId,
2204
+ threadId: resolveAgentOutboundTopic({
2205
+ kind: 'permission',
2206
+ turnInitiated: false,
2207
+ originThreadId: undefined,
2208
+ }),
2209
+ }))
2210
+ for (const { chatId, threadId } of targets) {
2211
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send
2212
+ void swallowingApiCall(
2213
+ () =>
2214
+ bot.api.sendMessage(chatId, text, {
2215
+ parse_mode: 'HTML',
2216
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
2217
+ }),
2218
+ { chat_id: chatId, verb: 'permission-resume', ...(threadId != null ? { threadId } : {}) },
2219
+ )
2220
+ }
2221
+ }
2222
+
2161
2223
  function resolveThreadId(chat_id: string, explicit?: string | number | null): number | undefined {
2162
2224
  if (explicit != null) return Number(explicit)
2163
2225
  return chatThreadMap.get(chat_id)
@@ -3066,6 +3128,11 @@ const pendingStateReaper = setInterval(() => {
3066
3128
  // The auto-deny un-parks the suspended turn — flip 🙏 → working so
3067
3129
  // it doesn't sit on the awaiting glyph (or stall) after the timeout.
3068
3130
  resumeReactionAfterVerdict()
3131
+ postPermissionResumeMessage({
3132
+ behavior: 'deny',
3133
+ action: naturalAction(v.tool_name, v.input_preview),
3134
+ timeoutMinutes: Math.round(PERMISSION_TTL_MS / 60000),
3135
+ })
3069
3136
  process.stderr.write(
3070
3137
  `telegram gateway: permission TTL expired — auto-deny request=${k} ` +
3071
3138
  `tool=${v.tool_name} (no operator response in ` +
@@ -5080,6 +5147,7 @@ const ALLOWED_TOOLS = new Set([
5080
5147
  'send_sticker', 'send_gif',
5081
5148
  'vault_request_save',
5082
5149
  'vault_request_access',
5150
+ 'request_secret',
5083
5151
  ])
5084
5152
 
5085
5153
  async function executeToolCall(tool: string, args: Record<string, unknown>): Promise<unknown> {
@@ -5123,6 +5191,8 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
5123
5191
  return executeVaultRequestSave(args)
5124
5192
  case 'vault_request_access':
5125
5193
  return executeVaultRequestAccess(args)
5194
+ case 'request_secret':
5195
+ return executeRequestSecret(args)
5126
5196
  default:
5127
5197
  throw new Error(`unknown tool: ${tool}`)
5128
5198
  }
@@ -5170,6 +5240,32 @@ async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{
5170
5240
  return { content: [{ type: 'text', text: `checklist updated (id: ${message_id})` }] }
5171
5241
  }
5172
5242
 
5243
+ /**
5244
+ * Outbound secret scrub (#2044). The agent is trusted, but it can still
5245
+ * echo a secret it read from a file, env, or a not-yet-vaulted value into
5246
+ * a reply — and outbound text had no redaction at all. This masks any
5247
+ * detected secret in agent-authored text BEFORE it is logged (the stderr
5248
+ * previews), forwarded (sent to Telegram), or stored (recordOutbound).
5249
+ *
5250
+ * It runs the SAME `detectSecrets` engine as the inbound gate, so a
5251
+ * pattern added once (e.g. the Sanctum `<id>|<token>` shape, #2043) covers
5252
+ * both directions. Applied at the entry of every agent-free-text tool
5253
+ * (reply / stream_reply / edit_message), mutating the text in place so all
5254
+ * downstream consumers — voice scrub, dedup key, chunker, answer-stream
5255
+ * diffing, history record — see the masked value. This is the same
5256
+ * in-place-mutation contract the voice scrub already relies on, which
5257
+ * keeps the answer-stream's incremental edit diffing consistent (it always
5258
+ * compares redacted-against-redacted).
5259
+ */
5260
+ function redactOutboundText(text: string, site: string): string {
5261
+ const masked = redact(text)
5262
+ if (masked !== text) {
5263
+ // Never log the secret — only that a mask fired, and where.
5264
+ process.stderr.write(`telegram gateway: outbound secret masked site=${site}\n`)
5265
+ }
5266
+ return masked
5267
+ }
5268
+
5173
5269
  async function executeReply(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
5174
5270
  // #1664 — pin the turn this reply belongs to at entry. The
5175
5271
  // finalAnswerDelivered write near the end of this function runs after
@@ -5183,6 +5279,11 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
5183
5279
  const rawText = args.text as string | undefined
5184
5280
  if (rawText == null || rawText === '') throw new Error('reply: text is required and cannot be empty')
5185
5281
  let text = repairEscapedWhitespace(rawText)
5282
+ // Outbound secret scrub (#2044): mask any secret the agent echoed BEFORE
5283
+ // the stderr preview below, the dedup key, the send, and the history
5284
+ // record. Mutates `text` so every downstream consumer sees the masked
5285
+ // value, exactly like the voice scrub that follows.
5286
+ text = redactOutboundText(text, 'reply')
5186
5287
  // Voice scrub (#1683): replace em / en dashes with commas / periods.
5187
5288
  // Runs BEFORE outboundDedup so retries see the scrubbed key, and
5188
5289
  // BEFORE markdownToHtml so code-block content is correctly parked
@@ -5935,6 +6036,12 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
5935
6036
  if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
5936
6037
  if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
5937
6038
 
6039
+ // Outbound secret scrub (#2044): mask before the dedup key, the draft
6040
+ // stream sends, and the history record. stream_reply carries the FULL
6041
+ // text-so-far on every call, so redacting each call keeps the answer-
6042
+ // stream's incremental diffing comparing redacted-against-redacted.
6043
+ args.text = redactOutboundText(args.text as string, 'stream_reply')
6044
+
5938
6045
  // Voice scrub (PR #1683 follow-up). Modern Claude on the fleet
5939
6046
  // uses the answer-stream / draft-stream path for multi-paragraph
5940
6047
  // replies — the model emits via stream_reply and the original
@@ -6777,6 +6884,297 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
6777
6884
  }
6778
6885
  }
6779
6886
 
6887
+ // ─── #2045 request_secret — agent asks the operator to PROVIDE a secret ───
6888
+ //
6889
+ // The third member of the agent-vault tool family. `vault_request_save`
6890
+ // = agent HAS a value and asks to save it; `vault_request_access` = agent
6891
+ // needs read access to an existing key; `request_secret` = agent needs a
6892
+ // value it does NOT have. The operator provides it through a secure card:
6893
+ // they tap [Provide securely], send the value once, and the gateway deletes
6894
+ // the message instantly + writes it straight to the vault. The raw value is
6895
+ // never recorded to history, never logged, never returned to the agent —
6896
+ // the agent only ever references `vault:<key>`. This removes the reason an
6897
+ // agent would ever ask a user to paste a secret as a normal chat message.
6898
+ interface PendingSecretRequest {
6899
+ agent: string
6900
+ chat_id: string
6901
+ key: string
6902
+ reason?: string
6903
+ staged_at: number
6904
+ card_message_id?: number
6905
+ }
6906
+ // stageId -> request (lives until tapped or TTL).
6907
+ const pendingSecretRequests = new Map<string, PendingSecretRequest>()
6908
+ // chat_id -> the armed capture: the operator's NEXT message in this chat is
6909
+ // the value for `key`. Set when [Provide securely] is tapped.
6910
+ interface ArmedSecretCapture { key: string; agent: string; stageId: string; armed_at: number }
6911
+ const armedSecretCaptures = new Map<string, ArmedSecretCapture>()
6912
+ const PENDING_SECRET_REQUEST_TTL_MS = 30 * 60_000 // card lifetime
6913
+ const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000 // window to send the value after tapping
6914
+
6915
+ function sweepSecretRequests(): void {
6916
+ const now = Date.now()
6917
+ for (const [k, v] of pendingSecretRequests) {
6918
+ if (now - v.staged_at > PENDING_SECRET_REQUEST_TTL_MS) pendingSecretRequests.delete(k)
6919
+ }
6920
+ for (const [k, v] of armedSecretCaptures) {
6921
+ if (now - v.armed_at > ARMED_SECRET_CAPTURE_TTL_MS) armedSecretCaptures.delete(k)
6922
+ }
6923
+ }
6924
+
6925
+ function buildSecretRequestKeyboard(stageId: string): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
6926
+ return {
6927
+ inline_keyboard: [
6928
+ [
6929
+ { text: '🔐 Provide securely', callback_data: `vsp:provide:${stageId}` },
6930
+ { text: '🚫 Decline', callback_data: `vsp:decline:${stageId}` },
6931
+ ],
6932
+ ],
6933
+ }
6934
+ }
6935
+
6936
+ function renderSecretRequestCard(req: PendingSecretRequest): string {
6937
+ const lines: string[] = [
6938
+ `🔒 <b>${escapeHtmlForTg(req.agent)}</b> needs a secret:`,
6939
+ `<code>${escapeHtmlForTg(req.key)}</code>`,
6940
+ ]
6941
+ if (req.reason) lines.push(`<i>${escapeHtmlForTg(req.reason)}</i>`)
6942
+ lines.push(
6943
+ '',
6944
+ 'Tap <b>Provide securely</b>, then send the value as your next message. I’ll delete it instantly and store it in the vault — it is never shown in chat or to the agent.',
6945
+ )
6946
+ return lines.join('\n')
6947
+ }
6948
+
6949
+ /**
6950
+ * `request_secret` tool — agent surfaces a card asking the operator to
6951
+ * provide a missing secret. No `value` arg: the value arrives via secure
6952
+ * capture (the operator's next message after they tap [Provide securely]).
6953
+ */
6954
+ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ content: Array<{ type: string; text: string }> }> {
6955
+ const chat_id = args.chat_id as string
6956
+ if (!chat_id) throw new Error('request_secret: chat_id is required')
6957
+ const key = args.key as string
6958
+ if (!key || typeof key !== 'string') throw new Error('request_secret: key is required')
6959
+ const reason = typeof args.reason === 'string' ? args.reason : undefined
6960
+ assertAllowedChat(chat_id)
6961
+ if (!VAULT_KEY_REGEX.test(key)) {
6962
+ throw new Error(`request_secret: key must match ${VAULT_KEY_REGEX_LABEL}`)
6963
+ }
6964
+ const agentSlug = process.env.SWITCHROOM_AGENT_NAME || 'agent'
6965
+
6966
+ // Dedupe: one open request per (chat, key). Drop any prior stage for
6967
+ // the same target so the operator never sees stacked cards.
6968
+ for (const [sid, p] of pendingSecretRequests) {
6969
+ if (p.chat_id === chat_id && p.key === key) pendingSecretRequests.delete(sid)
6970
+ }
6971
+
6972
+ const stageId = randomBytes(4).toString('hex')
6973
+ const pending: PendingSecretRequest = { agent: agentSlug, chat_id, key, reason, staged_at: Date.now() }
6974
+ pendingSecretRequests.set(stageId, pending)
6975
+ sweepSecretRequests()
6976
+
6977
+ const text = renderSecretRequestCard(pending)
6978
+ const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
6979
+ const sent = await retryWithThreadFallback<{ message_id: number }>(
6980
+ robustApiCall,
6981
+ (tid) =>
6982
+ lockedBot.api.sendMessage(chat_id, text, {
6983
+ parse_mode: 'HTML',
6984
+ reply_markup: buildSecretRequestKeyboard(stageId),
6985
+ ...(tid != null && Number.isFinite(tid) ? { message_thread_id: tid } : {}),
6986
+ }),
6987
+ { threadId, chat_id, verb: 'request_secret.card' },
6988
+ )
6989
+ pending.card_message_id = sent.message_id
6990
+
6991
+ return {
6992
+ content: [
6993
+ {
6994
+ type: 'text',
6995
+ 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.`,
6996
+ },
6997
+ ],
6998
+ }
6999
+ }
7000
+
7001
+ /**
7002
+ * Write a provided secret to the vault. Mirrors the vrs:save write branch:
7003
+ * posture-attested broker put under telegram-id mode (no passphrase), else
7004
+ * the cached-passphrase CLI path. Returns the same {ok, output} shape.
7005
+ */
7006
+ async function writeRequestedSecret(key: string, value: string, chat_id: string): Promise<{ ok: boolean; output: string }> {
7007
+ if (VAULT_APPROVAL_AUTH_MODE === 'telegram-id') {
7008
+ return await defaultVaultWritePosture(key, value)
7009
+ }
7010
+ const cached = vaultPassphraseCache.get(chat_id)
7011
+ if (!cached || cached.expiresAt <= Date.now()) {
7012
+ return { ok: false, output: 'VAULT-LOCKED: run /vault unlock once in this chat, then have the agent re-request.' }
7013
+ }
7014
+ return defaultVaultWrite(key, value, cached.passphrase)
7015
+ }
7016
+
7017
+ /**
7018
+ * Secure value capture. Called early in handleInbound: if this chat is
7019
+ * armed (operator tapped [Provide securely]), the inbound message IS the
7020
+ * secret value. Delete it, write to the vault under the requested key, tell
7021
+ * the agent it's available, and STOP — never record / log / forward the raw
7022
+ * value. Returns true if it consumed the message (caller must return).
7023
+ */
7024
+ async function captureProvidedSecret(
7025
+ ctx: Context,
7026
+ chat_id: string,
7027
+ msgId: number | undefined,
7028
+ value: string,
7029
+ ): Promise<boolean> {
7030
+ const armed = armedSecretCaptures.get(chat_id)
7031
+ if (!armed || Date.now() - armed.armed_at > ARMED_SECRET_CAPTURE_TTL_MS) {
7032
+ if (armed) armedSecretCaptures.delete(chat_id)
7033
+ return false
7034
+ }
7035
+ armedSecretCaptures.delete(chat_id)
7036
+ const pending = pendingSecretRequests.get(armed.stageId)
7037
+ pendingSecretRequests.delete(armed.stageId)
7038
+
7039
+ // Delete the raw message FIRST — surfaces a warning if it fails.
7040
+ if (msgId != null) await deleteSensitiveMessage(chat_id, msgId, 'provided secret value')
7041
+
7042
+ const write = await writeRequestedSecret(armed.key, value, chat_id)
7043
+ if (!write.ok) {
7044
+ const parsed = parseVaultCliError(write.output)
7045
+ const rendered = renderVaultCliError(parsed, { verb: 'save', key: armed.key })
7046
+ const body = rendered.suppressRaw ? rendered.html : `⚠️ vault write failed:\n<pre>${escapeHtmlForTg(write.output)}</pre>`
7047
+ await switchroomReply(ctx, `${body}\n\n<i>The secret was NOT saved. The agent can re-request with <code>request_secret</code>.</i>`, { html: true })
7048
+ // Wake the agent too (review #2047 finding #3): it ended its turn
7049
+ // waiting for a synthetic inbound; without this it stalls silently.
7050
+ const fts = Date.now()
7051
+ const failMsg: InboundMessage = {
7052
+ type: 'inbound',
7053
+ chatId: chat_id,
7054
+ messageId: fts,
7055
+ user: 'vault-broker',
7056
+ userId: 0,
7057
+ ts: fts,
7058
+ text: `⚠️ The secret you requested for \`vault:${armed.key}\` could NOT be saved (vault write failed). Do not assume it is available; tell the operator or try request_secret again.`,
7059
+ meta: { source: 'secret_provide_failed', agent: armed.agent, key: armed.key, stage_id: armed.stageId },
7060
+ }
7061
+ const fdelivered = ipcServer.sendToAgent(armed.agent, failMsg)
7062
+ if (fdelivered) markClaudeBusyForInbound(failMsg)
7063
+ else pendingInboundBuffer.push(armed.agent, failMsg)
7064
+ return true
7065
+ }
7066
+
7067
+ await switchroomReply(
7068
+ ctx,
7069
+ `✅ saved as <code>vault:${escapeHtmlForTg(armed.key)}</code> (masked: <code>${escapeHtmlForTg(maskToken(value))}</code>). The agent can now reference it.`,
7070
+ { html: true },
7071
+ )
7072
+
7073
+ // Resume the requesting agent with a synthetic inbound — mirrors the
7074
+ // vault_grant_approved injection. The value is NOT included; only the key.
7075
+ const ts = Date.now()
7076
+ const synthetic: InboundMessage = {
7077
+ type: 'inbound',
7078
+ chatId: chat_id,
7079
+ messageId: ts,
7080
+ user: 'vault-broker',
7081
+ userId: 0,
7082
+ ts,
7083
+ text:
7084
+ `✅ Operator provided the secret you requested. It is saved as ` +
7085
+ `\`vault:${armed.key}\` — reference it the usual way. Resume the task ` +
7086
+ `that was waiting on this credential. Do NOT ask the operator to paste it.`,
7087
+ meta: {
7088
+ source: 'secret_provided',
7089
+ agent: armed.agent,
7090
+ key: armed.key,
7091
+ stage_id: armed.stageId,
7092
+ },
7093
+ }
7094
+ const delivered = ipcServer.sendToAgent(armed.agent, synthetic)
7095
+ if (delivered) markClaudeBusyForInbound(synthetic)
7096
+ else pendingInboundBuffer.push(armed.agent, synthetic)
7097
+ process.stderr.write(
7098
+ `telegram gateway: secret_provided injection agent=${armed.agent} key=${armed.key} stage=${armed.stageId} delivered=${delivered}\n`,
7099
+ )
7100
+ return true
7101
+ }
7102
+
7103
+ /**
7104
+ * `vsp:` callbacks — agent-requested-secret card.
7105
+ * vsp:provide:<stageId> — arm capture: operator's next message is the value
7106
+ * vsp:decline:<stageId> — drop the request; tell the agent it was declined
7107
+ */
7108
+ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<void> {
7109
+ const senderId = String(ctx.from?.id ?? '')
7110
+ const access = loadAccess()
7111
+ if (!access.allowFrom.includes(senderId)) {
7112
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' }).catch(() => {})
7113
+ return
7114
+ }
7115
+ const parts = data.split(':')
7116
+ const action = parts[1]
7117
+ const stageId = parts[2] ?? ''
7118
+ const pending = pendingSecretRequests.get(stageId)
7119
+ if (!pending) {
7120
+ await ctx.answerCallbackQuery({ text: 'This request expired.' }).catch(() => {})
7121
+ return
7122
+ }
7123
+
7124
+ if (action === 'provide') {
7125
+ armedSecretCaptures.set(pending.chat_id, {
7126
+ key: pending.key,
7127
+ agent: pending.agent,
7128
+ stageId,
7129
+ armed_at: Date.now(),
7130
+ })
7131
+ await ctx.answerCallbackQuery({ text: 'Send the value now — it auto-deletes.' }).catch(() => {})
7132
+ if (pending.card_message_id != null) {
7133
+ await ctx.api
7134
+ .editMessageText(
7135
+ pending.chat_id,
7136
+ pending.card_message_id,
7137
+ `🔐 Send the value for <code>${escapeHtmlForTg(pending.key)}</code> as your next message — a single message, exactly as-is (don't add other text). I’ll delete it instantly and store it in the vault.`,
7138
+ { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
7139
+ )
7140
+ .catch(() => {})
7141
+ }
7142
+ return
7143
+ }
7144
+
7145
+ if (action === 'decline') {
7146
+ pendingSecretRequests.delete(stageId)
7147
+ armedSecretCaptures.delete(pending.chat_id)
7148
+ await ctx.answerCallbackQuery({ text: 'Declined.' }).catch(() => {})
7149
+ if (pending.card_message_id != null) {
7150
+ await ctx.api
7151
+ .editMessageText(pending.chat_id, pending.card_message_id, `🚫 Declined — <code>${escapeHtmlForTg(pending.key)}</code> not provided.`, {
7152
+ parse_mode: 'HTML',
7153
+ reply_markup: { inline_keyboard: [] },
7154
+ })
7155
+ .catch(() => {})
7156
+ }
7157
+ // Tell the agent so it stops waiting.
7158
+ const ts = Date.now()
7159
+ const synthetic: InboundMessage = {
7160
+ type: 'inbound',
7161
+ chatId: pending.chat_id,
7162
+ messageId: ts,
7163
+ user: 'vault-broker',
7164
+ userId: 0,
7165
+ ts,
7166
+ text: `🚫 Operator declined your request for \`vault:${pending.key}\`. Proceed without it or ask how they'd like to handle the task.`,
7167
+ meta: { source: 'secret_declined', agent: pending.agent, key: pending.key, stage_id: stageId },
7168
+ }
7169
+ const delivered = ipcServer.sendToAgent(pending.agent, synthetic)
7170
+ if (delivered) markClaudeBusyForInbound(synthetic)
7171
+ else pendingInboundBuffer.push(pending.agent, synthetic)
7172
+ return
7173
+ }
7174
+
7175
+ await ctx.answerCallbackQuery().catch(() => {})
7176
+ }
7177
+
6780
7178
  /**
6781
7179
  * Issue #1012 — render the agent-initiated ACL request approval card.
6782
7180
  * Same visual language as the recent-denials one-tap allow (#969 P2b)
@@ -6991,6 +7389,9 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
6991
7389
  const editConfigMode = editAccess.parseMode ?? 'html'
6992
7390
  const editFormat = (args.format as string | undefined) ?? editConfigMode
6993
7391
  let editRawText = repairEscapedWhitespace(args.text as string)
7392
+ // Outbound secret scrub (#2044): an edit must not re-introduce a raw
7393
+ // secret into a live bubble or the history row. Mask before scrub/send.
7394
+ editRawText = redactOutboundText(editRawText, 'edit_message')
6994
7395
  // Voice scrub (#1683): same em-dash scrub as the reply path. Edits
6995
7396
  // are how silent-anchor and progress-update mutate already-sent
6996
7397
  // bubbles, so without this an edit can re-introduce dashes the
@@ -8170,6 +8571,14 @@ function handleSessionEvent(ev: SessionEvent): void {
8170
8571
  const backstopThreadId = threadId
8171
8572
  const backstopCtrl = ctrl
8172
8573
 
8574
+ // Outbound secret scrub (#2044). Turn-flush delivers the model's
8575
+ // terminal answer prose when it skipped reply/stream_reply — that
8576
+ // is arbitrary agent free-text, sent via sendMessage/editMessageText
8577
+ // and previewed to stderr below, so it needs the same mask as the
8578
+ // three reply tools. Mirror the voice scrub: mask before the send,
8579
+ // the preview, and recordOutbound.
8580
+ capturedText = redactOutboundText(capturedText, 'turn_flush')
8581
+
8173
8582
  // Voice scrub (PR #1683 follow-up). Turn-flush is the path
8174
8583
  // that fires when the model emits raw transcript text WITHOUT
8175
8584
  // calling reply / stream_reply. That captured text bypasses
@@ -9216,6 +9625,11 @@ async function handleInbound(
9216
9625
  behavior,
9217
9626
  })
9218
9627
  resumeReactionAfterVerdict()
9628
+ const ftDetails = pendingPermissions.get(request_id)
9629
+ postPermissionResumeMessage({
9630
+ behavior,
9631
+ action: ftDetails ? naturalAction(ftDetails.tool_name, ftDetails.input_preview) : '',
9632
+ })
9219
9633
  if (msgId != null) {
9220
9634
  const emoji = behavior === 'allow' ? '✅' : '❌'
9221
9635
  void bot.api.setMessageReaction(chat_id, msgId, [
@@ -9525,6 +9939,22 @@ async function handleInbound(
9525
9939
  const isQueuedPrefix = parsedQueue.queued
9526
9940
  let effectiveText = isSteerPrefix ? parsedSteer.body : (isQueuedPrefix ? parsedQueue.body : text)
9527
9941
 
9942
+ // --- #2045 provided-secret capture ---
9943
+ // If this chat is armed (operator tapped [Provide securely] on a
9944
+ // request_secret card), THIS message is the secret value: delete it,
9945
+ // write it to the vault, resume the agent, and STOP. Runs before secret
9946
+ // detection, recordInbound, and the IPC broadcast so the raw value is
9947
+ // never recorded, logged, or forwarded.
9948
+ if (armedSecretCaptures.has(chat_id)) {
9949
+ // Capture the RAW message text, not effectiveText — the secret value
9950
+ // must be vaulted verbatim, without the steer/queue prefix-stripping or
9951
+ // trim that effectiveText applies (review #2047 finding #1). A
9952
+ // credential that legitimately starts with `/s` or has surrounding
9953
+ // whitespace would otherwise be mangled.
9954
+ const consumed = await captureProvidedSecret(ctx, chat_id, msgId ?? undefined, text)
9955
+ if (consumed) return
9956
+ }
9957
+
9528
9958
  // --- Secret detection + vault-scrub ---
9529
9959
  // If the user pasted a secret, intercept BEFORE we record to history or
9530
9960
  // broadcast to the agent: write to vault, delete the Telegram message,
@@ -12109,6 +12539,10 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
12109
12539
  // Forward to connected bridges — same IPC the button handler uses.
12110
12540
  dispatchPermissionVerdict({ type: 'permission', requestId: request_id, behavior })
12111
12541
  resumeReactionAfterVerdict()
12542
+ postPermissionResumeMessage({
12543
+ behavior,
12544
+ action: naturalAction(details.tool_name, details.input_preview),
12545
+ })
12112
12546
  pendingPermissions.delete(request_id)
12113
12547
  process.stderr.write(
12114
12548
  `[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}\n`,
@@ -15482,6 +15916,14 @@ bot.on('callback_query:data', async ctx => {
15482
15916
  return
15483
15917
  }
15484
15918
 
15919
+ // #2045: agent-requested-secret card.
15920
+ // vsp:provide:<stageId> — arm capture; operator's next message is the value
15921
+ // vsp:decline:<stageId> — drop the request, notify the agent
15922
+ if (data.startsWith('vsp:')) {
15923
+ await handleSecretRequestCallback(ctx, data)
15924
+ return
15925
+ }
15926
+
15485
15927
  // Issue #969 P2b: vault recent-denial one-tap approval.
15486
15928
  // vrd:<agent>:<key> — mint a 30-day read-grant for the agent + key
15487
15929
  // Posted by /vault audit <agent> in the "Recent denials" section.
@@ -15763,6 +16205,10 @@ bot.on('callback_query:data', async ctx => {
15763
16205
  // below). Un-park 🙏 → working immediately so the operator sees the
15764
16206
  // agent continue while hostd writes the durable rule.
15765
16207
  resumeReactionAfterVerdict()
16208
+ postPermissionResumeMessage({
16209
+ behavior: 'allow',
16210
+ action: naturalAction(details.tool_name, details.input_preview),
16211
+ })
15766
16212
 
15767
16213
  // (3) Decide the persistence path. tryHostdDispatch returns
15768
16214
  // "not-configured" when host_control is disabled or the per-agent
@@ -15914,18 +16360,20 @@ bot.on('callback_query:data', async ctx => {
15914
16360
  return
15915
16361
  }
15916
16362
 
15917
- // Forward permission decision to connected bridges
16363
+ // Forward permission decision to connected bridges. Capture the work
16364
+ // phrase BEFORE deleting the pending entry — postPermissionResumeMessage
16365
+ // (fired in synthInbound below) names the resumed work.
16366
+ const resumeAction = (() => {
16367
+ const d = pendingPermissions.get(request_id)
16368
+ return d ? naturalAction(d.tool_name, d.input_preview) : ''
16369
+ })()
15918
16370
  pendingPermissions.delete(request_id)
15919
- // Deterministic "▶️ resuming…" beat (framework-posted, not model text):
15920
- // the verdict un-parks the suspended turn, so confirm to the operator
15921
- // that the agent received it and is continuing closing the "is it
15922
- // working or did my tap do nothing?" gap. Allow and deny both resume the
15923
- // turn (deny just hands claude a refusal it then handles).
15924
- const resumeAgent = process.env.SWITCHROOM_AGENT_NAME
15925
- const resumeBeat = resumeAgent
15926
- ? `▶️ ${escapeHtmlForTg(resumeAgent)} resuming…`
15927
- : '▶️ resuming…'
15928
- const label = `${behavior === 'allow' ? '✅ Allowed' : '❌ Denied'} · ${resumeBeat}`
16371
+ // The card collapses to a plain verdict label. The distinct agent-voiced
16372
+ // "got it, continuing: …" message (posted on resume below) now carries
16373
+ // the "is it working or did my tap do nothing?" signal the old
16374
+ // `▶️ resuming…` card footnote used to and names the work, which the
16375
+ // footnote never did. Keeps the card terse and the resume legible.
16376
+ const label = behavior === 'allow' ? '✅ Allowed' : '❌ Denied'
15929
16377
  // HTML-escape the source text — same hazard as the scope-commit and
15930
16378
  // recent-denial paths above. The permission card body
15931
16379
  // (formatPermissionCardBody) appends claude-supplied `description`
@@ -15956,6 +16404,10 @@ bot.on('callback_query:data', async ctx => {
15956
16404
  // Un-park the status reaction: 🙏 → working, re-arming the stall
15957
16405
  // watchdog that setAwaiting() suspended.
15958
16406
  resumeReactionAfterVerdict()
16407
+ postPermissionResumeMessage({
16408
+ behavior: behavior as 'allow' | 'deny',
16409
+ action: resumeAction,
16410
+ })
15959
16411
  },
15960
16412
  })
15961
16413
  })