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.
- package/dist/cli/switchroom.js +20 -4
- package/dist/host-control/main.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +15 -0
- package/telegram-plugin/card-format.ts +7 -4
- package/telegram-plugin/dist/bridge/bridge.js +18 -0
- package/telegram-plugin/dist/gateway/gateway.js +2151 -1729
- package/telegram-plugin/dist/server.js +18 -0
- package/telegram-plugin/gateway/gateway.ts +464 -12
- package/telegram-plugin/history.ts +16 -4
- package/telegram-plugin/permission-title.ts +48 -0
- package/telegram-plugin/registry/subagents-schema.ts +35 -0
- package/telegram-plugin/registry/subagents.test.ts +78 -0
- package/telegram-plugin/secret-detect/patterns.ts +8 -0
- package/telegram-plugin/secret-detect/redact.ts +76 -0
- package/telegram-plugin/session-tail.ts +15 -0
- package/telegram-plugin/subagent-watcher.ts +19 -1
- package/telegram-plugin/tests/card-format.test.ts +16 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +80 -0
- package/telegram-plugin/tests/gateway-request-secret.test.ts +78 -0
- package/telegram-plugin/tests/history.test.ts +59 -0
- package/telegram-plugin/tests/permission-title.test.ts +68 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +35 -0
- package/telegram-plugin/tests/secret-detect-sanctum.test.ts +115 -0
- package/telegram-plugin/tests/session-tail.test.ts +43 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +15 -0
- package/telegram-plugin/uat/scenarios/jtbd-request-secret-dm.test.ts +101 -0
- 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
|
-
//
|
|
15920
|
-
//
|
|
15921
|
-
//
|
|
15922
|
-
//
|
|
15923
|
-
//
|
|
15924
|
-
const
|
|
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
|
})
|