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.
@@ -49462,8 +49462,8 @@ var {
49462
49462
  } = import__.default;
49463
49463
 
49464
49464
  // src/build-info.ts
49465
- var VERSION = "0.14.46";
49466
- var COMMIT_SHA = "df3deacf";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.46",
3
+ "version": "0.14.47",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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.46";
52038
- var COMMIT_SHA = "df3deacf";
52039
- var COMMIT_DATE = "2026-06-03T05:00:14Z";
52040
- var LATEST_PR = 2116;
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 turn = currentTurn;
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 permTopic = resolveAgentOutboundTopic({
54660
- kind: "permission",
54661
- turnInitiated: activeTurn != null,
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
- ...permThread != null ? { message_thread_id: permThread } : {}
54671
- }).catch((e) => {
54672
- process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}
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 turn = currentTurn
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
- // PR4b emitter sweep supergroup-mode permission card routing.
4689
- // Per CPO #3 the design is "turn-initiated requests follow the
4690
- // conversation topic; background requests go to admin alias."
4691
- // Permission requests come from the bridge mid-tool-use, so they
4692
- // are always turn-initiated in practice the currently active
4693
- // turn's sessionThreadId is the originating topic. Fall back to
4694
- // admin alias when no active turn (cron / background path).
4695
- // Fleet-shared / DM agents see `undefined` no
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 permTopic = resolveAgentOutboundTopic({
4704
- kind: 'permission',
4705
- turnInitiated: activeTurn != null,
4706
- originThreadId: activeTurn?.sessionThreadId,
4707
- })
4708
- const permSupergroup = resolveAgentSupergroupChatId()
4709
- for (const chat_id of access.allowFrom) {
4710
- // parse_mode=HTML pairs with formatPermissionCardBody (#1790)
4711
- // so the <b>/<i> tags render as formatting.
4712
- // The resolved topic is valid only in the agent's supergroup — attach
4713
- // it ONLY when this recipient IS that supergroup. allowFrom DMs get the
4714
- // card thread-less; attaching a topic to a DM yields 400 "message thread
4715
- // not found" → card never arrives → auto-deny → wedge (marko 2026-06-02).
4716
- const permThread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: permTopic, supergroupChatId: permSupergroup })
4717
- // allow-raw-bot-api: permission-request keyboard fan-out; topic-aware opts
4718
- void bot.api.sendMessage(chat_id, text, {
4719
- parse_mode: 'HTML',
4720
- reply_markup: keyboard,
4721
- ...(permThread != null ? { message_thread_id: permThread } : {}),
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', () => {