switchroom 0.14.46 → 0.14.48
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 +138 -47
- package/telegram-plugin/gateway/gateway.ts +134 -55
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +12 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +17 -1
- package/telegram-plugin/gateway/resume-inbound-builder.ts +20 -4
- package/telegram-plugin/permission-title.ts +100 -1
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +27 -0
- 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/resume-inbound-builder.test.ts +19 -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.48";
|
|
49466
|
+
var COMMIT_SHA = "a6517652";
|
|
49467
49467
|
|
|
49468
49468
|
// src/cli/agent.ts
|
|
49469
49469
|
init_source();
|
package/package.json
CHANGED
|
@@ -46927,7 +46927,7 @@ function escapeHtml7(s) {
|
|
|
46927
46927
|
|
|
46928
46928
|
// gateway/pending-inbound-buffer.ts
|
|
46929
46929
|
var DEFAULT_PENDING_INBOUND_CAP = 32;
|
|
46930
|
-
function redeliverBufferedInbound(buffer, agent, send, spool) {
|
|
46930
|
+
function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
|
|
46931
46931
|
const pending = buffer.drain(agent);
|
|
46932
46932
|
let redelivered = 0;
|
|
46933
46933
|
let rebuffered = 0;
|
|
@@ -46942,6 +46942,7 @@ function redeliverBufferedInbound(buffer, agent, send, spool) {
|
|
|
46942
46942
|
for (const o of originals)
|
|
46943
46943
|
spool?.ack(o);
|
|
46944
46944
|
redelivered += originals.length;
|
|
46945
|
+
onDelivered?.(merged, originals);
|
|
46945
46946
|
} else {
|
|
46946
46947
|
for (const o of originals)
|
|
46947
46948
|
buffer.push(agent, o);
|
|
@@ -47004,14 +47005,14 @@ function mergeRun(run2) {
|
|
|
47004
47005
|
merged.attachment = mediaEntry.attachment;
|
|
47005
47006
|
return merged;
|
|
47006
47007
|
}
|
|
47007
|
-
function idleDrainTick(buffer, agent, isBridgeAlive, send, spool) {
|
|
47008
|
+
function idleDrainTick(buffer, agent, isBridgeAlive, send, spool, onDelivered) {
|
|
47008
47009
|
if (!agent)
|
|
47009
47010
|
return null;
|
|
47010
47011
|
if (buffer.depth(agent) === 0)
|
|
47011
47012
|
return null;
|
|
47012
47013
|
if (!isBridgeAlive())
|
|
47013
47014
|
return null;
|
|
47014
|
-
return redeliverBufferedInbound(buffer, agent, send, spool);
|
|
47015
|
+
return redeliverBufferedInbound(buffer, agent, send, spool, onDelivered);
|
|
47015
47016
|
}
|
|
47016
47017
|
function createPendingInboundBuffer(opts = {}) {
|
|
47017
47018
|
const cap = opts.capPerAgent ?? DEFAULT_PENDING_INBOUND_CAP;
|
|
@@ -47618,7 +47619,7 @@ function formatEventDetail(event) {
|
|
|
47618
47619
|
}
|
|
47619
47620
|
|
|
47620
47621
|
// gateway/pending-inbound-buffer.ts
|
|
47621
|
-
function redeliverBufferedInbound2(buffer, agent, send, spool) {
|
|
47622
|
+
function redeliverBufferedInbound2(buffer, agent, send, spool, onDelivered) {
|
|
47622
47623
|
const pending = buffer.drain(agent);
|
|
47623
47624
|
let redelivered = 0;
|
|
47624
47625
|
let rebuffered = 0;
|
|
@@ -47633,6 +47634,7 @@ function redeliverBufferedInbound2(buffer, agent, send, spool) {
|
|
|
47633
47634
|
for (const o of originals)
|
|
47634
47635
|
spool?.ack(o);
|
|
47635
47636
|
redelivered += originals.length;
|
|
47637
|
+
onDelivered?.(merged, originals);
|
|
47636
47638
|
} else {
|
|
47637
47639
|
for (const o of originals)
|
|
47638
47640
|
buffer.push(agent, o);
|
|
@@ -47725,7 +47727,7 @@ function dispatchOne(effect, ctx) {
|
|
|
47725
47727
|
}
|
|
47726
47728
|
return ctx.ipcServer.sendToAgent(ctx.selfAgent, msg);
|
|
47727
47729
|
};
|
|
47728
|
-
const result = redeliverBufferedInbound2(ctx.pendingInboundBuffer, ctx.selfAgent, send, ctx.inboundSpool ?? undefined);
|
|
47730
|
+
const result = redeliverBufferedInbound2(ctx.pendingInboundBuffer, ctx.selfAgent, send, ctx.inboundSpool ?? undefined, ctx.onUserInboundDelivered ? (merged) => ctx.onUserInboundDelivered(merged) : undefined);
|
|
47729
47731
|
if (result.drained > 0) {
|
|
47730
47732
|
log(`telegram gateway: dispatch drainBuffer agent=${ctx.selfAgent} ` + `drained=${result.drained} redelivered=${result.redelivered} ` + `rebuffered=${result.rebuffered}
|
|
47731
47733
|
`);
|
|
@@ -51137,8 +51139,14 @@ function prettyMcpServer(server) {
|
|
|
51137
51139
|
}
|
|
51138
51140
|
|
|
51139
51141
|
// permission-title.ts
|
|
51142
|
+
init_redact();
|
|
51140
51143
|
var COMMAND_TITLE_MAX = 48;
|
|
51141
51144
|
var DESCRIPTION_LINE_MAX = 240;
|
|
51145
|
+
var HTTP_VERBS = new Set(["get", "post", "put", "patch", "delete", "head"]);
|
|
51146
|
+
var RESOURCE_KEYS = ["path", "endpoint", "url", "resource", "route"];
|
|
51147
|
+
var ARG_SUMMARY_MAX_KEYS = 4;
|
|
51148
|
+
var ARG_VALUE_MAX = 40;
|
|
51149
|
+
var ARG_SUMMARY_LINE_MAX = 180;
|
|
51142
51150
|
var MCP_TOOL_DESCRIPTIONS = {
|
|
51143
51151
|
"mcp__agent-config__config_get": "Read its own merged config",
|
|
51144
51152
|
"mcp__agent-config__cron_list": "List its own scheduled tasks",
|
|
@@ -51179,6 +51187,10 @@ function formatPermissionCardBody(opts) {
|
|
|
51179
51187
|
const rawWhy = (opts.description ?? "").replace(/\s+/g, " ").trim();
|
|
51180
51188
|
const truncatedWhy = rawWhy.length > DESCRIPTION_LINE_MAX ? rawWhy.slice(0, DESCRIPTION_LINE_MAX - 1) + "\u2026" : rawWhy;
|
|
51181
51189
|
lines.push(truncatedWhy.length > 0 ? `why: <i>${escapeTgHtml(truncatedWhy)}</i>` : `why: <i>not provided</i>`);
|
|
51190
|
+
const argSummary = mcpArgSummary(opts.toolName, opts.inputPreview);
|
|
51191
|
+
if (argSummary) {
|
|
51192
|
+
lines.push(`\u21b3 <i>${escapeTgHtml(argSummary)}</i>`);
|
|
51193
|
+
}
|
|
51182
51194
|
return lines.join(`
|
|
51183
51195
|
`);
|
|
51184
51196
|
}
|
|
@@ -51243,10 +51255,63 @@ function naturalMcpAction(toolName, input) {
|
|
|
51243
51255
|
}
|
|
51244
51256
|
if (parts.length >= 3) {
|
|
51245
51257
|
const verb = parts.slice(2).join("__").replace(/_/g, " ");
|
|
51258
|
+
if (!INTERNAL_MCP_SERVERS.has(server)) {
|
|
51259
|
+
const resourcePhrase = restResourcePhrase(server, verb, input);
|
|
51260
|
+
if (resourcePhrase)
|
|
51261
|
+
return resourcePhrase;
|
|
51262
|
+
}
|
|
51246
51263
|
return INTERNAL_MCP_SERVERS.has(server) ? verb : `${verb} (${prettyMcpServer(server)})`;
|
|
51247
51264
|
}
|
|
51248
51265
|
return `use ${toolName}`;
|
|
51249
51266
|
}
|
|
51267
|
+
function restResourcePhrase(server, verb, input) {
|
|
51268
|
+
if (!input)
|
|
51269
|
+
return null;
|
|
51270
|
+
let path = null;
|
|
51271
|
+
for (const key of RESOURCE_KEYS) {
|
|
51272
|
+
path = readString(input, key);
|
|
51273
|
+
if (path)
|
|
51274
|
+
break;
|
|
51275
|
+
}
|
|
51276
|
+
if (!path)
|
|
51277
|
+
return null;
|
|
51278
|
+
const v = HTTP_VERBS.has(verb.toLowerCase()) ? verb.toUpperCase() : verb;
|
|
51279
|
+
const shownPath = truncate6(redact(path), COMMAND_TITLE_MAX);
|
|
51280
|
+
return `${v} ${shownPath} (${prettyMcpServer(server)})`;
|
|
51281
|
+
}
|
|
51282
|
+
function mcpArgSummary(toolName, inputPreview) {
|
|
51283
|
+
if (!toolName.startsWith("mcp__"))
|
|
51284
|
+
return null;
|
|
51285
|
+
const server = toolName.split("__")[1] ?? "";
|
|
51286
|
+
if (INTERNAL_MCP_SERVERS.has(server))
|
|
51287
|
+
return null;
|
|
51288
|
+
const input = parseInput(inputPreview);
|
|
51289
|
+
if (!input)
|
|
51290
|
+
return null;
|
|
51291
|
+
const payload = input.body ?? input.query;
|
|
51292
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
51293
|
+
return null;
|
|
51294
|
+
}
|
|
51295
|
+
const parts = [];
|
|
51296
|
+
for (const [key, value] of Object.entries(payload)) {
|
|
51297
|
+
if (parts.length >= ARG_SUMMARY_MAX_KEYS) {
|
|
51298
|
+
parts.push("\u2026");
|
|
51299
|
+
break;
|
|
51300
|
+
}
|
|
51301
|
+
if (value == null)
|
|
51302
|
+
continue;
|
|
51303
|
+
if (typeof value === "object") {
|
|
51304
|
+
parts.push(key);
|
|
51305
|
+
continue;
|
|
51306
|
+
}
|
|
51307
|
+
const shown = truncate6(redact(String(value)), ARG_VALUE_MAX);
|
|
51308
|
+
parts.push(`${key}: ${shown}`);
|
|
51309
|
+
}
|
|
51310
|
+
if (parts.length === 0)
|
|
51311
|
+
return null;
|
|
51312
|
+
const joined = parts.join(", ");
|
|
51313
|
+
return joined.length > ARG_SUMMARY_LINE_MAX ? joined.slice(0, ARG_SUMMARY_LINE_MAX - 1) + "\u2026" : joined;
|
|
51314
|
+
}
|
|
51250
51315
|
function describeGrant(toolName, inputPreview, option) {
|
|
51251
51316
|
const rule = option.rule;
|
|
51252
51317
|
if (rule.endsWith("__*") && rule.startsWith("mcp__")) {
|
|
@@ -52034,10 +52099,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
52034
52099
|
}
|
|
52035
52100
|
|
|
52036
52101
|
// ../src/build-info.ts
|
|
52037
|
-
var VERSION = "0.14.
|
|
52038
|
-
var COMMIT_SHA = "
|
|
52039
|
-
var COMMIT_DATE = "2026-06-
|
|
52040
|
-
var LATEST_PR =
|
|
52102
|
+
var VERSION = "0.14.48";
|
|
52103
|
+
var COMMIT_SHA = "a6517652";
|
|
52104
|
+
var COMMIT_DATE = "2026-06-03T07:57:29Z";
|
|
52105
|
+
var LATEST_PR = 2120;
|
|
52041
52106
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
52042
52107
|
|
|
52043
52108
|
// gateway/boot-version.ts
|
|
@@ -52602,14 +52667,20 @@ function buildResumeWatchdogReportInbound(ctx) {
|
|
|
52602
52667
|
meta
|
|
52603
52668
|
};
|
|
52604
52669
|
}
|
|
52605
|
-
function selectResumeBuilder(endedVia) {
|
|
52670
|
+
function selectResumeBuilder(endedVia, opts) {
|
|
52671
|
+
let kind;
|
|
52606
52672
|
if (endedVia === "timeout")
|
|
52673
|
+
kind = "report";
|
|
52674
|
+
else if (endedVia === "restart" || endedVia === "sigterm" || endedVia === "unknown")
|
|
52675
|
+
kind = "resume";
|
|
52676
|
+
else if (endedVia == null)
|
|
52677
|
+
kind = "resume";
|
|
52678
|
+
else
|
|
52679
|
+
kind = null;
|
|
52680
|
+
if (kind === "resume" && opts?.ageMs != null && opts?.maxAgeMs != null && opts.ageMs > opts.maxAgeMs) {
|
|
52607
52681
|
return "report";
|
|
52608
|
-
|
|
52609
|
-
|
|
52610
|
-
if (endedVia == null)
|
|
52611
|
-
return "resume";
|
|
52612
|
-
return null;
|
|
52682
|
+
}
|
|
52683
|
+
return kind;
|
|
52613
52684
|
}
|
|
52614
52685
|
|
|
52615
52686
|
// registry/subagents-schema.ts
|
|
@@ -53060,7 +53131,14 @@ try {
|
|
|
53060
53131
|
const pending2 = findLatestTurnIfInterrupted(turnsDb);
|
|
53061
53132
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
53062
53133
|
if (pending2 != null && selfAgent) {
|
|
53063
|
-
const
|
|
53134
|
+
const RESUME_MAX_AGE_MS = (() => {
|
|
53135
|
+
const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS);
|
|
53136
|
+
return Number.isFinite(v) && v > 0 ? v : 10800000;
|
|
53137
|
+
})();
|
|
53138
|
+
const kind = selectResumeBuilder(pending2.ended_via, {
|
|
53139
|
+
ageMs: Math.max(0, Date.now() - pending2.started_at),
|
|
53140
|
+
maxAgeMs: RESUME_MAX_AGE_MS
|
|
53141
|
+
});
|
|
53064
53142
|
if (kind === "resume") {
|
|
53065
53143
|
bootResumeInbound = { agent: selfAgent, msg: buildResumeInterruptedInbound({ turn: pending2 }) };
|
|
53066
53144
|
} else if (kind === "report") {
|
|
@@ -53327,7 +53405,7 @@ function purgeReactionTracking(key, endingTurn) {
|
|
|
53327
53405
|
if (d)
|
|
53328
53406
|
markClaudeBusyForInbound(m);
|
|
53329
53407
|
return d;
|
|
53330
|
-
}, inboundSpool);
|
|
53408
|
+
}, inboundSpool, trackRedeliveredInbound);
|
|
53331
53409
|
if (fr.redelivered > 0) {
|
|
53332
53410
|
process.stderr.write(`telegram gateway: turn-complete flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
|
|
53333
53411
|
`);
|
|
@@ -53357,7 +53435,7 @@ function releaseTurnBufferGate(key) {
|
|
|
53357
53435
|
if (d)
|
|
53358
53436
|
markClaudeBusyForInbound(m);
|
|
53359
53437
|
return d;
|
|
53360
|
-
}, inboundSpool);
|
|
53438
|
+
}, inboundSpool, trackRedeliveredInbound);
|
|
53361
53439
|
if (fr.redelivered > 0) {
|
|
53362
53440
|
process.stderr.write(`telegram gateway: reply-released-gate flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
|
|
53363
53441
|
`);
|
|
@@ -53520,6 +53598,22 @@ function resumeReactionAfterVerdict() {
|
|
|
53520
53598
|
return;
|
|
53521
53599
|
activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))?.setThinking();
|
|
53522
53600
|
}
|
|
53601
|
+
function resolvePermissionCardTargets() {
|
|
53602
|
+
const turn = currentTurn;
|
|
53603
|
+
if (turn != null) {
|
|
53604
|
+
return [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }];
|
|
53605
|
+
}
|
|
53606
|
+
const sg = resolveAgentSupergroupChatId();
|
|
53607
|
+
const topic = resolveAgentOutboundTopic({
|
|
53608
|
+
kind: "permission",
|
|
53609
|
+
turnInitiated: false,
|
|
53610
|
+
originThreadId: undefined
|
|
53611
|
+
});
|
|
53612
|
+
return loadAccess().allowFrom.map((chatId) => ({
|
|
53613
|
+
chatId,
|
|
53614
|
+
threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg })
|
|
53615
|
+
}));
|
|
53616
|
+
}
|
|
53523
53617
|
function postPermissionResumeMessage(opts) {
|
|
53524
53618
|
if (process.env.SWITCHROOM_RESUME_MSG === "0")
|
|
53525
53619
|
return;
|
|
@@ -53529,19 +53623,7 @@ function postPermissionResumeMessage(opts) {
|
|
|
53529
53623
|
action: opts.action,
|
|
53530
53624
|
timeoutMinutes: opts.timeoutMinutes
|
|
53531
53625
|
});
|
|
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
|
-
})();
|
|
53626
|
+
const targets = resolvePermissionCardTargets();
|
|
53545
53627
|
for (const { chatId, threadId } of targets) {
|
|
53546
53628
|
swallowingApiCall(() => bot.api.sendMessage(chatId, text, {
|
|
53547
53629
|
parse_mode: "HTML",
|
|
@@ -54339,7 +54421,7 @@ startTimer({
|
|
|
54339
54421
|
if (d)
|
|
54340
54422
|
markClaudeBusyForInbound(m);
|
|
54341
54423
|
return d;
|
|
54342
|
-
}, inboundSpool);
|
|
54424
|
+
}, inboundSpool, trackRedeliveredInbound);
|
|
54343
54425
|
process.stderr.write(`telegram gateway: silence-poke framework-fallback ended wedged turn chat=${fbChatId} thread=${ctx.threadId ?? "-"} silence_ms=${ctx.silenceMs} currentTurn_nulled=${turnMatchesFallback} drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ""}${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ""}
|
|
54344
54426
|
`);
|
|
54345
54427
|
}
|
|
@@ -54349,6 +54431,20 @@ var _deliveryMachineTick = setInterval(() => {
|
|
|
54349
54431
|
shadowEmit({ kind: "tick", now: Date.now() });
|
|
54350
54432
|
}, DELIVERY_MACHINE_TICK_MS);
|
|
54351
54433
|
_deliveryMachineTick.unref?.();
|
|
54434
|
+
function trackRedeliveredInbound(merged) {
|
|
54435
|
+
if (!DELIVERY_CONFIRM_ENABLED)
|
|
54436
|
+
return;
|
|
54437
|
+
if (!shouldTrackDelivery({
|
|
54438
|
+
isSteering: false,
|
|
54439
|
+
isInterrupt: false,
|
|
54440
|
+
hasSource: merged.meta?.source != null,
|
|
54441
|
+
effectiveText: merged.text
|
|
54442
|
+
})) {
|
|
54443
|
+
return;
|
|
54444
|
+
}
|
|
54445
|
+
const key = chatKey2(merged.chatId, merged.threadId != null ? Number(merged.threadId) : null);
|
|
54446
|
+
trackDelivery(deliveryQueue, key, merged, Date.now(), merged.messageId != null ? String(merged.messageId) : null);
|
|
54447
|
+
}
|
|
54352
54448
|
async function redeliverStrandedInbound(p) {
|
|
54353
54449
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
|
|
54354
54450
|
process.stderr.write(`telegram gateway: inbound strand (no enqueue ack) key=${p.key} \u2014 re-clearing composer + re-delivering
|
|
@@ -54455,7 +54551,8 @@ var ipcServer = createIpcServer({
|
|
|
54455
54551
|
pendingInboundBuffer,
|
|
54456
54552
|
inboundSpool: inboundSpool ?? null,
|
|
54457
54553
|
pendingPermissionBuffer,
|
|
54458
|
-
client: client3
|
|
54554
|
+
client: client3,
|
|
54555
|
+
onUserInboundDelivered: trackRedeliveredInbound
|
|
54459
54556
|
});
|
|
54460
54557
|
} else {
|
|
54461
54558
|
const pending2 = pendingInboundBuffer.drain(client3.agentName);
|
|
@@ -54463,6 +54560,7 @@ var ipcServer = createIpcServer({
|
|
|
54463
54560
|
try {
|
|
54464
54561
|
client3.send(msg);
|
|
54465
54562
|
inboundSpool?.ack(msg);
|
|
54563
|
+
trackRedeliveredInbound(msg);
|
|
54466
54564
|
} catch (err) {
|
|
54467
54565
|
process.stderr.write(`telegram gateway: pending-inbound drain failed agent=${client3.agentName} source=${msg.meta?.source ?? "-"}: ${err.message}
|
|
54468
54566
|
`);
|
|
@@ -54646,7 +54744,6 @@ var ipcServer = createIpcServer({
|
|
|
54646
54744
|
onPermissionRequest(_client, msg) {
|
|
54647
54745
|
const { requestId, toolName, description, inputPreview } = msg;
|
|
54648
54746
|
pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() });
|
|
54649
|
-
const access = loadAccess();
|
|
54650
54747
|
const text = formatPermissionCardBody({
|
|
54651
54748
|
toolName,
|
|
54652
54749
|
inputPreview,
|
|
@@ -54656,20 +54753,14 @@ var ipcServer = createIpcServer({
|
|
|
54656
54753
|
const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null;
|
|
54657
54754
|
const keyboard = buildPermissionActionRow(requestId, showAlways);
|
|
54658
54755
|
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, {
|
|
54756
|
+
const targets = resolvePermissionCardTargets();
|
|
54757
|
+
for (const { chatId, threadId } of targets) {
|
|
54758
|
+
retryWithThreadFallback(robustApiCall, (tid) => bot.api.sendMessage(chatId, text, {
|
|
54668
54759
|
parse_mode: "HTML",
|
|
54669
54760
|
reply_markup: keyboard,
|
|
54670
|
-
...
|
|
54671
|
-
}).catch((e) => {
|
|
54672
|
-
process.stderr.write(`telegram gateway: permission_request send to ${
|
|
54761
|
+
...tid != null ? { message_thread_id: tid } : {}
|
|
54762
|
+
}), { threadId, chat_id: chatId, verb: "permission_request" }).catch((e) => {
|
|
54763
|
+
process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}
|
|
54673
54764
|
`);
|
|
54674
54765
|
});
|
|
54675
54766
|
}
|
|
@@ -55025,7 +55116,7 @@ if (!STATIC) {
|
|
|
55025
55116
|
if (d)
|
|
55026
55117
|
markClaudeBusyForInbound(m);
|
|
55027
55118
|
return d;
|
|
55028
|
-
}, inboundSpool);
|
|
55119
|
+
}, inboundSpool, trackRedeliveredInbound);
|
|
55029
55120
|
if (r != null && r.redelivered > 0) {
|
|
55030
55121
|
process.stderr.write(`telegram gateway: idle-drain flushed ${r.redelivered}/${r.drained} buffered inbound for ${selfAgent}${r.rebuffered > 0 ? ` (${r.rebuffered} re-buffered)` : ""}
|
|
55031
55122
|
`);
|
|
@@ -1064,7 +1064,19 @@ try {
|
|
|
1064
1064
|
const pending = findLatestTurnIfInterrupted(turnsDb)
|
|
1065
1065
|
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
1066
1066
|
if (pending != null && selfAgent) {
|
|
1067
|
-
|
|
1067
|
+
// 3h staleness failsafe (operator spec, 2026-06-03): never AUTO-resume
|
|
1068
|
+
// interrupted work older than RESUME_MAX_AGE_MS — selectResumeBuilder
|
|
1069
|
+
// downgrades a stale 'resume' to the passive 'report' so the user is told
|
|
1070
|
+
// ("I was working on X ~Nh ago") but nothing replays unprompted. Env
|
|
1071
|
+
// override SWITCHROOM_RESUME_MAX_AGE_MS (ms); set very high to disable.
|
|
1072
|
+
const RESUME_MAX_AGE_MS = (() => {
|
|
1073
|
+
const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
|
|
1074
|
+
return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
|
|
1075
|
+
})()
|
|
1076
|
+
const kind = selectResumeBuilder(pending.ended_via, {
|
|
1077
|
+
ageMs: Math.max(0, Date.now() - pending.started_at),
|
|
1078
|
+
maxAgeMs: RESUME_MAX_AGE_MS,
|
|
1079
|
+
})
|
|
1068
1080
|
if (kind === 'resume') {
|
|
1069
1081
|
bootResumeInbound = { agent: selfAgent, msg: buildResumeInterruptedInbound({ turn: pending }) }
|
|
1070
1082
|
} else if (kind === 'report') {
|
|
@@ -1801,6 +1813,7 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
|
1801
1813
|
return d
|
|
1802
1814
|
},
|
|
1803
1815
|
inboundSpool,
|
|
1816
|
+
trackRedeliveredInbound,
|
|
1804
1817
|
)
|
|
1805
1818
|
if (fr.redelivered > 0) {
|
|
1806
1819
|
process.stderr.write(
|
|
@@ -1896,6 +1909,7 @@ function releaseTurnBufferGate(key: string): void {
|
|
|
1896
1909
|
return d
|
|
1897
1910
|
},
|
|
1898
1911
|
inboundSpool,
|
|
1912
|
+
trackRedeliveredInbound,
|
|
1899
1913
|
)
|
|
1900
1914
|
if (fr.redelivered > 0) {
|
|
1901
1915
|
process.stderr.write(
|
|
@@ -2240,6 +2254,47 @@ function resumeReactionAfterVerdict(): void {
|
|
|
2240
2254
|
?.setThinking()
|
|
2241
2255
|
}
|
|
2242
2256
|
|
|
2257
|
+
/**
|
|
2258
|
+
* The recipient set for a permission card (the initial Approve/Deny card
|
|
2259
|
+
* AND the post-verdict resume message — they MUST route identically, so
|
|
2260
|
+
* both go through this one helper).
|
|
2261
|
+
*
|
|
2262
|
+
* Turn-initiated (the normal case — a permission gate fires mid-tool-use
|
|
2263
|
+
* with an active turn): send to the ORIGINATING chat+thread. For a
|
|
2264
|
+
* supergroup-owned agent working in a forum topic that is the supergroup +
|
|
2265
|
+
* the topic, so the card lands IN the topic the operator asked from (e.g.
|
|
2266
|
+
* marko's "CRM (Brevo)" topic) — not the operator's DM. For a DM agent the
|
|
2267
|
+
* originating chat IS the operator's DM (thread-less), unchanged.
|
|
2268
|
+
*
|
|
2269
|
+
* No active turn (cron / background / a swept turn at TTL): fall back to the
|
|
2270
|
+
* configured operator DMs (`allowFrom`), thread-stripped via
|
|
2271
|
+
* `topicForRecipient` so a DM never gets a `message_thread_id` (the 400
|
|
2272
|
+
* "message thread not found" → auto-deny wedge, #2096).
|
|
2273
|
+
*
|
|
2274
|
+
* Before this helper the INITIAL card emitter iterated `allowFrom`
|
|
2275
|
+
* unconditionally, so a supergroup card could only ever reach operator DMs —
|
|
2276
|
+
* the topic chat id is never in `allowFrom`. The resume message already
|
|
2277
|
+
* routed correctly; the card now matches it (marko, 2026-06-03).
|
|
2278
|
+
*/
|
|
2279
|
+
function resolvePermissionCardTargets(): Array<{ chatId: string; threadId: number | undefined }> {
|
|
2280
|
+
const turn = currentTurn
|
|
2281
|
+
if (turn != null) {
|
|
2282
|
+
return [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }]
|
|
2283
|
+
}
|
|
2284
|
+
const sg = resolveAgentSupergroupChatId()
|
|
2285
|
+
const topic = resolveAgentOutboundTopic({
|
|
2286
|
+
kind: 'permission',
|
|
2287
|
+
turnInitiated: false,
|
|
2288
|
+
originThreadId: undefined,
|
|
2289
|
+
})
|
|
2290
|
+
// allowFrom is normally operator DMs — attach the topic only to a
|
|
2291
|
+
// recipient that owns it (the supergroup), never a DM (marko wedge).
|
|
2292
|
+
return loadAccess().allowFrom.map(chatId => ({
|
|
2293
|
+
chatId,
|
|
2294
|
+
threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg }),
|
|
2295
|
+
}))
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2243
2298
|
/**
|
|
2244
2299
|
* Post the agent-voiced "got your verdict — continuing" message the
|
|
2245
2300
|
* instant the operator answers a permission card. Travels right beside
|
|
@@ -2269,24 +2324,7 @@ function postPermissionResumeMessage(opts: {
|
|
|
2269
2324
|
action: opts.action,
|
|
2270
2325
|
timeoutMinutes: opts.timeoutMinutes,
|
|
2271
2326
|
})
|
|
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
|
-
})()
|
|
2327
|
+
const targets = resolvePermissionCardTargets()
|
|
2290
2328
|
for (const { chatId, threadId } of targets) {
|
|
2291
2329
|
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send
|
|
2292
2330
|
void swallowingApiCall(
|
|
@@ -4110,6 +4148,7 @@ silencePoke.startTimer({
|
|
|
4110
4148
|
return d
|
|
4111
4149
|
},
|
|
4112
4150
|
inboundSpool,
|
|
4151
|
+
trackRedeliveredInbound,
|
|
4113
4152
|
)
|
|
4114
4153
|
process.stderr.write(
|
|
4115
4154
|
`telegram gateway: silence-poke framework-fallback ended wedged turn ` +
|
|
@@ -4139,6 +4178,45 @@ const _deliveryMachineTick = setInterval(() => {
|
|
|
4139
4178
|
}, DELIVERY_MACHINE_TICK_MS)
|
|
4140
4179
|
_deliveryMachineTick.unref?.()
|
|
4141
4180
|
|
|
4181
|
+
// Enrol a buffer-redelivered inbound in the deliver-until-acked queue so the
|
|
4182
|
+
// existing sweep re-delivers it until claude's `enqueue` ack lands. Wired into
|
|
4183
|
+
// EVERY redelivery path (bridgeUp drain, silence-poke fallback, flap/reply-gate
|
|
4184
|
+
// flushes) — `send` returning true only means the bytes reached the bridge, NOT
|
|
4185
|
+
// that claude consumed them. Right after a restart (esp. a slow MCP boot) the
|
|
4186
|
+
// inject can hit a not-ready session and be silently dropped, and nothing
|
|
4187
|
+
// retried it: the clerk 2026-06-03 lost-message incident. Mirrors the
|
|
4188
|
+
// live-delivery tracking at the handleInbound site (chatKey + messageId), so
|
|
4189
|
+
// DMs and supergroup forum topics are handled identically. Only real user
|
|
4190
|
+
// inbounds are tracked — shouldTrackDelivery excludes steer/interrupt/
|
|
4191
|
+
// synthetic-source/empty, which never produce an `enqueue` and would otherwise
|
|
4192
|
+
// re-deliver forever.
|
|
4193
|
+
function trackRedeliveredInbound(merged: InboundMessage): void {
|
|
4194
|
+
if (!DELIVERY_CONFIRM_ENABLED) return
|
|
4195
|
+
if (
|
|
4196
|
+
!shouldTrackDelivery({
|
|
4197
|
+
isSteering: false,
|
|
4198
|
+
isInterrupt: false,
|
|
4199
|
+
// Synthetic inbounds (cron / vault / handback / resume) carry a source
|
|
4200
|
+
// and are NOT tracked here — they enqueue under their own semantics, and
|
|
4201
|
+
// (for the resume synthetics) tracking them safely first needs the
|
|
4202
|
+
// resume builder to emit meta.message_id so the deliver-until-acked ack
|
|
4203
|
+
// matches its enqueue. Tracked separately as a follow-up (see PR notes).
|
|
4204
|
+
hasSource: merged.meta?.source != null,
|
|
4205
|
+
effectiveText: merged.text,
|
|
4206
|
+
})
|
|
4207
|
+
) {
|
|
4208
|
+
return
|
|
4209
|
+
}
|
|
4210
|
+
const key = chatKey(merged.chatId, merged.threadId != null ? Number(merged.threadId) : null)
|
|
4211
|
+
trackDelivery(
|
|
4212
|
+
deliveryQueue,
|
|
4213
|
+
key,
|
|
4214
|
+
merged,
|
|
4215
|
+
Date.now(),
|
|
4216
|
+
merged.messageId != null ? String(merged.messageId) : null,
|
|
4217
|
+
)
|
|
4218
|
+
}
|
|
4219
|
+
|
|
4142
4220
|
// Re-deliver stranded inbounds until claude acks (the marko drop-wedge).
|
|
4143
4221
|
// Every few seconds, re-send any inbound that was handed to claude but never
|
|
4144
4222
|
// acked by an `enqueue` — it stranded unsubmitted in the composer. Re-clear
|
|
@@ -4376,6 +4454,11 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4376
4454
|
inboundSpool: inboundSpool ?? null,
|
|
4377
4455
|
pendingPermissionBuffer,
|
|
4378
4456
|
client,
|
|
4457
|
+
// Enrol each drained user inbound in the deliver-until-acked queue
|
|
4458
|
+
// so the 5s sweep re-delivers until claude's `enqueue` ack lands —
|
|
4459
|
+
// a socket-write into a still-booting session is NOT consumption
|
|
4460
|
+
// (clerk lost-message incident, 2026-06-03).
|
|
4461
|
+
onUserInboundDelivered: trackRedeliveredInbound,
|
|
4379
4462
|
})
|
|
4380
4463
|
} else {
|
|
4381
4464
|
// Kill-switch fallback: imperative drain (parity with pre-cutover
|
|
@@ -4386,6 +4469,10 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4386
4469
|
try {
|
|
4387
4470
|
client.send(msg)
|
|
4388
4471
|
inboundSpool?.ack(msg)
|
|
4472
|
+
// Same enrol as the cutover drain path: a socket-write success is
|
|
4473
|
+
// not proof claude consumed it — enrol so the sweep re-delivers
|
|
4474
|
+
// until `enqueue` (clerk lost-message incident, 2026-06-03).
|
|
4475
|
+
trackRedeliveredInbound(msg)
|
|
4389
4476
|
} catch (err) {
|
|
4390
4477
|
process.stderr.write(
|
|
4391
4478
|
`telegram gateway: pending-inbound drain failed agent=${client.agentName} ` +
|
|
@@ -4665,7 +4752,6 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4665
4752
|
onPermissionRequest(_client: IpcClient, msg: PermissionRequestForward) {
|
|
4666
4753
|
const { requestId, toolName, description, inputPreview } = msg
|
|
4667
4754
|
pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
|
|
4668
|
-
const access = loadAccess()
|
|
4669
4755
|
// Natural-language card body — a plain sentence ("Gymbro wants to
|
|
4670
4756
|
// edit: supplement-log.md" + a why-line), never a raw tool id.
|
|
4671
4757
|
// The operator sees what is being requested and why at a glance.
|
|
@@ -4685,42 +4771,34 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4685
4771
|
// two-button row only.
|
|
4686
4772
|
const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null
|
|
4687
4773
|
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.
|
|
4774
|
+
// Route the card to the SAME place the post-verdict resume message
|
|
4775
|
+
// lands (resolvePermissionCardTargets): the ORIGINATING chat+topic when
|
|
4776
|
+
// there's an active turn — so a supergroup agent's card appears IN the
|
|
4777
|
+
// topic the operator asked from (marko's "CRM (Brevo)"), not the
|
|
4778
|
+
// operator DM — else the configured operator DMs, thread-stripped. The
|
|
4779
|
+
// old code iterated `allowFrom` unconditionally, so a supergroup card
|
|
4780
|
+
// could only ever reach operator DMs (the topic chat id is never in
|
|
4781
|
+
// `allowFrom`) (marko, 2026-06-03).
|
|
4702
4782
|
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`)
|
|
4783
|
+
const targets = resolvePermissionCardTargets()
|
|
4784
|
+
for (const { chatId, threadId } of targets) {
|
|
4785
|
+
// parse_mode=HTML pairs with formatPermissionCardBody (#1790) so the
|
|
4786
|
+
// <b>/<i> tags render. retryWithThreadFallback: if the topic was
|
|
4787
|
+
// deleted/recreated (stale thread id → 400 "message thread not
|
|
4788
|
+
// found"), re-send thread-less into the main chat so the card still
|
|
4789
|
+
// ARRIVES rather than vanishing → 10-min TTL auto-deny → wedge.
|
|
4790
|
+
// allow-raw-bot-api: wrapped in retryWithThreadFallback (retry policy); topic-aware send
|
|
4791
|
+
void retryWithThreadFallback<{ message_id: number }>(
|
|
4792
|
+
robustApiCall,
|
|
4793
|
+
(tid) =>
|
|
4794
|
+
bot.api.sendMessage(chatId, text, {
|
|
4795
|
+
parse_mode: 'HTML',
|
|
4796
|
+
reply_markup: keyboard,
|
|
4797
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
4798
|
+
}),
|
|
4799
|
+
{ threadId, chat_id: chatId, verb: 'permission_request' },
|
|
4800
|
+
).catch(e => {
|
|
4801
|
+
process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
|
|
4724
4802
|
})
|
|
4725
4803
|
}
|
|
4726
4804
|
// Park the turn's status reaction on 🙏 (awaiting your tap) and
|
|
@@ -5303,6 +5381,7 @@ if (!STATIC) {
|
|
|
5303
5381
|
return d
|
|
5304
5382
|
},
|
|
5305
5383
|
inboundSpool,
|
|
5384
|
+
trackRedeliveredInbound,
|
|
5306
5385
|
)
|
|
5307
5386
|
if (r != null && r.redelivered > 0) {
|
|
5308
5387
|
process.stderr.write(
|
|
@@ -45,6 +45,15 @@ export interface DispatchCtx {
|
|
|
45
45
|
readonly client?: IpcClient
|
|
46
46
|
/** Optional log sink — default stderr. Test hook. */
|
|
47
47
|
readonly log?: (line: string) => void
|
|
48
|
+
/**
|
|
49
|
+
* Optional: enrol a drained+redelivered inbound in the deliver-until-acked
|
|
50
|
+
* queue. The bridgeUp drain's socket-write "success" is NOT proof claude
|
|
51
|
+
* consumed the message — right after a restart (esp. with a slow MCP boot)
|
|
52
|
+
* the inject can hit a not-ready session and be dropped. Wiring this makes
|
|
53
|
+
* the existing 5s sweep re-deliver until claude's `enqueue` ack lands.
|
|
54
|
+
* (clerk lost-message incident, 2026-06-03.)
|
|
55
|
+
*/
|
|
56
|
+
readonly onUserInboundDelivered?: (merged: InboundMessage) => void
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== '0'
|
|
@@ -103,6 +112,9 @@ function dispatchOne(effect: Effect, ctx: DispatchCtx): void {
|
|
|
103
112
|
ctx.selfAgent,
|
|
104
113
|
send,
|
|
105
114
|
ctx.inboundSpool ?? undefined,
|
|
115
|
+
ctx.onUserInboundDelivered
|
|
116
|
+
? (merged) => ctx.onUserInboundDelivered!(merged)
|
|
117
|
+
: undefined,
|
|
106
118
|
)
|
|
107
119
|
if (result.drained > 0) {
|
|
108
120
|
log(
|
|
@@ -87,6 +87,14 @@ export function redeliverBufferedInbound(
|
|
|
87
87
|
agent: string,
|
|
88
88
|
send: (msg: InboundMessage) => boolean,
|
|
89
89
|
spool?: InboundSpool,
|
|
90
|
+
// Called once per merged group on CONFIRMED delivery (after spool.ack).
|
|
91
|
+
// The caller uses it to enrol the redelivered inbound in the
|
|
92
|
+
// deliver-until-acked queue (`trackDelivery`) so it is re-sent until
|
|
93
|
+
// claude's `enqueue` ack lands — closing the restart boot-race where a
|
|
94
|
+
// socket-write "succeeds" into a not-ready session and the message is
|
|
95
|
+
// silently dropped (clerk 2026-06-03). `send` returning true only means
|
|
96
|
+
// the bytes reached the bridge, NOT that claude consumed them.
|
|
97
|
+
onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
|
|
90
98
|
): { drained: number; redelivered: number; rebuffered: number } {
|
|
91
99
|
const pending = buffer.drain(agent)
|
|
92
100
|
let redelivered = 0
|
|
@@ -110,6 +118,10 @@ export function redeliverBufferedInbound(
|
|
|
110
118
|
// originals are, so we ack by original identity.
|
|
111
119
|
for (const o of originals) spool?.ack(o)
|
|
112
120
|
redelivered += originals.length
|
|
121
|
+
// Enrol in the deliver-until-acked queue (caller's hook). A bare
|
|
122
|
+
// socket-write success is NOT proof claude consumed it; the queue's
|
|
123
|
+
// sweep re-delivers until the `enqueue` ack lands.
|
|
124
|
+
onDelivered?.(merged, originals)
|
|
113
125
|
} else {
|
|
114
126
|
// Re-buffer the originals (not the merged synthetic) so the spool
|
|
115
127
|
// identity is preserved and the next drain re-merges them losslessly.
|
|
@@ -258,11 +270,15 @@ export function idleDrainTick(
|
|
|
258
270
|
isBridgeAlive: () => boolean,
|
|
259
271
|
send: (msg: InboundMessage) => boolean,
|
|
260
272
|
spool?: InboundSpool,
|
|
273
|
+
// Forwarded to redeliverBufferedInbound so the post-flap-settle drain also
|
|
274
|
+
// enrols redelivered inbounds in the deliver-until-acked queue (parity with
|
|
275
|
+
// the bridgeUp drain — clerk lost-message incident, 2026-06-03).
|
|
276
|
+
onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
|
|
261
277
|
): { drained: number; redelivered: number; rebuffered: number } | null {
|
|
262
278
|
if (!agent) return null
|
|
263
279
|
if (buffer.depth(agent) === 0) return null
|
|
264
280
|
if (!isBridgeAlive()) return null
|
|
265
|
-
return redeliverBufferedInbound(buffer, agent, send, spool)
|
|
281
|
+
return redeliverBufferedInbound(buffer, agent, send, spool, onDelivered)
|
|
266
282
|
}
|
|
267
283
|
|
|
268
284
|
export function createPendingInboundBuffer(
|
|
@@ -172,9 +172,25 @@ export function buildResumeWatchdogReportInbound(
|
|
|
172
172
|
*/
|
|
173
173
|
export function selectResumeBuilder(
|
|
174
174
|
endedVia: TurnEndedVia | null,
|
|
175
|
+
// 3h staleness failsafe (operator spec, 2026-06-03): when the interrupted
|
|
176
|
+
// turn is older than `maxAgeMs`, an AUTO-resume is downgraded to the passive
|
|
177
|
+
// `report` — silently re-injecting hours-old work could act on long-stale
|
|
178
|
+
// context (a tax figure, a "send it" the user has moved on from). Pass both
|
|
179
|
+
// to enable; omit (default) keeps the legacy blanket-resume behaviour.
|
|
180
|
+
opts?: { ageMs?: number; maxAgeMs?: number },
|
|
175
181
|
): 'resume' | 'report' | null {
|
|
176
|
-
|
|
177
|
-
if (endedVia === '
|
|
178
|
-
if (endedVia
|
|
179
|
-
|
|
182
|
+
let kind: 'resume' | 'report' | null
|
|
183
|
+
if (endedVia === 'timeout') kind = 'report'
|
|
184
|
+
else if (endedVia === 'restart' || endedVia === 'sigterm' || endedVia === 'unknown') kind = 'resume'
|
|
185
|
+
else if (endedVia == null) kind = 'resume' // still-open at boot = killed mid-flight
|
|
186
|
+
else kind = null
|
|
187
|
+
if (
|
|
188
|
+
kind === 'resume' &&
|
|
189
|
+
opts?.ageMs != null &&
|
|
190
|
+
opts?.maxAgeMs != null &&
|
|
191
|
+
opts.ageMs > opts.maxAgeMs
|
|
192
|
+
) {
|
|
193
|
+
return 'report' // too old to safely auto-resume — passive notice only
|
|
194
|
+
}
|
|
195
|
+
return kind
|
|
180
196
|
}
|
|
@@ -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
|
|
@@ -220,6 +220,33 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
|
|
|
220
220
|
expect(calls).toBe(0)
|
|
221
221
|
})
|
|
222
222
|
|
|
223
|
+
// onDelivered: the deliver-until-acked enrol hook (clerk lost-message
|
|
224
|
+
// incident 2026-06-03). A socket-write "success" is not proof claude
|
|
225
|
+
// consumed it; the caller uses onDelivered to enrol the redelivered inbound
|
|
226
|
+
// in the deliver-until-acked queue so the sweep re-delivers until `enqueue`.
|
|
227
|
+
it('calls onDelivered for each CONFIRMED-delivered group (per merged identity)', () => {
|
|
228
|
+
const buf = createPendingInboundBuffer({ log: () => {} })
|
|
229
|
+
buf.push('klanker', inbound('user', 1))
|
|
230
|
+
buf.push('klanker', inbound('cron', 2)) // source-tagged → its own group
|
|
231
|
+
const delivered: number[] = []
|
|
232
|
+
const r = redeliverBufferedInbound(buf, 'klanker', () => true, undefined, (merged) => {
|
|
233
|
+
delivered.push(merged.messageId as number)
|
|
234
|
+
})
|
|
235
|
+
expect(r.redelivered).toBe(2)
|
|
236
|
+
expect(delivered).toEqual([1, 2]) // fired once per group, carrying the merged identity
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('does NOT call onDelivered for a group that failed to send (re-buffered, not enrolled)', () => {
|
|
240
|
+
const buf = createPendingInboundBuffer({ log: () => {} })
|
|
241
|
+
buf.push('klanker', inbound('user', 1))
|
|
242
|
+
const delivered: number[] = []
|
|
243
|
+
const r = redeliverBufferedInbound(buf, 'klanker', () => false, undefined, (m) =>
|
|
244
|
+
delivered.push(m.messageId as number),
|
|
245
|
+
)
|
|
246
|
+
expect(r.rebuffered).toBe(1)
|
|
247
|
+
expect(delivered).toEqual([]) // never enrolled — buffer/spool still own it
|
|
248
|
+
})
|
|
249
|
+
|
|
223
250
|
it('only touches the named agent', () => {
|
|
224
251
|
const buf = createPendingInboundBuffer({ log: () => {} })
|
|
225
252
|
buf.push('klanker', inbound('user', 1))
|
|
@@ -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', () => {
|
|
@@ -179,4 +179,23 @@ describe('selectResumeBuilder', () => {
|
|
|
179
179
|
expect(selectResumeBuilder(endedVia)).toBe(expected)
|
|
180
180
|
})
|
|
181
181
|
}
|
|
182
|
+
|
|
183
|
+
// 3h staleness failsafe (operator spec, 2026-06-03).
|
|
184
|
+
const MAX = 10_800_000 // 3h
|
|
185
|
+
it('downgrades a fresh resume to report when older than maxAgeMs (no auto-resume of stale work)', () => {
|
|
186
|
+
expect(selectResumeBuilder('restart', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
|
|
187
|
+
expect(selectResumeBuilder(null, { ageMs: MAX + 60_000, maxAgeMs: MAX })).toBe('report')
|
|
188
|
+
})
|
|
189
|
+
it('keeps resume when within maxAgeMs', () => {
|
|
190
|
+
expect(selectResumeBuilder('restart', { ageMs: MAX - 1, maxAgeMs: MAX })).toBe('resume')
|
|
191
|
+
expect(selectResumeBuilder('sigterm', { ageMs: 1000, maxAgeMs: MAX })).toBe('resume')
|
|
192
|
+
})
|
|
193
|
+
it('age cap never UPGRADES — report/null stay as-is regardless of age', () => {
|
|
194
|
+
expect(selectResumeBuilder('timeout', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
|
|
195
|
+
expect(selectResumeBuilder('stop', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe(null)
|
|
196
|
+
})
|
|
197
|
+
it('legacy behaviour preserved when age/maxAge omitted (blanket resume)', () => {
|
|
198
|
+
expect(selectResumeBuilder('restart')).toBe('resume')
|
|
199
|
+
expect(selectResumeBuilder('restart', { ageMs: MAX + 1 })).toBe('resume') // needs BOTH to cap
|
|
200
|
+
})
|
|
182
201
|
})
|