switchroom 0.14.45 → 0.14.47
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/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +499 -378
- package/dist/host-control/main.js +148 -148
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/examples/switchroom.yaml +26 -0
- package/package.json +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +335 -225
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/gateway.ts +127 -60
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +17 -0
- package/telegram-plugin/permission-title.ts +100 -1
- package/telegram-plugin/tests/permission-card-routing.test.ts +77 -0
- package/telegram-plugin/tests/permission-title.test.ts +79 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +84 -0
|
@@ -2240,6 +2240,47 @@ function resumeReactionAfterVerdict(): void {
|
|
|
2240
2240
|
?.setThinking()
|
|
2241
2241
|
}
|
|
2242
2242
|
|
|
2243
|
+
/**
|
|
2244
|
+
* The recipient set for a permission card (the initial Approve/Deny card
|
|
2245
|
+
* AND the post-verdict resume message — they MUST route identically, so
|
|
2246
|
+
* both go through this one helper).
|
|
2247
|
+
*
|
|
2248
|
+
* Turn-initiated (the normal case — a permission gate fires mid-tool-use
|
|
2249
|
+
* with an active turn): send to the ORIGINATING chat+thread. For a
|
|
2250
|
+
* supergroup-owned agent working in a forum topic that is the supergroup +
|
|
2251
|
+
* the topic, so the card lands IN the topic the operator asked from (e.g.
|
|
2252
|
+
* marko's "CRM (Brevo)" topic) — not the operator's DM. For a DM agent the
|
|
2253
|
+
* originating chat IS the operator's DM (thread-less), unchanged.
|
|
2254
|
+
*
|
|
2255
|
+
* No active turn (cron / background / a swept turn at TTL): fall back to the
|
|
2256
|
+
* configured operator DMs (`allowFrom`), thread-stripped via
|
|
2257
|
+
* `topicForRecipient` so a DM never gets a `message_thread_id` (the 400
|
|
2258
|
+
* "message thread not found" → auto-deny wedge, #2096).
|
|
2259
|
+
*
|
|
2260
|
+
* Before this helper the INITIAL card emitter iterated `allowFrom`
|
|
2261
|
+
* unconditionally, so a supergroup card could only ever reach operator DMs —
|
|
2262
|
+
* the topic chat id is never in `allowFrom`. The resume message already
|
|
2263
|
+
* routed correctly; the card now matches it (marko, 2026-06-03).
|
|
2264
|
+
*/
|
|
2265
|
+
function resolvePermissionCardTargets(): Array<{ chatId: string; threadId: number | undefined }> {
|
|
2266
|
+
const turn = currentTurn
|
|
2267
|
+
if (turn != null) {
|
|
2268
|
+
return [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }]
|
|
2269
|
+
}
|
|
2270
|
+
const sg = resolveAgentSupergroupChatId()
|
|
2271
|
+
const topic = resolveAgentOutboundTopic({
|
|
2272
|
+
kind: 'permission',
|
|
2273
|
+
turnInitiated: false,
|
|
2274
|
+
originThreadId: undefined,
|
|
2275
|
+
})
|
|
2276
|
+
// allowFrom is normally operator DMs — attach the topic only to a
|
|
2277
|
+
// recipient that owns it (the supergroup), never a DM (marko wedge).
|
|
2278
|
+
return loadAccess().allowFrom.map(chatId => ({
|
|
2279
|
+
chatId,
|
|
2280
|
+
threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg }),
|
|
2281
|
+
}))
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2243
2284
|
/**
|
|
2244
2285
|
* Post the agent-voiced "got your verdict — continuing" message the
|
|
2245
2286
|
* instant the operator answers a permission card. Travels right beside
|
|
@@ -2269,24 +2310,7 @@ function postPermissionResumeMessage(opts: {
|
|
|
2269
2310
|
action: opts.action,
|
|
2270
2311
|
timeoutMinutes: opts.timeoutMinutes,
|
|
2271
2312
|
})
|
|
2272
|
-
const
|
|
2273
|
-
const targets: Array<{ chatId: string; threadId: number | undefined }> =
|
|
2274
|
-
turn != null
|
|
2275
|
-
? [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }]
|
|
2276
|
-
: (() => {
|
|
2277
|
-
const sg = resolveAgentSupergroupChatId()
|
|
2278
|
-
const topic = resolveAgentOutboundTopic({
|
|
2279
|
-
kind: 'permission',
|
|
2280
|
-
turnInitiated: false,
|
|
2281
|
-
originThreadId: undefined,
|
|
2282
|
-
})
|
|
2283
|
-
// allowFrom is normally operator DMs — attach the topic only to a
|
|
2284
|
-
// recipient that owns it (the supergroup), never a DM (marko wedge).
|
|
2285
|
-
return loadAccess().allowFrom.map(chatId => ({
|
|
2286
|
-
chatId,
|
|
2287
|
-
threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg }),
|
|
2288
|
-
}))
|
|
2289
|
-
})()
|
|
2313
|
+
const targets = resolvePermissionCardTargets()
|
|
2290
2314
|
for (const { chatId, threadId } of targets) {
|
|
2291
2315
|
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send
|
|
2292
2316
|
void swallowingApiCall(
|
|
@@ -2913,6 +2937,10 @@ interface PendingVaultRequestSave {
|
|
|
2913
2937
|
chat_id: string
|
|
2914
2938
|
/** Card message id (filled in after we send the card). */
|
|
2915
2939
|
card_message_id?: number
|
|
2940
|
+
/** Supergroup forum topic the agent was working in when it requested the
|
|
2941
|
+
* save — carried into the save-outcome inbound so the resumed reply lands
|
|
2942
|
+
* back in that topic, not General. */
|
|
2943
|
+
threadId?: number
|
|
2916
2944
|
/** Currently-suggested slug; may be renamed by the user. */
|
|
2917
2945
|
key: string
|
|
2918
2946
|
/** Storage shape — 'string' (default) or 'binary'. */
|
|
@@ -2954,6 +2982,10 @@ interface PendingVaultRequestAccess {
|
|
|
2954
2982
|
chat_id: string
|
|
2955
2983
|
/** Card message id (filled in after we send the card). */
|
|
2956
2984
|
card_message_id?: number
|
|
2985
|
+
/** Supergroup forum topic the agent was working in when it requested (the
|
|
2986
|
+
* card's originating thread). Carried into the grant-outcome inbound so the
|
|
2987
|
+
* resumed reply lands back in that topic, not General. */
|
|
2988
|
+
threadId?: number
|
|
2957
2989
|
/** Vault key the agent wants to read. */
|
|
2958
2990
|
key: string
|
|
2959
2991
|
/** 'read' (default) or 'write'. */
|
|
@@ -4657,7 +4689,6 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4657
4689
|
onPermissionRequest(_client: IpcClient, msg: PermissionRequestForward) {
|
|
4658
4690
|
const { requestId, toolName, description, inputPreview } = msg
|
|
4659
4691
|
pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
|
|
4660
|
-
const access = loadAccess()
|
|
4661
4692
|
// Natural-language card body — a plain sentence ("Gymbro wants to
|
|
4662
4693
|
// edit: supplement-log.md" + a why-line), never a raw tool id.
|
|
4663
4694
|
// The operator sees what is being requested and why at a glance.
|
|
@@ -4677,42 +4708,34 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4677
4708
|
// two-button row only.
|
|
4678
4709
|
const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null
|
|
4679
4710
|
const keyboard = buildPermissionActionRow(requestId, showAlways)
|
|
4680
|
-
//
|
|
4681
|
-
//
|
|
4682
|
-
//
|
|
4683
|
-
//
|
|
4684
|
-
//
|
|
4685
|
-
//
|
|
4686
|
-
//
|
|
4687
|
-
//
|
|
4688
|
-
// `message_thread_id` is added → behavior unchanged.
|
|
4689
|
-
// currentTurn is the singleton "claude is currently on this turn"
|
|
4690
|
-
// pointer — per Framing 1 / PR3b scope-discovery, claude
|
|
4691
|
-
// serializes so there's exactly one (or zero) active turn at any
|
|
4692
|
-
// moment. When set, the permission request is in-flight for that
|
|
4693
|
-
// turn and follows the originating topic.
|
|
4711
|
+
// Route the card to the SAME place the post-verdict resume message
|
|
4712
|
+
// lands (resolvePermissionCardTargets): the ORIGINATING chat+topic when
|
|
4713
|
+
// there's an active turn — so a supergroup agent's card appears IN the
|
|
4714
|
+
// topic the operator asked from (marko's "CRM (Brevo)"), not the
|
|
4715
|
+
// operator DM — else the configured operator DMs, thread-stripped. The
|
|
4716
|
+
// old code iterated `allowFrom` unconditionally, so a supergroup card
|
|
4717
|
+
// could only ever reach operator DMs (the topic chat id is never in
|
|
4718
|
+
// `allowFrom`) (marko, 2026-06-03).
|
|
4694
4719
|
const activeTurn = currentTurn
|
|
4695
|
-
const
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
|
|
4702
|
-
//
|
|
4703
|
-
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
}).catch(e => {
|
|
4715
|
-
process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
|
|
4720
|
+
const targets = resolvePermissionCardTargets()
|
|
4721
|
+
for (const { chatId, threadId } of targets) {
|
|
4722
|
+
// parse_mode=HTML pairs with formatPermissionCardBody (#1790) so the
|
|
4723
|
+
// <b>/<i> tags render. retryWithThreadFallback: if the topic was
|
|
4724
|
+
// deleted/recreated (stale thread id → 400 "message thread not
|
|
4725
|
+
// found"), re-send thread-less into the main chat so the card still
|
|
4726
|
+
// ARRIVES rather than vanishing → 10-min TTL auto-deny → wedge.
|
|
4727
|
+
// allow-raw-bot-api: wrapped in retryWithThreadFallback (retry policy); topic-aware send
|
|
4728
|
+
void retryWithThreadFallback<{ message_id: number }>(
|
|
4729
|
+
robustApiCall,
|
|
4730
|
+
(tid) =>
|
|
4731
|
+
bot.api.sendMessage(chatId, text, {
|
|
4732
|
+
parse_mode: 'HTML',
|
|
4733
|
+
reply_markup: keyboard,
|
|
4734
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
4735
|
+
}),
|
|
4736
|
+
{ threadId, chat_id: chatId, verb: 'permission_request' },
|
|
4737
|
+
).catch(e => {
|
|
4738
|
+
process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
|
|
4716
4739
|
})
|
|
4717
4740
|
}
|
|
4718
4741
|
// Park the turn's status reaction on 🙏 (awaiting your tap) and
|
|
@@ -7080,6 +7103,8 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
|
|
|
7080
7103
|
// crashing the tool call.
|
|
7081
7104
|
const text = renderVaultRequestSaveCard(pending, agentSlug)
|
|
7082
7105
|
const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
7106
|
+
// Remember the agent's working topic so the save-outcome inbound resumes in it.
|
|
7107
|
+
if (threadId != null) pending.threadId = threadId
|
|
7083
7108
|
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
7084
7109
|
robustApiCall,
|
|
7085
7110
|
(tid) =>
|
|
@@ -7120,12 +7145,16 @@ interface PendingSecretRequest {
|
|
|
7120
7145
|
reason?: string
|
|
7121
7146
|
staged_at: number
|
|
7122
7147
|
card_message_id?: number
|
|
7148
|
+
/** Supergroup forum topic the agent was working in — carried into the
|
|
7149
|
+
* provide/decline/fail outcome inbounds so the resumed reply lands back
|
|
7150
|
+
* in that topic, not General. */
|
|
7151
|
+
threadId?: number
|
|
7123
7152
|
}
|
|
7124
7153
|
// stageId -> request (lives until tapped or TTL).
|
|
7125
7154
|
const pendingSecretRequests = new Map<string, PendingSecretRequest>()
|
|
7126
7155
|
// chat_id -> the armed capture: the operator's NEXT message in this chat is
|
|
7127
7156
|
// the value for `key`. Set when [Provide securely] is tapped.
|
|
7128
|
-
interface ArmedSecretCapture { key: string; agent: string; stageId: string; armed_at: number }
|
|
7157
|
+
interface ArmedSecretCapture { key: string; agent: string; stageId: string; armed_at: number; threadId?: number }
|
|
7129
7158
|
const armedSecretCaptures = new Map<string, ArmedSecretCapture>()
|
|
7130
7159
|
const PENDING_SECRET_REQUEST_TTL_MS = 30 * 60_000 // card lifetime
|
|
7131
7160
|
const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000 // window to send the value after tapping
|
|
@@ -7194,6 +7223,8 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
|
|
|
7194
7223
|
|
|
7195
7224
|
const text = renderSecretRequestCard(pending)
|
|
7196
7225
|
const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
7226
|
+
// Remember the agent's working topic so the provide/decline/fail inbound resumes in it.
|
|
7227
|
+
if (threadId != null) pending.threadId = threadId
|
|
7197
7228
|
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
7198
7229
|
robustApiCall,
|
|
7199
7230
|
(tid) =>
|
|
@@ -7269,12 +7300,19 @@ async function captureProvidedSecret(
|
|
|
7269
7300
|
const failMsg: InboundMessage = {
|
|
7270
7301
|
type: 'inbound',
|
|
7271
7302
|
chatId: chat_id,
|
|
7303
|
+
...(armed.threadId != null ? { threadId: armed.threadId } : {}),
|
|
7272
7304
|
messageId: fts,
|
|
7273
7305
|
user: 'vault-broker',
|
|
7274
7306
|
userId: 0,
|
|
7275
7307
|
ts: fts,
|
|
7276
7308
|
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.`,
|
|
7277
|
-
meta: {
|
|
7309
|
+
meta: {
|
|
7310
|
+
source: 'secret_provide_failed',
|
|
7311
|
+
agent: armed.agent,
|
|
7312
|
+
...(armed.threadId != null ? { message_thread_id: String(armed.threadId) } : {}),
|
|
7313
|
+
key: armed.key,
|
|
7314
|
+
stage_id: armed.stageId,
|
|
7315
|
+
},
|
|
7278
7316
|
}
|
|
7279
7317
|
const fdelivered = ipcServer.sendToAgent(armed.agent, failMsg)
|
|
7280
7318
|
if (fdelivered) markClaudeBusyForInbound(failMsg)
|
|
@@ -7294,6 +7332,7 @@ async function captureProvidedSecret(
|
|
|
7294
7332
|
const synthetic: InboundMessage = {
|
|
7295
7333
|
type: 'inbound',
|
|
7296
7334
|
chatId: chat_id,
|
|
7335
|
+
...(armed.threadId != null ? { threadId: armed.threadId } : {}),
|
|
7297
7336
|
messageId: ts,
|
|
7298
7337
|
user: 'vault-broker',
|
|
7299
7338
|
userId: 0,
|
|
@@ -7305,6 +7344,7 @@ async function captureProvidedSecret(
|
|
|
7305
7344
|
meta: {
|
|
7306
7345
|
source: 'secret_provided',
|
|
7307
7346
|
agent: armed.agent,
|
|
7347
|
+
...(armed.threadId != null ? { message_thread_id: String(armed.threadId) } : {}),
|
|
7308
7348
|
key: armed.key,
|
|
7309
7349
|
stage_id: armed.stageId,
|
|
7310
7350
|
},
|
|
@@ -7345,6 +7385,7 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
|
|
|
7345
7385
|
agent: pending.agent,
|
|
7346
7386
|
stageId,
|
|
7347
7387
|
armed_at: Date.now(),
|
|
7388
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
7348
7389
|
})
|
|
7349
7390
|
await ctx.answerCallbackQuery({ text: 'Send the value now — it auto-deletes.' }).catch(() => {})
|
|
7350
7391
|
if (pending.card_message_id != null) {
|
|
@@ -7377,12 +7418,19 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
|
|
|
7377
7418
|
const synthetic: InboundMessage = {
|
|
7378
7419
|
type: 'inbound',
|
|
7379
7420
|
chatId: pending.chat_id,
|
|
7421
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
7380
7422
|
messageId: ts,
|
|
7381
7423
|
user: 'vault-broker',
|
|
7382
7424
|
userId: 0,
|
|
7383
7425
|
ts,
|
|
7384
7426
|
text: `🚫 Operator declined your request for \`vault:${pending.key}\`. Proceed without it or ask how they'd like to handle the task.`,
|
|
7385
|
-
meta: {
|
|
7427
|
+
meta: {
|
|
7428
|
+
source: 'secret_declined',
|
|
7429
|
+
agent: pending.agent,
|
|
7430
|
+
...(pending.threadId != null ? { message_thread_id: String(pending.threadId) } : {}),
|
|
7431
|
+
key: pending.key,
|
|
7432
|
+
stage_id: stageId,
|
|
7433
|
+
},
|
|
7386
7434
|
}
|
|
7387
7435
|
const delivered = ipcServer.sendToAgent(pending.agent, synthetic)
|
|
7388
7436
|
if (delivered) markClaudeBusyForInbound(synthetic)
|
|
@@ -7530,6 +7578,8 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
|
|
|
7530
7578
|
|
|
7531
7579
|
const text = renderVaultRequestAccessCard(pending)
|
|
7532
7580
|
const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
7581
|
+
// Remember the agent's working topic so the grant-outcome inbound resumes in it.
|
|
7582
|
+
if (threadId != null) pending.threadId = threadId
|
|
7533
7583
|
// #1075: deleted-topic safe — fall back to main chat.
|
|
7534
7584
|
const sent = await retryWithThreadFallback<{ message_id: number }>(
|
|
7535
7585
|
robustApiCall,
|
|
@@ -14023,6 +14073,7 @@ async function performVaultAccessApproval(
|
|
|
14023
14073
|
scope: pending.scope,
|
|
14024
14074
|
chat_id: pending.chat_id,
|
|
14025
14075
|
ttl_seconds: pending.ttl_seconds,
|
|
14076
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
14026
14077
|
},
|
|
14027
14078
|
grantId: id,
|
|
14028
14079
|
stageId,
|
|
@@ -14104,6 +14155,7 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
|
|
|
14104
14155
|
scope: pending.scope,
|
|
14105
14156
|
chat_id: pending.chat_id,
|
|
14106
14157
|
ttl_seconds: pending.ttl_seconds,
|
|
14158
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
14107
14159
|
},
|
|
14108
14160
|
stageId,
|
|
14109
14161
|
operatorId: senderId,
|
|
@@ -14278,7 +14330,12 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
14278
14330
|
// tool returned "waiting for operator", the turn ended, and a
|
|
14279
14331
|
// Discard left the agent silently idle forever.
|
|
14280
14332
|
const discardInbound = buildVaultSaveDiscardedInbound({
|
|
14281
|
-
ctx: {
|
|
14333
|
+
ctx: {
|
|
14334
|
+
agent: pending.agent,
|
|
14335
|
+
key: pending.key,
|
|
14336
|
+
chat_id: pending.chat_id,
|
|
14337
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
14338
|
+
},
|
|
14282
14339
|
stageId,
|
|
14283
14340
|
operatorId: senderId,
|
|
14284
14341
|
})
|
|
@@ -14401,7 +14458,12 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
14401
14458
|
const failReason =
|
|
14402
14459
|
(write.output || 'vault write error').split('\n')[0]!.slice(0, 200)
|
|
14403
14460
|
const failInbound = buildVaultSaveFailedInbound({
|
|
14404
|
-
ctx: {
|
|
14461
|
+
ctx: {
|
|
14462
|
+
agent: pending.agent,
|
|
14463
|
+
key: pending.key,
|
|
14464
|
+
chat_id: pending.chat_id,
|
|
14465
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
14466
|
+
},
|
|
14405
14467
|
stageId,
|
|
14406
14468
|
operatorId: senderId,
|
|
14407
14469
|
reason: failReason,
|
|
@@ -14432,7 +14494,12 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
14432
14494
|
// task that was blocked on this credential (symmetric with the
|
|
14433
14495
|
// vra: approve path; buffered if the bridge is mid-reconnect).
|
|
14434
14496
|
const okInbound = buildVaultSaveCompletedInbound({
|
|
14435
|
-
ctx: {
|
|
14497
|
+
ctx: {
|
|
14498
|
+
agent: pending.agent,
|
|
14499
|
+
key: pending.key,
|
|
14500
|
+
chat_id: pending.chat_id,
|
|
14501
|
+
...(pending.threadId != null ? { threadId: pending.threadId } : {}),
|
|
14502
|
+
},
|
|
14436
14503
|
stageId,
|
|
14437
14504
|
operatorId: senderId,
|
|
14438
14505
|
})
|
|
@@ -34,6 +34,10 @@ export interface VaultGrantInboundContext {
|
|
|
34
34
|
chat_id: string
|
|
35
35
|
/** Seconds. For approved grants; ignored for deny. */
|
|
36
36
|
ttl_seconds: number
|
|
37
|
+
/** Supergroup forum topic (message_thread_id) the agent was working in
|
|
38
|
+
* when it requested the credential — so the resumed turn's reply lands
|
|
39
|
+
* back in that topic, not General. Undefined for DM / non-topic requests. */
|
|
40
|
+
threadId?: number
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
/**
|
|
@@ -62,6 +66,7 @@ export function buildVaultGrantApprovedInbound(opts: {
|
|
|
62
66
|
return {
|
|
63
67
|
type: 'inbound',
|
|
64
68
|
chatId: opts.ctx.chat_id,
|
|
69
|
+
...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
|
|
65
70
|
messageId: ts, // synthetic — no Telegram message id exists
|
|
66
71
|
user: 'vault-broker',
|
|
67
72
|
userId: 0,
|
|
@@ -76,6 +81,7 @@ export function buildVaultGrantApprovedInbound(opts: {
|
|
|
76
81
|
meta: {
|
|
77
82
|
source: 'vault_grant_approved',
|
|
78
83
|
agent: opts.ctx.agent,
|
|
84
|
+
...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
|
|
79
85
|
key: opts.ctx.key,
|
|
80
86
|
scope: opts.ctx.scope,
|
|
81
87
|
grant_id: opts.grantId,
|
|
@@ -103,6 +109,7 @@ export function buildVaultGrantDeniedInbound(opts: {
|
|
|
103
109
|
return {
|
|
104
110
|
type: 'inbound',
|
|
105
111
|
chatId: opts.ctx.chat_id,
|
|
112
|
+
...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
|
|
106
113
|
messageId: ts,
|
|
107
114
|
user: 'vault-broker',
|
|
108
115
|
userId: 0,
|
|
@@ -116,6 +123,7 @@ export function buildVaultGrantDeniedInbound(opts: {
|
|
|
116
123
|
meta: {
|
|
117
124
|
source: 'vault_grant_denied',
|
|
118
125
|
agent: opts.ctx.agent,
|
|
126
|
+
...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
|
|
119
127
|
key: opts.ctx.key,
|
|
120
128
|
scope: opts.ctx.scope,
|
|
121
129
|
stage_id: opts.stageId,
|
|
@@ -133,6 +141,9 @@ export interface VaultSaveInboundContext {
|
|
|
133
141
|
/** Telegram chat the save card lived in — keeps the synthesized
|
|
134
142
|
* resume-turn associated with the originating conversation. */
|
|
135
143
|
chat_id: string
|
|
144
|
+
/** Supergroup forum topic the agent was working in when it requested the
|
|
145
|
+
* save — so the resumed reply lands in that topic, not General. */
|
|
146
|
+
threadId?: number
|
|
136
147
|
}
|
|
137
148
|
|
|
138
149
|
/**
|
|
@@ -154,6 +165,7 @@ export function buildVaultSaveCompletedInbound(opts: {
|
|
|
154
165
|
return {
|
|
155
166
|
type: 'inbound',
|
|
156
167
|
chatId: opts.ctx.chat_id,
|
|
168
|
+
...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
|
|
157
169
|
messageId: ts,
|
|
158
170
|
user: 'vault-broker',
|
|
159
171
|
userId: 0,
|
|
@@ -165,6 +177,7 @@ export function buildVaultSaveCompletedInbound(opts: {
|
|
|
165
177
|
meta: {
|
|
166
178
|
source: 'vault_save_completed',
|
|
167
179
|
agent: opts.ctx.agent,
|
|
180
|
+
...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
|
|
168
181
|
key: opts.ctx.key,
|
|
169
182
|
stage_id: opts.stageId,
|
|
170
183
|
operator_id: opts.operatorId,
|
|
@@ -187,6 +200,7 @@ export function buildVaultSaveFailedInbound(opts: {
|
|
|
187
200
|
return {
|
|
188
201
|
type: 'inbound',
|
|
189
202
|
chatId: opts.ctx.chat_id,
|
|
203
|
+
...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
|
|
190
204
|
messageId: ts,
|
|
191
205
|
user: 'vault-broker',
|
|
192
206
|
userId: 0,
|
|
@@ -200,6 +214,7 @@ export function buildVaultSaveFailedInbound(opts: {
|
|
|
200
214
|
meta: {
|
|
201
215
|
source: 'vault_save_failed',
|
|
202
216
|
agent: opts.ctx.agent,
|
|
217
|
+
...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
|
|
203
218
|
key: opts.ctx.key,
|
|
204
219
|
stage_id: opts.stageId,
|
|
205
220
|
operator_id: opts.operatorId,
|
|
@@ -221,6 +236,7 @@ export function buildVaultSaveDiscardedInbound(opts: {
|
|
|
221
236
|
return {
|
|
222
237
|
type: 'inbound',
|
|
223
238
|
chatId: opts.ctx.chat_id,
|
|
239
|
+
...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
|
|
224
240
|
messageId: ts,
|
|
225
241
|
user: 'vault-broker',
|
|
226
242
|
userId: 0,
|
|
@@ -234,6 +250,7 @@ export function buildVaultSaveDiscardedInbound(opts: {
|
|
|
234
250
|
meta: {
|
|
235
251
|
source: 'vault_save_discarded',
|
|
236
252
|
agent: opts.ctx.agent,
|
|
253
|
+
...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
|
|
237
254
|
key: opts.ctx.key,
|
|
238
255
|
stage_id: opts.stageId,
|
|
239
256
|
operator_id: opts.operatorId,
|
|
@@ -19,10 +19,21 @@
|
|
|
19
19
|
|
|
20
20
|
import { basename } from "node:path";
|
|
21
21
|
import { prettyMcpServer, type ScopeOption } from "./permission-rule.js";
|
|
22
|
+
import { redact } from "./secret-detect/redact.js";
|
|
22
23
|
|
|
23
24
|
const COMMAND_TITLE_MAX = 48;
|
|
24
25
|
const DESCRIPTION_LINE_MAX = 240;
|
|
25
26
|
|
|
27
|
+
/** HTTP methods the generic REST-wrapper MCP tools (brevo/meta/postiz/… via
|
|
28
|
+
* rest-server.mjs) expose as verbs — uppercased on the card so the operator
|
|
29
|
+
* reads "POST /smtp/email" as an API write, not "post". */
|
|
30
|
+
const HTTP_VERBS = new Set(["get", "post", "put", "patch", "delete", "head"]);
|
|
31
|
+
/** Keys that, on a REST-style MCP input, name the resource/endpoint. */
|
|
32
|
+
const RESOURCE_KEYS = ["path", "endpoint", "url", "resource", "route"];
|
|
33
|
+
const ARG_SUMMARY_MAX_KEYS = 4; // how many payload keys to surface on the card
|
|
34
|
+
const ARG_VALUE_MAX = 40; // per-value truncation in the arg-summary line
|
|
35
|
+
const ARG_SUMMARY_LINE_MAX = 180; // total cap for the arg-summary line
|
|
36
|
+
|
|
26
37
|
/**
|
|
27
38
|
* Human verb-phrases for switchroom-managed MCP tools. The raw
|
|
28
39
|
* `mcp__<server>__<tool>` name is operator-hostile. Phrases are written
|
|
@@ -104,6 +115,14 @@ export function formatPermissionCardBody(opts: {
|
|
|
104
115
|
: `why: <i>not provided</i>`,
|
|
105
116
|
);
|
|
106
117
|
|
|
118
|
+
// Third line (REST-wrapper MCP writes only): a redaction-safe summary of
|
|
119
|
+
// the payload so the operator can see WHAT is being sent, not just the
|
|
120
|
+
// endpoint — e.g. "↳ to: lisa@…, subject: Priority access…".
|
|
121
|
+
const argSummary = mcpArgSummary(opts.toolName, opts.inputPreview);
|
|
122
|
+
if (argSummary) {
|
|
123
|
+
lines.push(`↳ <i>${escapeTgHtml(argSummary)}</i>`);
|
|
124
|
+
}
|
|
125
|
+
|
|
107
126
|
return lines.join("\n");
|
|
108
127
|
}
|
|
109
128
|
|
|
@@ -171,7 +190,6 @@ function naturalMcpAction(
|
|
|
171
190
|
toolName: string,
|
|
172
191
|
input: Record<string, unknown> | null,
|
|
173
192
|
): string {
|
|
174
|
-
void input;
|
|
175
193
|
const parts = toolName.split("__");
|
|
176
194
|
const server = parts.length >= 2 ? parts[1]! : "";
|
|
177
195
|
const curated = MCP_TOOL_DESCRIPTIONS[toolName];
|
|
@@ -183,6 +201,15 @@ function naturalMcpAction(
|
|
|
183
201
|
}
|
|
184
202
|
if (parts.length >= 3) {
|
|
185
203
|
const verb = parts.slice(2).join("__").replace(/_/g, " ");
|
|
204
|
+
// External REST-wrapper tools (brevo/meta/postiz/…) take a `path`. Name
|
|
205
|
+
// the endpoint so "post (Brevo)" becomes "POST /smtp/email (Brevo)" —
|
|
206
|
+
// the operator can see WHICH resource is being written, not just that
|
|
207
|
+
// *something* is. Internal servers + tools without a resource key keep
|
|
208
|
+
// the plain verb phrasing.
|
|
209
|
+
if (!INTERNAL_MCP_SERVERS.has(server)) {
|
|
210
|
+
const resourcePhrase = restResourcePhrase(server, verb, input);
|
|
211
|
+
if (resourcePhrase) return resourcePhrase;
|
|
212
|
+
}
|
|
186
213
|
return INTERNAL_MCP_SERVERS.has(server)
|
|
187
214
|
? verb
|
|
188
215
|
: `${verb} (${prettyMcpServer(server)})`;
|
|
@@ -190,6 +217,78 @@ function naturalMcpAction(
|
|
|
190
217
|
return `use ${toolName}`;
|
|
191
218
|
}
|
|
192
219
|
|
|
220
|
+
/**
|
|
221
|
+
* For a REST-wrapper MCP call ({ path, body?, query? }), build the action
|
|
222
|
+
* phrase "<VERB> <path> (<Server>)" — e.g. "POST /smtp/email (Brevo)". The
|
|
223
|
+
* path is redaction-passed + length-capped before display. Returns null
|
|
224
|
+
* when the input carries no recognizable resource key (caller falls back to
|
|
225
|
+
* the plain verb phrasing).
|
|
226
|
+
*/
|
|
227
|
+
function restResourcePhrase(
|
|
228
|
+
server: string,
|
|
229
|
+
verb: string,
|
|
230
|
+
input: Record<string, unknown> | null,
|
|
231
|
+
): string | null {
|
|
232
|
+
if (!input) return null;
|
|
233
|
+
let path: string | null = null;
|
|
234
|
+
for (const key of RESOURCE_KEYS) {
|
|
235
|
+
path = readString(input, key);
|
|
236
|
+
if (path) break;
|
|
237
|
+
}
|
|
238
|
+
if (!path) return null;
|
|
239
|
+
const v = HTTP_VERBS.has(verb.toLowerCase()) ? verb.toUpperCase() : verb;
|
|
240
|
+
const shownPath = truncate(redact(path), COMMAND_TITLE_MAX);
|
|
241
|
+
return `${v} ${shownPath} (${prettyMcpServer(server)})`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A compact, redaction-safe one-line summary of a REST-wrapper MCP call's
|
|
246
|
+
* payload ({ body } for writes, { query } for reads) — the third card line.
|
|
247
|
+
* Shows up to {@link ARG_SUMMARY_MAX_KEYS} payload keys with short, masked
|
|
248
|
+
* scalar values ("to: lisa@…, subject: Priority access…"); nested
|
|
249
|
+
* objects/arrays surface as the bare key name (no value dump — avoids
|
|
250
|
+
* leaking PII/secrets and oversized blobs). Every value passes through
|
|
251
|
+
* `redact()` so an API key in the payload is masked, never surfaced.
|
|
252
|
+
* Returns null when there's nothing meaningful to show.
|
|
253
|
+
*/
|
|
254
|
+
function mcpArgSummary(
|
|
255
|
+
toolName: string,
|
|
256
|
+
inputPreview: string | undefined,
|
|
257
|
+
): string | null {
|
|
258
|
+
if (!toolName.startsWith("mcp__")) return null;
|
|
259
|
+
// Internal servers (agent-config / hostd / hindsight / telegram) use flat
|
|
260
|
+
// input schemas, not the REST `body`/`query` convention — and we don't
|
|
261
|
+
// endpoint-enrich their title line either, so keep the summary line off
|
|
262
|
+
// them too (redact() still runs, so this is intent-match, not a leak fix).
|
|
263
|
+
const server = toolName.split("__")[1] ?? "";
|
|
264
|
+
if (INTERNAL_MCP_SERVERS.has(server)) return null;
|
|
265
|
+
const input = parseInput(inputPreview);
|
|
266
|
+
if (!input) return null;
|
|
267
|
+
const payload = input.body ?? input.query;
|
|
268
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
const parts: string[] = [];
|
|
272
|
+
for (const [key, value] of Object.entries(payload as Record<string, unknown>)) {
|
|
273
|
+
if (parts.length >= ARG_SUMMARY_MAX_KEYS) {
|
|
274
|
+
parts.push("…");
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
if (value == null) continue;
|
|
278
|
+
if (typeof value === "object") {
|
|
279
|
+
parts.push(key); // nested object/array → key name only, never dumped
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const shown = truncate(redact(String(value)), ARG_VALUE_MAX);
|
|
283
|
+
parts.push(`${key}: ${shown}`);
|
|
284
|
+
}
|
|
285
|
+
if (parts.length === 0) return null;
|
|
286
|
+
const joined = parts.join(", ");
|
|
287
|
+
return joined.length > ARG_SUMMARY_LINE_MAX
|
|
288
|
+
? joined.slice(0, ARG_SUMMARY_LINE_MAX - 1) + "…"
|
|
289
|
+
: joined;
|
|
290
|
+
}
|
|
291
|
+
|
|
193
292
|
/**
|
|
194
293
|
* Confirmation phrase describing a grant that just landed, derived from
|
|
195
294
|
* the *scope option the operator chose* — so an always-allow's breadth
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural pin for permission-card topic routing.
|
|
3
|
+
*
|
|
4
|
+
* The bug (marko, 2026-06-03): the INITIAL Approve/Deny card emitter
|
|
5
|
+
* (`onPermissionRequest`) iterated `access.allowFrom` unconditionally as its
|
|
6
|
+
* recipient set. For a supergroup-owned agent, `allowFrom` holds only the
|
|
7
|
+
* operator DM user-ids — the supergroup chat id is never in it — so a
|
|
8
|
+
* permission card raised from a forum topic could ONLY ever land in the
|
|
9
|
+
* operator's DM, never in the topic the operator asked from. The
|
|
10
|
+
* post-verdict resume message already routed correctly (to the turn's
|
|
11
|
+
* originating chat+thread); the card did not.
|
|
12
|
+
*
|
|
13
|
+
* The fix routes BOTH the card and the resume through one shared helper,
|
|
14
|
+
* `resolvePermissionCardTargets()`, so they can't drift: turn-initiated →
|
|
15
|
+
* the originating chat+topic; no active turn → operator DMs, thread-stripped
|
|
16
|
+
* via topicForRecipient (the DM-thread 400 / auto-deny guard, #2096).
|
|
17
|
+
*
|
|
18
|
+
* gateway.ts is not unit-importable (top-level side effects), so this is a
|
|
19
|
+
* source-text pin in the same style as permission-verdict-resume-guard.ts.
|
|
20
|
+
* The routing *logic* (topicForRecipient / resolveAgentOutboundTopic) is
|
|
21
|
+
* unit-tested in src/telegram/topic-router.test.ts; the end-to-end
|
|
22
|
+
* "card lands in the topic" is covered by the supergroup UAT. This guards
|
|
23
|
+
* the wiring: that the card uses the shared helper and never reverts to the
|
|
24
|
+
* raw allowFrom fan-out.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { describe, it, expect } from 'vitest'
|
|
28
|
+
import { readFileSync } from 'node:fs'
|
|
29
|
+
import { fileURLToPath } from 'node:url'
|
|
30
|
+
import { dirname, resolve } from 'node:path'
|
|
31
|
+
|
|
32
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
33
|
+
const GATEWAY_SRC = readFileSync(
|
|
34
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
35
|
+
'utf8',
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
/** Slice the body of the `onPermissionRequest` IPC handler — from its header
|
|
39
|
+
* to the next handler method (`onHeartbeat`). */
|
|
40
|
+
function onPermissionRequestBody(): string {
|
|
41
|
+
const start = GATEWAY_SRC.indexOf('onPermissionRequest(')
|
|
42
|
+
expect(start).toBeGreaterThan(-1)
|
|
43
|
+
const rest = GATEWAY_SRC.slice(start)
|
|
44
|
+
const end = rest.indexOf('onHeartbeat(')
|
|
45
|
+
expect(end).toBeGreaterThan(-1)
|
|
46
|
+
return rest.slice(0, end)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('permission card routing', () => {
|
|
50
|
+
it('the shared target helper exists', () => {
|
|
51
|
+
expect(
|
|
52
|
+
/function\s+resolvePermissionCardTargets\s*\(/.test(GATEWAY_SRC),
|
|
53
|
+
).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('the initial card emitter routes via resolvePermissionCardTargets()', () => {
|
|
57
|
+
expect(onPermissionRequestBody()).toContain('resolvePermissionCardTargets()')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('the initial card emitter no longer iterates access.allowFrom directly (the bug shape)', () => {
|
|
61
|
+
// The raw fan-out loop is what sent supergroup cards to operator DMs.
|
|
62
|
+
expect(onPermissionRequestBody()).not.toMatch(
|
|
63
|
+
/for\s*\(\s*const\s+chat_id\s+of\s+access\.allowFrom\s*\)/,
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('the card send is wrapped in retryWithThreadFallback (stale-topic → thread-less, not a silent drop)', () => {
|
|
68
|
+
expect(onPermissionRequestBody()).toContain('retryWithThreadFallback')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('the resume message uses the SAME helper, so card + resume cannot drift', () => {
|
|
72
|
+
const start = GATEWAY_SRC.indexOf('function postPermissionResumeMessage(')
|
|
73
|
+
expect(start).toBeGreaterThan(-1)
|
|
74
|
+
const body = GATEWAY_SRC.slice(start, start + 1400)
|
|
75
|
+
expect(body).toContain('resolvePermissionCardTargets()')
|
|
76
|
+
})
|
|
77
|
+
})
|