switchroom 0.13.35 → 0.13.36
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 +92 -7
- package/dist/host-control/main.js +80 -32
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +158 -26
- package/telegram-plugin/gateway/config-approval-handler.test.ts +188 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +170 -15
- package/telegram-plugin/gateway/diff-preview-card.test.ts +2 -2
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -2
- package/telegram-plugin/gateway/drive-write-approval.test.ts +70 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +51 -2
- package/telegram-plugin/gateway/gateway.ts +42 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +10 -1
- package/telegram-plugin/gateway/oversize-card-body.test.ts +108 -0
- package/telegram-plugin/gateway/oversize-card-body.ts +114 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +118 -41
- package/telegram-plugin/hooks/silent-end-scan.mjs +190 -0
- package/telegram-plugin/pending-work-progress.ts +37 -1
- package/telegram-plugin/tests/pending-work-progress.test.ts +134 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +242 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +314 -0
- package/telegram-plugin/tests/silent-end.test.ts +122 -38
|
@@ -25292,6 +25292,39 @@ function parseDriveScope(scope) {
|
|
|
25292
25292
|
return { action, target: { kind: "doc", doc_id: rest } };
|
|
25293
25293
|
}
|
|
25294
25294
|
|
|
25295
|
+
// gateway/oversize-card-body.ts
|
|
25296
|
+
function truncateRawToFit(input) {
|
|
25297
|
+
const { raw, render, cap, sentinel } = input;
|
|
25298
|
+
const hardLimit = input.hardLimit ?? cap + 196;
|
|
25299
|
+
const fullBody = render(raw);
|
|
25300
|
+
if (fullBody.length <= cap) {
|
|
25301
|
+
return { body: fullBody, truncated: false };
|
|
25302
|
+
}
|
|
25303
|
+
let lo = 0;
|
|
25304
|
+
let hi = raw.length;
|
|
25305
|
+
let bestSliceLen = 0;
|
|
25306
|
+
while (lo <= hi) {
|
|
25307
|
+
const mid = lo + hi >>> 1;
|
|
25308
|
+
const candidate = raw.slice(0, mid) + sentinel;
|
|
25309
|
+
if (render(candidate).length <= cap) {
|
|
25310
|
+
bestSliceLen = mid;
|
|
25311
|
+
lo = mid + 1;
|
|
25312
|
+
} else {
|
|
25313
|
+
hi = mid - 1;
|
|
25314
|
+
}
|
|
25315
|
+
}
|
|
25316
|
+
let chosenRaw = raw.slice(0, bestSliceLen);
|
|
25317
|
+
const lastNl = chosenRaw.lastIndexOf(`
|
|
25318
|
+
`);
|
|
25319
|
+
if (lastNl > 0)
|
|
25320
|
+
chosenRaw = chosenRaw.slice(0, lastNl);
|
|
25321
|
+
let body = render(chosenRaw + sentinel);
|
|
25322
|
+
if (body.length > hardLimit) {
|
|
25323
|
+
body = body.slice(0, hardLimit - 1);
|
|
25324
|
+
}
|
|
25325
|
+
return { body, truncated: true };
|
|
25326
|
+
}
|
|
25327
|
+
|
|
25295
25328
|
// secret-detect/suppressor.ts
|
|
25296
25329
|
function isSuppressed(text, start, end) {
|
|
25297
25330
|
const left = Math.max(0, start - WINDOW);
|
|
@@ -29060,49 +29093,90 @@ var init_materialize_bot_token = __esm(() => {
|
|
|
29060
29093
|
// gateway/config-approval-handler.ts
|
|
29061
29094
|
var exports_config_approval_handler = {};
|
|
29062
29095
|
__export(exports_config_approval_handler, {
|
|
29096
|
+
truncateDiffForCard: () => truncateDiffForCard,
|
|
29063
29097
|
resolvePendingConfigApproval: () => resolvePendingConfigApproval,
|
|
29064
29098
|
parseConfigApprovalCallback: () => parseConfigApprovalCallback,
|
|
29065
29099
|
handleRequestConfigFinalize: () => handleRequestConfigFinalize,
|
|
29066
29100
|
handleRequestConfigApproval: () => handleRequestConfigApproval,
|
|
29067
29101
|
buildConfigApprovalCardBody: () => buildConfigApprovalCardBody,
|
|
29068
29102
|
_resetPendingConfigApprovalsForTest: () => _resetPendingConfigApprovalsForTest,
|
|
29069
|
-
_peekPendingConfigApprovalForTest: () => _peekPendingConfigApprovalForTest
|
|
29103
|
+
_peekPendingConfigApprovalForTest: () => _peekPendingConfigApprovalForTest,
|
|
29104
|
+
DIFF_SENTINEL: () => DIFF_SENTINEL
|
|
29070
29105
|
});
|
|
29106
|
+
function truncateDiffForCard(unifiedDiff, maxLines = 50, maxChars = 3000) {
|
|
29107
|
+
const sentinel = `
|
|
29108
|
+
[\u2026 diff continues, see attached file]`;
|
|
29109
|
+
const lines = unifiedDiff.split(`
|
|
29110
|
+
`);
|
|
29111
|
+
let out;
|
|
29112
|
+
if (lines.length <= maxLines) {
|
|
29113
|
+
out = unifiedDiff;
|
|
29114
|
+
} else {
|
|
29115
|
+
out = lines.slice(0, maxLines).join(`
|
|
29116
|
+
`);
|
|
29117
|
+
}
|
|
29118
|
+
if (out.length > maxChars) {
|
|
29119
|
+
const cap = out.slice(0, maxChars);
|
|
29120
|
+
const lastNl = cap.lastIndexOf(`
|
|
29121
|
+
`);
|
|
29122
|
+
out = lastNl > 0 ? cap.slice(0, lastNl) : cap;
|
|
29123
|
+
}
|
|
29124
|
+
return out === unifiedDiff ? out : out + sentinel;
|
|
29125
|
+
}
|
|
29126
|
+
function escapeHtml11(s) {
|
|
29127
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
29128
|
+
}
|
|
29129
|
+
function clipReason(reason) {
|
|
29130
|
+
if (reason.length <= REASON_MAX_CHARS)
|
|
29131
|
+
return reason;
|
|
29132
|
+
return reason.slice(0, REASON_MAX_CHARS - REASON_ELLIPSIS.length) + REASON_ELLIPSIS;
|
|
29133
|
+
}
|
|
29071
29134
|
function buildConfigApprovalCardBody(args) {
|
|
29072
|
-
const
|
|
29073
|
-
|
|
29074
|
-
` + `Agent: <code>${
|
|
29075
|
-
` + `Reason: ${
|
|
29076
|
-
|
|
29077
|
-
` + `<pre>${
|
|
29135
|
+
const safeReason = clipReason(args.reason);
|
|
29136
|
+
const render = (diff) => `\uD83D\uDEE0 <b>Config edit proposed</b>
|
|
29137
|
+
` + `Agent: <code>${escapeHtml11(args.agentName)}</code>
|
|
29138
|
+
` + `Reason: ${escapeHtml11(safeReason)}
|
|
29139
|
+
|
|
29140
|
+
` + `<pre>${escapeHtml11(diff)}</pre>`;
|
|
29141
|
+
return truncateRawToFit({
|
|
29142
|
+
raw: args.unifiedDiff,
|
|
29143
|
+
render,
|
|
29144
|
+
cap: RENDERED_BODY_CAP2,
|
|
29145
|
+
sentinel: DIFF_SENTINEL,
|
|
29146
|
+
hardLimit: TELEGRAM_SENDMESSAGE_LIMIT2
|
|
29147
|
+
});
|
|
29078
29148
|
}
|
|
29079
29149
|
async function handleRequestConfigApproval(client3, msg, deps) {
|
|
29080
|
-
const reply = (verdict, reason) => {
|
|
29150
|
+
const reply = (verdict, reason, denySource) => {
|
|
29081
29151
|
try {
|
|
29082
29152
|
client3.send({
|
|
29083
29153
|
type: "config_approval_resolved",
|
|
29084
29154
|
requestId: msg.requestId,
|
|
29085
29155
|
verdict,
|
|
29086
|
-
...reason ? { reason } : {}
|
|
29156
|
+
...reason ? { reason } : {},
|
|
29157
|
+
...denySource ? { denySource } : {}
|
|
29087
29158
|
});
|
|
29088
29159
|
} catch (err) {
|
|
29089
29160
|
deps.log?.(`config_approval_resolved send failed (requestId=${msg.requestId}): ${err.message}`);
|
|
29090
29161
|
}
|
|
29091
29162
|
};
|
|
29092
29163
|
if (msg.agentName !== deps.agentName) {
|
|
29093
|
-
reply("deny", `gateway serves '${deps.agentName}', not '${msg.agentName}'
|
|
29164
|
+
reply("deny", `gateway serves '${deps.agentName}', not '${msg.agentName}'`, "dispatch_failure");
|
|
29094
29165
|
return;
|
|
29095
29166
|
}
|
|
29096
29167
|
const target = deps.loadTargetChat();
|
|
29097
29168
|
if (target === null) {
|
|
29098
|
-
reply("deny", "no target chat available \u2014 operator not paired?");
|
|
29169
|
+
reply("deny", "no target chat available \u2014 operator not paired?", "dispatch_failure");
|
|
29099
29170
|
return;
|
|
29100
29171
|
}
|
|
29101
|
-
const
|
|
29172
|
+
const prelim = truncateDiffForCard(msg.unifiedDiff);
|
|
29173
|
+
const built = buildConfigApprovalCardBody({
|
|
29102
29174
|
agentName: msg.agentName,
|
|
29103
29175
|
reason: msg.reason,
|
|
29104
|
-
unifiedDiff:
|
|
29176
|
+
unifiedDiff: prelim
|
|
29105
29177
|
});
|
|
29178
|
+
const body = built.body;
|
|
29179
|
+
const oversize = prelim !== msg.unifiedDiff || built.truncated;
|
|
29106
29180
|
const replyMarkup = deps.buildKeyboard(msg.requestId);
|
|
29107
29181
|
const posted = await deps.postCard({
|
|
29108
29182
|
chatId: target.chatId,
|
|
@@ -29111,9 +29185,12 @@ async function handleRequestConfigApproval(client3, msg, deps) {
|
|
|
29111
29185
|
replyMarkup
|
|
29112
29186
|
});
|
|
29113
29187
|
if (posted === null) {
|
|
29114
|
-
reply("deny", "Telegram sendMessage failed");
|
|
29188
|
+
reply("deny", "Telegram sendMessage failed", "dispatch_failure");
|
|
29115
29189
|
return;
|
|
29116
29190
|
}
|
|
29191
|
+
if (oversize) {
|
|
29192
|
+
await maybePostAttachment(deps, target, msg);
|
|
29193
|
+
}
|
|
29117
29194
|
const entry = {
|
|
29118
29195
|
requestId: msg.requestId,
|
|
29119
29196
|
client: client3,
|
|
@@ -29179,8 +29256,21 @@ ${escapeHtml11(msg.detail)}` : ""}`;
|
|
|
29179
29256
|
deps.log?.(`config finalize card edit failed (requestId=${msg.requestId}): ${err.message}`);
|
|
29180
29257
|
}
|
|
29181
29258
|
}
|
|
29182
|
-
function
|
|
29183
|
-
|
|
29259
|
+
async function maybePostAttachment(deps, target, msg) {
|
|
29260
|
+
if (deps.postAttachment === undefined) {
|
|
29261
|
+
deps.log?.(`oversize config approval card but no postAttachment dep wired (requestId=${msg.requestId})`);
|
|
29262
|
+
return;
|
|
29263
|
+
}
|
|
29264
|
+
try {
|
|
29265
|
+
await deps.postAttachment({
|
|
29266
|
+
chatId: target.chatId,
|
|
29267
|
+
...target.threadId !== undefined ? { threadId: target.threadId } : {},
|
|
29268
|
+
filename: `config-edit-${msg.requestId}.patch`,
|
|
29269
|
+
content: msg.unifiedDiff
|
|
29270
|
+
});
|
|
29271
|
+
} catch (err) {
|
|
29272
|
+
deps.log?.(`config approval attachment failed (requestId=${msg.requestId}): ${err.message}`);
|
|
29273
|
+
}
|
|
29184
29274
|
}
|
|
29185
29275
|
function _resetPendingConfigApprovalsForTest() {
|
|
29186
29276
|
for (const entry of pending.values()) {
|
|
@@ -29207,7 +29297,8 @@ function parseConfigApprovalCallback(data) {
|
|
|
29207
29297
|
return null;
|
|
29208
29298
|
return { requestId, choice };
|
|
29209
29299
|
}
|
|
29210
|
-
var pending
|
|
29300
|
+
var pending, TELEGRAM_SENDMESSAGE_LIMIT2 = 4096, RENDERED_BODY_CAP2 = 3900, REASON_MAX_CHARS = 500, REASON_ELLIPSIS = "\u2026", DIFF_SENTINEL = `
|
|
29301
|
+
[\u2026 diff continues, see attached file]`;
|
|
29211
29302
|
var init_config_approval_handler = __esm(() => {
|
|
29212
29303
|
pending = new Map;
|
|
29213
29304
|
});
|
|
@@ -37491,6 +37582,10 @@ function tick2(now) {
|
|
|
37491
37582
|
clearPending(key, "timeout");
|
|
37492
37583
|
continue;
|
|
37493
37584
|
}
|
|
37585
|
+
if (activeDeps2.isActiveTurnNewerThan != null && activeDeps2.isActiveTurnNewerThan(key, s.activatedAt)) {
|
|
37586
|
+
clearPending(key, "stale_turn");
|
|
37587
|
+
continue;
|
|
37588
|
+
}
|
|
37494
37589
|
const sinceEdit = s.lastEditAt == null ? 0 : now - s.lastEditAt;
|
|
37495
37590
|
if (sinceEdit < EDIT_INTERVAL_MS)
|
|
37496
37591
|
continue;
|
|
@@ -44123,6 +44218,10 @@ function validateInput(input) {
|
|
|
44123
44218
|
var DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
44124
44219
|
var MAX_TTL_MS = 30 * 60 * 1000;
|
|
44125
44220
|
var MIN_TTL_MS = 30 * 1000;
|
|
44221
|
+
var TELEGRAM_SENDMESSAGE_LIMIT = 4096;
|
|
44222
|
+
var RENDERED_BODY_CAP = 3900;
|
|
44223
|
+
var OVERSIZE_SENTINEL = `
|
|
44224
|
+
[\u2026 preview truncated; open in Drive for full context]`;
|
|
44126
44225
|
async function handleRequestDriveApproval(client3, msg, deps) {
|
|
44127
44226
|
const reply = (event) => {
|
|
44128
44227
|
try {
|
|
@@ -44198,20 +44297,36 @@ async function handleRequestDriveApproval(client3, msg, deps) {
|
|
|
44198
44297
|
});
|
|
44199
44298
|
return;
|
|
44200
44299
|
}
|
|
44300
|
+
let cardText = card.text;
|
|
44301
|
+
let truncatedForFit = false;
|
|
44302
|
+
if (cardText.length > RENDERED_BODY_CAP) {
|
|
44303
|
+
const fit = truncateRawToFit({
|
|
44304
|
+
raw: card.text,
|
|
44305
|
+
render: (slice) => slice,
|
|
44306
|
+
cap: RENDERED_BODY_CAP,
|
|
44307
|
+
sentinel: OVERSIZE_SENTINEL,
|
|
44308
|
+
hardLimit: TELEGRAM_SENDMESSAGE_LIMIT
|
|
44309
|
+
});
|
|
44310
|
+
cardText = fit.body;
|
|
44311
|
+
truncatedForFit = fit.truncated;
|
|
44312
|
+
}
|
|
44201
44313
|
const posted = await deps.postCard({
|
|
44202
44314
|
chatId: target.chatId,
|
|
44203
44315
|
...target.threadId !== undefined ? { threadId: target.threadId } : {},
|
|
44204
|
-
text:
|
|
44316
|
+
text: cardText,
|
|
44205
44317
|
replyMarkup: card.reply_markup
|
|
44206
44318
|
});
|
|
44207
44319
|
if (posted === null) {
|
|
44208
44320
|
reply({
|
|
44209
44321
|
correlationId: msg.correlationId,
|
|
44210
44322
|
ok: false,
|
|
44211
|
-
reason: "Telegram sendMessage failed"
|
|
44323
|
+
reason: truncatedForFit ? "Telegram sendMessage failed even after oversize-body truncation" : "Telegram sendMessage failed"
|
|
44212
44324
|
});
|
|
44213
44325
|
return;
|
|
44214
44326
|
}
|
|
44327
|
+
if (truncatedForFit) {
|
|
44328
|
+
deps.log?.(`drive_approval_posted oversize-truncated correlation=${msg.correlationId} original_len=${card.text.length} rendered_len=${cardText.length}`);
|
|
44329
|
+
}
|
|
44215
44330
|
deps.log?.(`drive_approval_posted ok correlation=${msg.correlationId} request_id=${registered.request_id} file=${fileId}`);
|
|
44216
44331
|
reply({
|
|
44217
44332
|
correlationId: msg.correlationId,
|
|
@@ -44235,10 +44350,10 @@ var REQUEST_ID_RE = /^[0-9a-f]{32}$/;
|
|
|
44235
44350
|
var PENDING_FILE_ID_SENTINEL = "pending-create";
|
|
44236
44351
|
function buildDiffPreviewCard(input) {
|
|
44237
44352
|
if (!REQUEST_ID_RE.test(input.suggestRequestId)) {
|
|
44238
|
-
throw new Error(`buildDiffPreviewCard: suggestRequestId must be
|
|
44353
|
+
throw new Error(`buildDiffPreviewCard: suggestRequestId must be 32 hex chars (got '${input.suggestRequestId}')`);
|
|
44239
44354
|
}
|
|
44240
44355
|
if (input.writeRequestId !== undefined && !REQUEST_ID_RE.test(input.writeRequestId)) {
|
|
44241
|
-
throw new Error(`buildDiffPreviewCard: writeRequestId must be
|
|
44356
|
+
throw new Error(`buildDiffPreviewCard: writeRequestId must be 32 hex chars (got '${input.writeRequestId}')`);
|
|
44242
44357
|
}
|
|
44243
44358
|
const preview = input.preview;
|
|
44244
44359
|
const bodyLines = [];
|
|
@@ -48556,10 +48671,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
48556
48671
|
}
|
|
48557
48672
|
|
|
48558
48673
|
// ../src/build-info.ts
|
|
48559
|
-
var VERSION = "0.13.
|
|
48560
|
-
var COMMIT_SHA = "
|
|
48561
|
-
var COMMIT_DATE = "2026-05-
|
|
48562
|
-
var LATEST_PR =
|
|
48674
|
+
var VERSION = "0.13.36";
|
|
48675
|
+
var COMMIT_SHA = "73e8bb05";
|
|
48676
|
+
var COMMIT_DATE = "2026-05-25T03:53:49Z";
|
|
48677
|
+
var LATEST_PR = 1785;
|
|
48563
48678
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
48564
48679
|
|
|
48565
48680
|
// gateway/boot-version.ts
|
|
@@ -50465,7 +50580,11 @@ startTimer2({
|
|
|
50465
50580
|
...ctx.threadId != null ? { threadId: ctx.threadId } : {}
|
|
50466
50581
|
});
|
|
50467
50582
|
},
|
|
50468
|
-
emitMetric: (event) => emitRuntimeMetric(event)
|
|
50583
|
+
emitMetric: (event) => emitRuntimeMetric(event),
|
|
50584
|
+
isActiveTurnNewerThan: (key, activatedAt) => {
|
|
50585
|
+
const turnStartedAt = activeTurnStartedAt.get(key);
|
|
50586
|
+
return turnStartedAt != null && turnStartedAt > activatedAt;
|
|
50587
|
+
}
|
|
50469
50588
|
});
|
|
50470
50589
|
var inboundSpool = STATIC ? undefined : createInboundSpool({
|
|
50471
50590
|
path: join32(STATE_DIR, "inbound-spool.jsonl"),
|
|
@@ -50867,6 +50986,16 @@ ${reminder}
|
|
|
50867
50986
|
`);
|
|
50868
50987
|
}
|
|
50869
50988
|
},
|
|
50989
|
+
postAttachment: async (args) => {
|
|
50990
|
+
const input = new import_grammy9.InputFile(Buffer.from(args.content, "utf8"), args.filename);
|
|
50991
|
+
await robustApiCall(() => bot.api.sendDocument(args.chatId, input, {
|
|
50992
|
+
...args.threadId !== undefined ? { message_thread_id: args.threadId } : {}
|
|
50993
|
+
}), {
|
|
50994
|
+
chat_id: String(args.chatId),
|
|
50995
|
+
verb: "config-approval-attachment",
|
|
50996
|
+
...args.threadId !== undefined ? { threadId: args.threadId } : {}
|
|
50997
|
+
});
|
|
50998
|
+
},
|
|
50870
50999
|
log: (m) => process.stderr.write(`telegram gateway: config-approval \u2014 ${m}
|
|
50871
51000
|
`)
|
|
50872
51001
|
});
|
|
@@ -51222,6 +51351,7 @@ ${url}`;
|
|
|
51222
51351
|
logOutbound("edit", chat_id, decision.messageId, decision.mergedText.length, "silent-anchor-merge");
|
|
51223
51352
|
process.stderr.write(`telegram gateway: silent-reply auto-edit \u2014 ` + `chat=${chat_id} anchor=${decision.messageId} merged_len=${decision.mergedText.length}
|
|
51224
51353
|
`);
|
|
51354
|
+
clearPending(statusKey(chat_id, threadId), "reply_finalize");
|
|
51225
51355
|
noteOutbound3(statusKey(chat_id, threadId), {
|
|
51226
51356
|
messageId: decision.messageId,
|
|
51227
51357
|
text: decision.mergedText,
|
|
@@ -51362,6 +51492,7 @@ ${url}`;
|
|
|
51362
51492
|
if (sentIds.length === chunks.length && chunks.length > 0) {
|
|
51363
51493
|
const anchorMsgId = sentIds[chunks.length - 1];
|
|
51364
51494
|
if (typeof anchorMsgId === "number") {
|
|
51495
|
+
clearPending(statusKey(chat_id, threadId), "reply_finalize");
|
|
51365
51496
|
noteOutbound3(statusKey(chat_id, threadId), {
|
|
51366
51497
|
messageId: anchorMsgId,
|
|
51367
51498
|
text: chunks[chunks.length - 1],
|
|
@@ -51577,6 +51708,7 @@ async function executeStreamReply(args) {
|
|
|
51577
51708
|
outboundDedup.record(sChatId, sThreadId, args.text, Date.now(), currentTurn?.registryKey ?? null);
|
|
51578
51709
|
const streamFormat = args.format ?? (access.parseMode ?? "html");
|
|
51579
51710
|
const streamParseMode = streamFormat === "html" ? "HTML" : streamFormat === "markdownv2" ? "MarkdownV2" : undefined;
|
|
51711
|
+
clearPending(statusKey(sChatId, sThreadId), "reply_finalize");
|
|
51580
51712
|
noteOutbound3(statusKey(sChatId, sThreadId), {
|
|
51581
51713
|
messageId: result.messageId,
|
|
51582
51714
|
text: args.text,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
handleRequestConfigFinalize,
|
|
18
18
|
parseConfigApprovalCallback,
|
|
19
19
|
resolvePendingConfigApproval,
|
|
20
|
+
truncateDiffForCard,
|
|
20
21
|
_resetPendingConfigApprovalsForTest,
|
|
21
22
|
_peekPendingConfigApprovalForTest,
|
|
22
23
|
} from "./config-approval-handler.js";
|
|
@@ -66,7 +67,7 @@ afterEach(() => {
|
|
|
66
67
|
|
|
67
68
|
describe("buildConfigApprovalCardBody", () => {
|
|
68
69
|
it("HTML-escapes the diff body so `<` / `&` can't break out of the <pre> block", () => {
|
|
69
|
-
const body = buildConfigApprovalCardBody({
|
|
70
|
+
const { body } = buildConfigApprovalCardBody({
|
|
70
71
|
agentName: "klanker",
|
|
71
72
|
reason: "<script>",
|
|
72
73
|
unifiedDiff: "a & b <c>",
|
|
@@ -74,6 +75,97 @@ describe("buildConfigApprovalCardBody", () => {
|
|
|
74
75
|
expect(body).toContain("<script>");
|
|
75
76
|
expect(body).toContain("a & b <c>");
|
|
76
77
|
});
|
|
78
|
+
|
|
79
|
+
it("rendered body stays under Telegram's 4096-char limit when raw diff is all `&` (worst-case 5x escape inflation)", () => {
|
|
80
|
+
// 3000 `&` chars escape to 15000 `&` chars — far past 4096.
|
|
81
|
+
// The post-escape cap MUST kick in and truncate the rendered body.
|
|
82
|
+
const evilDiff = "&".repeat(3000);
|
|
83
|
+
const { body } = buildConfigApprovalCardBody({
|
|
84
|
+
agentName: "klanker",
|
|
85
|
+
reason: "test",
|
|
86
|
+
unifiedDiff: evilDiff,
|
|
87
|
+
});
|
|
88
|
+
expect(body.length).toBeLessThanOrEqual(4096);
|
|
89
|
+
expect(body).toContain("diff continues, see attached file");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("rendered body stays under 4096 when raw diff is all `<` (5x escape)", () => {
|
|
93
|
+
const evilDiff = "<".repeat(3000);
|
|
94
|
+
const { body } = buildConfigApprovalCardBody({
|
|
95
|
+
agentName: "klanker",
|
|
96
|
+
reason: "test",
|
|
97
|
+
unifiedDiff: evilDiff,
|
|
98
|
+
});
|
|
99
|
+
expect(body.length).toBeLessThanOrEqual(4096);
|
|
100
|
+
expect(body).toContain("<");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("clips an unbounded operator-supplied `reason` to ~500 chars with ellipsis", () => {
|
|
104
|
+
const longReason = "x".repeat(2000);
|
|
105
|
+
const { body } = buildConfigApprovalCardBody({
|
|
106
|
+
agentName: "klanker",
|
|
107
|
+
reason: longReason,
|
|
108
|
+
unifiedDiff: "small",
|
|
109
|
+
});
|
|
110
|
+
// The escaped reason should appear, but capped.
|
|
111
|
+
const reasonLine = body
|
|
112
|
+
.split("\n")
|
|
113
|
+
.find((l) => l.startsWith("Reason: "))!;
|
|
114
|
+
// "Reason: " prefix (8) + clipped reason.
|
|
115
|
+
expect(reasonLine.length).toBeLessThanOrEqual(8 + 500);
|
|
116
|
+
expect(reasonLine.endsWith("…")).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns truncated:false when the rendered body fits without trimming", () => {
|
|
120
|
+
const { body, truncated } = buildConfigApprovalCardBody({
|
|
121
|
+
agentName: "klanker",
|
|
122
|
+
reason: "small",
|
|
123
|
+
unifiedDiff: "-a\n+b\n",
|
|
124
|
+
});
|
|
125
|
+
expect(truncated).toBe(false);
|
|
126
|
+
expect(body).toContain("<pre>-a\n+b\n</pre>");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns truncated:true and appends the sentinel when the body has to shrink", () => {
|
|
130
|
+
const { body, truncated } = buildConfigApprovalCardBody({
|
|
131
|
+
agentName: "klanker",
|
|
132
|
+
reason: "test",
|
|
133
|
+
unifiedDiff: "&".repeat(3000),
|
|
134
|
+
});
|
|
135
|
+
expect(truncated).toBe(true);
|
|
136
|
+
expect(body).toContain("diff continues, see attached file");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("handles a single unbroken line (no `\\n` to snap to) by char-truncation fallback", () => {
|
|
140
|
+
// 8000 `x` chars on a single line. After HTML escape (no inflation
|
|
141
|
+
// for `x`) the diff body alone is 8000 chars + framing — way past
|
|
142
|
+
// the cap. There's no newline to snap to, so the helper must fall
|
|
143
|
+
// through to char-truncation rather than returning empty.
|
|
144
|
+
const oneLongLine = "x".repeat(8000);
|
|
145
|
+
const { body, truncated } = buildConfigApprovalCardBody({
|
|
146
|
+
agentName: "klanker",
|
|
147
|
+
reason: "test",
|
|
148
|
+
unifiedDiff: oneLongLine,
|
|
149
|
+
});
|
|
150
|
+
expect(truncated).toBe(true);
|
|
151
|
+
expect(body.length).toBeLessThanOrEqual(4096);
|
|
152
|
+
// Should still contain SOME of the line content — the helper
|
|
153
|
+
// shouldn't degenerate to "framing + sentinel only" when char-
|
|
154
|
+
// truncation is available.
|
|
155
|
+
expect(body).toMatch(/x{100,}/);
|
|
156
|
+
expect(body).toContain("diff continues, see attached file");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("rendered body stays under 4096 even when reason is also adversarial", () => {
|
|
160
|
+
const evilDiff = "&".repeat(3000);
|
|
161
|
+
const evilReason = "&".repeat(2000);
|
|
162
|
+
const { body } = buildConfigApprovalCardBody({
|
|
163
|
+
agentName: "klanker",
|
|
164
|
+
reason: evilReason,
|
|
165
|
+
unifiedDiff: evilDiff,
|
|
166
|
+
});
|
|
167
|
+
expect(body.length).toBeLessThanOrEqual(4096);
|
|
168
|
+
});
|
|
77
169
|
});
|
|
78
170
|
|
|
79
171
|
describe("handleRequestConfigApproval", () => {
|
|
@@ -100,6 +192,7 @@ describe("handleRequestConfigApproval", () => {
|
|
|
100
192
|
requestId: "req-1",
|
|
101
193
|
verdict: "deny",
|
|
102
194
|
reason: expect.stringContaining("gateway serves 'klanker'"),
|
|
195
|
+
denySource: "dispatch_failure",
|
|
103
196
|
},
|
|
104
197
|
]);
|
|
105
198
|
});
|
|
@@ -225,6 +318,100 @@ describe("handleRequestConfigFinalize", () => {
|
|
|
225
318
|
});
|
|
226
319
|
});
|
|
227
320
|
|
|
321
|
+
describe("oversize diff → attachment fallback (#1762)", () => {
|
|
322
|
+
function bigDiff(lines: number): string {
|
|
323
|
+
// Each line ~80 chars → 200 lines ≈ 16 KB, comfortably > 4096.
|
|
324
|
+
const row =
|
|
325
|
+
"-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
326
|
+
return Array.from({ length: lines }, (_, i) => `${row}${i}`).join("\n");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
it("truncateDiffForCard caps the diff and appends a sentinel", () => {
|
|
330
|
+
const truncated = truncateDiffForCard(bigDiff(200), 50, 3000);
|
|
331
|
+
expect(truncated.length).toBeLessThanOrEqual(3050);
|
|
332
|
+
expect(truncated.endsWith("[… diff continues, see attached file]")).toBe(
|
|
333
|
+
true,
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("returns the original diff unchanged when below the line cap", () => {
|
|
338
|
+
const small = "--- a\n+++ b\n@@\n-x\n+y\n";
|
|
339
|
+
expect(truncateDiffForCard(small, 50)).toBe(small);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("oversize body still posts a card with buttons AND fires postAttachment", async () => {
|
|
343
|
+
const huge = bigDiff(200);
|
|
344
|
+
const attachmentCalls: Array<{
|
|
345
|
+
chatId: number | string;
|
|
346
|
+
filename: string;
|
|
347
|
+
content: string;
|
|
348
|
+
}> = [];
|
|
349
|
+
const { client, sent, deps } = fakeDeps({
|
|
350
|
+
postAttachment: async (a: {
|
|
351
|
+
chatId: number | string;
|
|
352
|
+
filename: string;
|
|
353
|
+
content: string;
|
|
354
|
+
}) => {
|
|
355
|
+
attachmentCalls.push({
|
|
356
|
+
chatId: a.chatId,
|
|
357
|
+
filename: a.filename,
|
|
358
|
+
content: a.content,
|
|
359
|
+
});
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
await handleRequestConfigApproval(
|
|
363
|
+
client,
|
|
364
|
+
{ ...baseMsg, unifiedDiff: huge },
|
|
365
|
+
deps,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
// Card was posted exactly once, with buttons, and within Telegram's limit.
|
|
369
|
+
expect(deps.postCard).toHaveBeenCalledTimes(1);
|
|
370
|
+
const postArgs = (deps.postCard as ReturnType<typeof vi.fn>).mock
|
|
371
|
+
.calls[0]![0] as { text: string; replyMarkup: unknown };
|
|
372
|
+
expect(postArgs.text.length).toBeLessThanOrEqual(4096);
|
|
373
|
+
expect(postArgs.text).toMatch(/diff continues, see attached file/);
|
|
374
|
+
expect(postArgs.replyMarkup).toBeDefined();
|
|
375
|
+
|
|
376
|
+
// Attachment carries the FULL diff, named .patch, keyed by requestId.
|
|
377
|
+
expect(attachmentCalls.length).toBe(1);
|
|
378
|
+
expect(attachmentCalls[0]!.filename).toBe("config-edit-req-1.patch");
|
|
379
|
+
expect(attachmentCalls[0]!.content).toBe(huge);
|
|
380
|
+
|
|
381
|
+
// The pending entry is registered — handler hasn't auto-denied.
|
|
382
|
+
expect(_peekPendingConfigApprovalForTest("req-1")).toBeDefined();
|
|
383
|
+
// No verdict has crossed the wire yet (still pending operator tap).
|
|
384
|
+
expect(sent.filter((s) => s.type === "config_approval_resolved")).toEqual(
|
|
385
|
+
[],
|
|
386
|
+
);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("oversize but no postAttachment dep → card still posts, missing-attachment is logged", async () => {
|
|
390
|
+
const huge = bigDiff(200);
|
|
391
|
+
const logs: string[] = [];
|
|
392
|
+
const { client, deps } = fakeDeps({ log: (m: string) => logs.push(m) });
|
|
393
|
+
await handleRequestConfigApproval(
|
|
394
|
+
client,
|
|
395
|
+
{ ...baseMsg, unifiedDiff: huge },
|
|
396
|
+
deps,
|
|
397
|
+
);
|
|
398
|
+
expect(deps.postCard).toHaveBeenCalledTimes(1);
|
|
399
|
+
expect(
|
|
400
|
+
logs.some((l) => l.includes("no postAttachment dep wired")),
|
|
401
|
+
).toBe(true);
|
|
402
|
+
expect(_peekPendingConfigApprovalForTest("req-1")).toBeDefined();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("postCard failure → deny carries denySource='dispatch_failure'", async () => {
|
|
406
|
+
const { client, sent, deps } = fakeDeps({
|
|
407
|
+
postCard: vi.fn(async () => null),
|
|
408
|
+
});
|
|
409
|
+
await handleRequestConfigApproval(client, baseMsg, deps);
|
|
410
|
+
expect(sent[0]!.verdict).toBe("deny");
|
|
411
|
+
expect(sent[0]!.denySource).toBe("dispatch_failure");
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
228
415
|
describe("parseConfigApprovalCallback", () => {
|
|
229
416
|
it("parses well-formed callbacks", () => {
|
|
230
417
|
expect(parseConfigApprovalCallback("cfg:abc:approve")).toEqual({
|