switchroom 0.14.46 → 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/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +90 -30
- package/telegram-plugin/gateway/gateway.ts +69 -54
- 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/dist/cli/switchroom.js
CHANGED
|
@@ -49462,8 +49462,8 @@ var {
|
|
|
49462
49462
|
} = import__.default;
|
|
49463
49463
|
|
|
49464
49464
|
// src/build-info.ts
|
|
49465
|
-
var VERSION = "0.14.
|
|
49466
|
-
var COMMIT_SHA = "
|
|
49465
|
+
var VERSION = "0.14.47";
|
|
49466
|
+
var COMMIT_SHA = "fbd2e491";
|
|
49467
49467
|
|
|
49468
49468
|
// src/cli/agent.ts
|
|
49469
49469
|
init_source();
|
package/package.json
CHANGED
|
@@ -51137,8 +51137,14 @@ function prettyMcpServer(server) {
|
|
|
51137
51137
|
}
|
|
51138
51138
|
|
|
51139
51139
|
// permission-title.ts
|
|
51140
|
+
init_redact();
|
|
51140
51141
|
var COMMAND_TITLE_MAX = 48;
|
|
51141
51142
|
var DESCRIPTION_LINE_MAX = 240;
|
|
51143
|
+
var HTTP_VERBS = new Set(["get", "post", "put", "patch", "delete", "head"]);
|
|
51144
|
+
var RESOURCE_KEYS = ["path", "endpoint", "url", "resource", "route"];
|
|
51145
|
+
var ARG_SUMMARY_MAX_KEYS = 4;
|
|
51146
|
+
var ARG_VALUE_MAX = 40;
|
|
51147
|
+
var ARG_SUMMARY_LINE_MAX = 180;
|
|
51142
51148
|
var MCP_TOOL_DESCRIPTIONS = {
|
|
51143
51149
|
"mcp__agent-config__config_get": "Read its own merged config",
|
|
51144
51150
|
"mcp__agent-config__cron_list": "List its own scheduled tasks",
|
|
@@ -51179,6 +51185,10 @@ function formatPermissionCardBody(opts) {
|
|
|
51179
51185
|
const rawWhy = (opts.description ?? "").replace(/\s+/g, " ").trim();
|
|
51180
51186
|
const truncatedWhy = rawWhy.length > DESCRIPTION_LINE_MAX ? rawWhy.slice(0, DESCRIPTION_LINE_MAX - 1) + "\u2026" : rawWhy;
|
|
51181
51187
|
lines.push(truncatedWhy.length > 0 ? `why: <i>${escapeTgHtml(truncatedWhy)}</i>` : `why: <i>not provided</i>`);
|
|
51188
|
+
const argSummary = mcpArgSummary(opts.toolName, opts.inputPreview);
|
|
51189
|
+
if (argSummary) {
|
|
51190
|
+
lines.push(`\u21b3 <i>${escapeTgHtml(argSummary)}</i>`);
|
|
51191
|
+
}
|
|
51182
51192
|
return lines.join(`
|
|
51183
51193
|
`);
|
|
51184
51194
|
}
|
|
@@ -51243,10 +51253,63 @@ function naturalMcpAction(toolName, input) {
|
|
|
51243
51253
|
}
|
|
51244
51254
|
if (parts.length >= 3) {
|
|
51245
51255
|
const verb = parts.slice(2).join("__").replace(/_/g, " ");
|
|
51256
|
+
if (!INTERNAL_MCP_SERVERS.has(server)) {
|
|
51257
|
+
const resourcePhrase = restResourcePhrase(server, verb, input);
|
|
51258
|
+
if (resourcePhrase)
|
|
51259
|
+
return resourcePhrase;
|
|
51260
|
+
}
|
|
51246
51261
|
return INTERNAL_MCP_SERVERS.has(server) ? verb : `${verb} (${prettyMcpServer(server)})`;
|
|
51247
51262
|
}
|
|
51248
51263
|
return `use ${toolName}`;
|
|
51249
51264
|
}
|
|
51265
|
+
function restResourcePhrase(server, verb, input) {
|
|
51266
|
+
if (!input)
|
|
51267
|
+
return null;
|
|
51268
|
+
let path = null;
|
|
51269
|
+
for (const key of RESOURCE_KEYS) {
|
|
51270
|
+
path = readString(input, key);
|
|
51271
|
+
if (path)
|
|
51272
|
+
break;
|
|
51273
|
+
}
|
|
51274
|
+
if (!path)
|
|
51275
|
+
return null;
|
|
51276
|
+
const v = HTTP_VERBS.has(verb.toLowerCase()) ? verb.toUpperCase() : verb;
|
|
51277
|
+
const shownPath = truncate6(redact(path), COMMAND_TITLE_MAX);
|
|
51278
|
+
return `${v} ${shownPath} (${prettyMcpServer(server)})`;
|
|
51279
|
+
}
|
|
51280
|
+
function mcpArgSummary(toolName, inputPreview) {
|
|
51281
|
+
if (!toolName.startsWith("mcp__"))
|
|
51282
|
+
return null;
|
|
51283
|
+
const server = toolName.split("__")[1] ?? "";
|
|
51284
|
+
if (INTERNAL_MCP_SERVERS.has(server))
|
|
51285
|
+
return null;
|
|
51286
|
+
const input = parseInput(inputPreview);
|
|
51287
|
+
if (!input)
|
|
51288
|
+
return null;
|
|
51289
|
+
const payload = input.body ?? input.query;
|
|
51290
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
51291
|
+
return null;
|
|
51292
|
+
}
|
|
51293
|
+
const parts = [];
|
|
51294
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
51295
|
+
if (parts.length >= ARG_SUMMARY_MAX_KEYS) {
|
|
51296
|
+
parts.push("\u2026");
|
|
51297
|
+
break;
|
|
51298
|
+
}
|
|
51299
|
+
if (value == null)
|
|
51300
|
+
continue;
|
|
51301
|
+
if (typeof value === "object") {
|
|
51302
|
+
parts.push(key);
|
|
51303
|
+
continue;
|
|
51304
|
+
}
|
|
51305
|
+
const shown = truncate6(redact(String(value)), ARG_VALUE_MAX);
|
|
51306
|
+
parts.push(`${key}: ${shown}`);
|
|
51307
|
+
}
|
|
51308
|
+
if (parts.length === 0)
|
|
51309
|
+
return null;
|
|
51310
|
+
const joined = parts.join(", ");
|
|
51311
|
+
return joined.length > ARG_SUMMARY_LINE_MAX ? joined.slice(0, ARG_SUMMARY_LINE_MAX - 1) + "\u2026" : joined;
|
|
51312
|
+
}
|
|
51250
51313
|
function describeGrant(toolName, inputPreview, option) {
|
|
51251
51314
|
const rule = option.rule;
|
|
51252
51315
|
if (rule.endsWith("__*") && rule.startsWith("mcp__")) {
|
|
@@ -52034,10 +52097,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52034
52097
|
}
|
|
52035
52098
|
|
|
52036
52099
|
// ../src/build-info.ts
|
|
52037
|
-
var VERSION = "0.14.
|
|
52038
|
-
var COMMIT_SHA = "
|
|
52039
|
-
var COMMIT_DATE = "2026-06-
|
|
52040
|
-
var LATEST_PR =
|
|
52100
|
+
var VERSION = "0.14.47";
|
|
52101
|
+
var COMMIT_SHA = "fbd2e491";
|
|
52102
|
+
var COMMIT_DATE = "2026-06-03T07:22:07Z";
|
|
52103
|
+
var LATEST_PR = 2119;
|
|
52041
52104
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
52042
52105
|
|
|
52043
52106
|
// gateway/boot-version.ts
|
|
@@ -53520,6 +53583,22 @@ function resumeReactionAfterVerdict() {
|
|
|
53520
53583
|
return;
|
|
53521
53584
|
activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))?.setThinking();
|
|
53522
53585
|
}
|
|
53586
|
+
function resolvePermissionCardTargets() {
|
|
53587
|
+
const turn = currentTurn;
|
|
53588
|
+
if (turn != null) {
|
|
53589
|
+
return [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }];
|
|
53590
|
+
}
|
|
53591
|
+
const sg = resolveAgentSupergroupChatId();
|
|
53592
|
+
const topic = resolveAgentOutboundTopic({
|
|
53593
|
+
kind: "permission",
|
|
53594
|
+
turnInitiated: false,
|
|
53595
|
+
originThreadId: undefined
|
|
53596
|
+
});
|
|
53597
|
+
return loadAccess().allowFrom.map((chatId) => ({
|
|
53598
|
+
chatId,
|
|
53599
|
+
threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg })
|
|
53600
|
+
}));
|
|
53601
|
+
}
|
|
53523
53602
|
function postPermissionResumeMessage(opts) {
|
|
53524
53603
|
if (process.env.SWITCHROOM_RESUME_MSG === "0")
|
|
53525
53604
|
return;
|
|
@@ -53529,19 +53608,7 @@ function postPermissionResumeMessage(opts) {
|
|
|
53529
53608
|
action: opts.action,
|
|
53530
53609
|
timeoutMinutes: opts.timeoutMinutes
|
|
53531
53610
|
});
|
|
53532
|
-
const
|
|
53533
|
-
const targets = turn != null ? [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }] : (() => {
|
|
53534
|
-
const sg = resolveAgentSupergroupChatId();
|
|
53535
|
-
const topic = resolveAgentOutboundTopic({
|
|
53536
|
-
kind: "permission",
|
|
53537
|
-
turnInitiated: false,
|
|
53538
|
-
originThreadId: undefined
|
|
53539
|
-
});
|
|
53540
|
-
return loadAccess().allowFrom.map((chatId) => ({
|
|
53541
|
-
chatId,
|
|
53542
|
-
threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg })
|
|
53543
|
-
}));
|
|
53544
|
-
})();
|
|
53611
|
+
const targets = resolvePermissionCardTargets();
|
|
53545
53612
|
for (const { chatId, threadId } of targets) {
|
|
53546
53613
|
swallowingApiCall(() => bot.api.sendMessage(chatId, text, {
|
|
53547
53614
|
parse_mode: "HTML",
|
|
@@ -54646,7 +54713,6 @@ var ipcServer = createIpcServer({
|
|
|
54646
54713
|
onPermissionRequest(_client, msg) {
|
|
54647
54714
|
const { requestId, toolName, description, inputPreview } = msg;
|
|
54648
54715
|
pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() });
|
|
54649
|
-
const access = loadAccess();
|
|
54650
54716
|
const text = formatPermissionCardBody({
|
|
54651
54717
|
toolName,
|
|
54652
54718
|
inputPreview,
|
|
@@ -54656,20 +54722,14 @@ var ipcServer = createIpcServer({
|
|
|
54656
54722
|
const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null;
|
|
54657
54723
|
const keyboard = buildPermissionActionRow(requestId, showAlways);
|
|
54658
54724
|
const activeTurn = currentTurn;
|
|
54659
|
-
const
|
|
54660
|
-
|
|
54661
|
-
|
|
54662
|
-
originThreadId: activeTurn?.sessionThreadId
|
|
54663
|
-
});
|
|
54664
|
-
const permSupergroup = resolveAgentSupergroupChatId();
|
|
54665
|
-
for (const chat_id of access.allowFrom) {
|
|
54666
|
-
const permThread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: permTopic, supergroupChatId: permSupergroup });
|
|
54667
|
-
bot.api.sendMessage(chat_id, text, {
|
|
54725
|
+
const targets = resolvePermissionCardTargets();
|
|
54726
|
+
for (const { chatId, threadId } of targets) {
|
|
54727
|
+
retryWithThreadFallback(robustApiCall, (tid) => bot.api.sendMessage(chatId, text, {
|
|
54668
54728
|
parse_mode: "HTML",
|
|
54669
54729
|
reply_markup: keyboard,
|
|
54670
|
-
...
|
|
54671
|
-
}).catch((e) => {
|
|
54672
|
-
process.stderr.write(`telegram gateway: permission_request send to ${
|
|
54730
|
+
...tid != null ? { message_thread_id: tid } : {}
|
|
54731
|
+
}), { threadId, chat_id: chatId, verb: "permission_request" }).catch((e) => {
|
|
54732
|
+
process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}
|
|
54673
54733
|
`);
|
|
54674
54734
|
});
|
|
54675
54735
|
}
|
|
@@ -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(
|
|
@@ -4665,7 +4689,6 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4665
4689
|
onPermissionRequest(_client: IpcClient, msg: PermissionRequestForward) {
|
|
4666
4690
|
const { requestId, toolName, description, inputPreview } = msg
|
|
4667
4691
|
pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
|
|
4668
|
-
const access = loadAccess()
|
|
4669
4692
|
// Natural-language card body — a plain sentence ("Gymbro wants to
|
|
4670
4693
|
// edit: supplement-log.md" + a why-line), never a raw tool id.
|
|
4671
4694
|
// The operator sees what is being requested and why at a glance.
|
|
@@ -4685,42 +4708,34 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4685
4708
|
// two-button row only.
|
|
4686
4709
|
const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null
|
|
4687
4710
|
const keyboard = buildPermissionActionRow(requestId, showAlways)
|
|
4688
|
-
//
|
|
4689
|
-
//
|
|
4690
|
-
//
|
|
4691
|
-
//
|
|
4692
|
-
//
|
|
4693
|
-
//
|
|
4694
|
-
//
|
|
4695
|
-
//
|
|
4696
|
-
// `message_thread_id` is added → behavior unchanged.
|
|
4697
|
-
// currentTurn is the singleton "claude is currently on this turn"
|
|
4698
|
-
// pointer — per Framing 1 / PR3b scope-discovery, claude
|
|
4699
|
-
// serializes so there's exactly one (or zero) active turn at any
|
|
4700
|
-
// moment. When set, the permission request is in-flight for that
|
|
4701
|
-
// 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).
|
|
4702
4719
|
const activeTurn = currentTurn
|
|
4703
|
-
const
|
|
4704
|
-
|
|
4705
|
-
|
|
4706
|
-
|
|
4707
|
-
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
//
|
|
4711
|
-
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
4715
|
-
|
|
4716
|
-
|
|
4717
|
-
|
|
4718
|
-
|
|
4719
|
-
|
|
4720
|
-
|
|
4721
|
-
|
|
4722
|
-
}).catch(e => {
|
|
4723
|
-
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`)
|
|
4724
4739
|
})
|
|
4725
4740
|
}
|
|
4726
4741
|
// Park the turn's status reaction on 🙏 (awaiting your tap) and
|
|
@@ -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
|
+
})
|
|
@@ -87,6 +87,35 @@ describe('naturalAction — MCP tools', () => {
|
|
|
87
87
|
'list files (Google Workspace)',
|
|
88
88
|
)
|
|
89
89
|
})
|
|
90
|
+
|
|
91
|
+
// Clarity fix: REST-wrapper MCP tools (brevo/meta/postiz via rest-server.mjs)
|
|
92
|
+
// take a `path` — surface it so "post (Brevo)" becomes "POST /smtp/email
|
|
93
|
+
// (Brevo)" and the operator can see WHICH endpoint is being written.
|
|
94
|
+
test('REST-wrapper write names the endpoint with an uppercased HTTP verb', () => {
|
|
95
|
+
expect(
|
|
96
|
+
naturalAction('mcp__brevo__post', JSON.stringify({ path: '/smtp/email', body: { to: 'x' } })),
|
|
97
|
+
).toBe('POST /smtp/email (Brevo)')
|
|
98
|
+
expect(
|
|
99
|
+
naturalAction('mcp__brevo__put', JSON.stringify({ path: '/contacts/123', body: {} })),
|
|
100
|
+
).toBe('PUT /contacts/123 (Brevo)')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('REST-wrapper read surfaces the path too', () => {
|
|
104
|
+
expect(
|
|
105
|
+
naturalAction('mcp__brevo__get', JSON.stringify({ path: '/contacts', query: { limit: 10 } })),
|
|
106
|
+
).toBe('GET /contacts (Brevo)')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('falls back to the plain verb phrase when there is no resource key', () => {
|
|
110
|
+
// No path → today's behavior, unchanged (defensive for unknown shapes).
|
|
111
|
+
expect(naturalAction('mcp__brevo__post', undefined)).toBe('post (Brevo)')
|
|
112
|
+
expect(naturalAction('mcp__brevo__post', JSON.stringify({ foo: 1 }))).toBe('post (Brevo)')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('internal REST-ish tool is NOT endpoint-enriched (stays a bare verb)', () => {
|
|
116
|
+
// hostd is internal → no "(Server)" tag, no path enrichment.
|
|
117
|
+
expect(naturalAction('mcp__hostd__do_thing', JSON.stringify({ path: '/x' }))).toBe('do thing')
|
|
118
|
+
})
|
|
90
119
|
})
|
|
91
120
|
|
|
92
121
|
describe('formatPermissionCardBody', () => {
|
|
@@ -156,6 +185,56 @@ describe('formatPermissionCardBody', () => {
|
|
|
156
185
|
})
|
|
157
186
|
expect(body).toContain('why: <i>first second paragraph</i>')
|
|
158
187
|
})
|
|
188
|
+
|
|
189
|
+
// Clarity fix: the card gains a third "↳" line summarizing the REST
|
|
190
|
+
// payload so the operator can see WHAT is being written, not just the
|
|
191
|
+
// endpoint. Values are redaction-passed + truncated; nested objects show
|
|
192
|
+
// as a bare key name.
|
|
193
|
+
test('REST write card: endpoint in the title + a payload summary line', () => {
|
|
194
|
+
const body = formatPermissionCardBody({
|
|
195
|
+
toolName: 'mcp__brevo__post',
|
|
196
|
+
inputPreview: JSON.stringify({
|
|
197
|
+
path: '/smtp/email',
|
|
198
|
+
body: { subject: 'Priority access', templateId: 12, to: [{ email: 'lisa@example.com' }] },
|
|
199
|
+
}),
|
|
200
|
+
description: 'HIGH RISK: write to the brevo API (POST).',
|
|
201
|
+
agentName: 'marko',
|
|
202
|
+
})
|
|
203
|
+
const lines = body.split('\n')
|
|
204
|
+
expect(lines[0]).toBe('🔐 <b>Marko</b> wants to POST /smtp/email (Brevo)')
|
|
205
|
+
expect(lines[1]).toBe('why: <i>HIGH RISK: write to the brevo API (POST).</i>')
|
|
206
|
+
// Third line: scalar keys show value; the nested `to` array shows key-only.
|
|
207
|
+
expect(lines[2]).toContain('↳')
|
|
208
|
+
expect(lines[2]).toContain('subject: Priority access')
|
|
209
|
+
expect(lines[2]).toContain('templateId: 12')
|
|
210
|
+
expect(lines[2]).toContain('to') // key-only, not the email object dumped
|
|
211
|
+
expect(lines[2]).not.toContain('lisa@example.com')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('no payload → no third line (DM / non-REST cards unchanged)', () => {
|
|
215
|
+
const body = formatPermissionCardBody({
|
|
216
|
+
toolName: 'Edit',
|
|
217
|
+
inputPreview: JSON.stringify({ file_path: '/a/b.md' }),
|
|
218
|
+
description: 'edit it',
|
|
219
|
+
agentName: 'clerk',
|
|
220
|
+
})
|
|
221
|
+
expect(body.split('\n')).toHaveLength(2)
|
|
222
|
+
expect(body).not.toContain('↳')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
test('redaction is load-bearing: a token in the payload is masked, never shown', () => {
|
|
226
|
+
// Build the fake token at runtime so the source file never holds a
|
|
227
|
+
// contiguous token literal (repo push-protection rule).
|
|
228
|
+
const fakeToken = 'sk-ant-' + 'api03-' + 'A'.repeat(48)
|
|
229
|
+
const body = formatPermissionCardBody({
|
|
230
|
+
toolName: 'mcp__brevo__post',
|
|
231
|
+
inputPreview: JSON.stringify({ path: '/contacts', body: { apiKey: fakeToken, name: 'Lisa' } }),
|
|
232
|
+
description: 'create a contact',
|
|
233
|
+
agentName: 'marko',
|
|
234
|
+
})
|
|
235
|
+
expect(body).not.toContain(fakeToken)
|
|
236
|
+
expect(body).toContain('name: Lisa') // benign value still surfaces
|
|
237
|
+
})
|
|
159
238
|
})
|
|
160
239
|
|
|
161
240
|
describe('describeGrant — phrased from the chosen scope', () => {
|