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
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
RequestConfigApprovalMessage,
|
|
8
8
|
RequestConfigFinalizeMessage,
|
|
9
9
|
} from "./ipc-protocol.js";
|
|
10
|
+
import { truncateRawToFit } from "./oversize-card-body.js";
|
|
10
11
|
|
|
11
12
|
/** Pending approval state — in-memory only (no SQLite per RFC §3.4). */
|
|
12
13
|
interface PendingConfigApproval {
|
|
@@ -50,6 +51,18 @@ export interface ConfigApprovalHandlerDeps {
|
|
|
50
51
|
messageId: number;
|
|
51
52
|
text: string;
|
|
52
53
|
}) => Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Send the full diff as a `.patch` document attachment when the
|
|
56
|
+
* card body exceeds Telegram's 4096-char sendMessage limit
|
|
57
|
+
* (#1762). Best-effort — failures are logged and do NOT block the
|
|
58
|
+
* approval flow (the truncated card is still actionable).
|
|
59
|
+
*/
|
|
60
|
+
postAttachment?: (args: {
|
|
61
|
+
chatId: number | string;
|
|
62
|
+
threadId?: number;
|
|
63
|
+
filename: string;
|
|
64
|
+
content: string;
|
|
65
|
+
}) => Promise<void>;
|
|
53
66
|
log?: (msg: string) => void;
|
|
54
67
|
}
|
|
55
68
|
|
|
@@ -59,19 +72,107 @@ export interface ConfigApprovalHandlerDeps {
|
|
|
59
72
|
* diffs may be truncated by the API; the validator already caps
|
|
60
73
|
* unified_diff at ~63 KiB so practical fleet edits fit comfortably.
|
|
61
74
|
*/
|
|
75
|
+
/**
|
|
76
|
+
* Truncate a unified diff for inline display in the card body when
|
|
77
|
+
* the full diff would exceed Telegram's 4096-char sendMessage limit.
|
|
78
|
+
* Caps at `maxLines` lines AND at `maxChars` *raw* characters
|
|
79
|
+
* (whichever trips first), then appends a sentinel pointing to the
|
|
80
|
+
* attached `.patch` document. (#1762)
|
|
81
|
+
*
|
|
82
|
+
* NOTE: This is a *raw-input* cap. HTML escaping happens downstream
|
|
83
|
+
* and can inflate by up to 5x per char (`&` → `&`). The
|
|
84
|
+
* load-bearing post-escape cap lives in `buildConfigApprovalCardBody`
|
|
85
|
+
* (rendered-body cap), which re-truncates the raw diff if escaping
|
|
86
|
+
* blew past Telegram's 4096 sendMessage limit. This function is the
|
|
87
|
+
* cheap fast-path for the common case.
|
|
88
|
+
*/
|
|
89
|
+
export function truncateDiffForCard(
|
|
90
|
+
unifiedDiff: string,
|
|
91
|
+
maxLines = 50,
|
|
92
|
+
maxChars = 3000,
|
|
93
|
+
): string {
|
|
94
|
+
const sentinel = "\n[… diff continues, see attached file]";
|
|
95
|
+
const lines = unifiedDiff.split("\n");
|
|
96
|
+
let out: string;
|
|
97
|
+
if (lines.length <= maxLines) {
|
|
98
|
+
out = unifiedDiff;
|
|
99
|
+
} else {
|
|
100
|
+
out = lines.slice(0, maxLines).join("\n");
|
|
101
|
+
}
|
|
102
|
+
if (out.length > maxChars) {
|
|
103
|
+
// Snap to the last complete line within the cap, falling back to
|
|
104
|
+
// a hard char cut if a single line exceeds maxChars.
|
|
105
|
+
const cap = out.slice(0, maxChars);
|
|
106
|
+
const lastNl = cap.lastIndexOf("\n");
|
|
107
|
+
out = lastNl > 0 ? cap.slice(0, lastNl) : cap;
|
|
108
|
+
}
|
|
109
|
+
return out === unifiedDiff ? out : out + sentinel;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Telegram `sendMessage` hard limit. We render with `parse_mode=HTML`,
|
|
114
|
+
* so the limit applies to the rendered (escaped) body, NOT the raw
|
|
115
|
+
* pre-escape source. Worst-case escape is `&` → `&` (5x).
|
|
116
|
+
*/
|
|
117
|
+
const TELEGRAM_SENDMESSAGE_LIMIT = 4096;
|
|
118
|
+
/** Safety margin under the hard limit for invisible framing wobble. */
|
|
119
|
+
const RENDERED_BODY_CAP = 3900;
|
|
120
|
+
/** Operator-supplied `reason` is unbounded at the wire — clip it. */
|
|
121
|
+
const REASON_MAX_CHARS = 500;
|
|
122
|
+
const REASON_ELLIPSIS = "…";
|
|
123
|
+
/**
|
|
124
|
+
* Sentinel appended to a truncated diff in the inline card body when
|
|
125
|
+
* the full diff ships separately as a `.patch` attachment. Exported
|
|
126
|
+
* so the dispatcher can key oversize detection off the
|
|
127
|
+
* `{truncated}` flag returned by `buildConfigApprovalCardBody`
|
|
128
|
+
* instead of substring-matching this string.
|
|
129
|
+
*/
|
|
130
|
+
export const DIFF_SENTINEL = "\n[… diff continues, see attached file]";
|
|
131
|
+
|
|
132
|
+
function escapeHtml(s: string): string {
|
|
133
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function clipReason(reason: string): string {
|
|
137
|
+
if (reason.length <= REASON_MAX_CHARS) return reason;
|
|
138
|
+
return reason.slice(0, REASON_MAX_CHARS - REASON_ELLIPSIS.length) +
|
|
139
|
+
REASON_ELLIPSIS;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Render the card body, guaranteeing the result fits under Telegram's
|
|
144
|
+
* 4096-char sendMessage limit even when HTML escaping inflates the
|
|
145
|
+
* raw diff up to 5x (worst case: all `&`). Strategy:
|
|
146
|
+
*
|
|
147
|
+
* 1. Clip `reason` (user-supplied, unbounded) to REASON_MAX_CHARS.
|
|
148
|
+
* 2. Render with the (already line/char-capped) diff.
|
|
149
|
+
* 3. If the rendered body still exceeds RENDERED_BODY_CAP, binary-
|
|
150
|
+
* shrink the raw diff and re-render until it fits. Truncation
|
|
151
|
+
* happens on the RAW diff (then re-escaped), so we never cut
|
|
152
|
+
* mid-entity like `&am|p;`.
|
|
153
|
+
*
|
|
154
|
+
* The full diff still ships as a `.patch` attachment — this cap only
|
|
155
|
+
* shrinks the inline preview.
|
|
156
|
+
*/
|
|
62
157
|
export function buildConfigApprovalCardBody(args: {
|
|
63
158
|
agentName: string;
|
|
64
159
|
reason: string;
|
|
65
160
|
unifiedDiff: string;
|
|
66
|
-
}): string {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
return (
|
|
161
|
+
}): { body: string; truncated: boolean } {
|
|
162
|
+
const safeReason = clipReason(args.reason);
|
|
163
|
+
const render = (diff: string): string =>
|
|
70
164
|
`🛠 <b>Config edit proposed</b>\n` +
|
|
71
|
-
`Agent: <code>${
|
|
72
|
-
`Reason: ${
|
|
73
|
-
`<pre>${
|
|
74
|
-
|
|
165
|
+
`Agent: <code>${escapeHtml(args.agentName)}</code>\n` +
|
|
166
|
+
`Reason: ${escapeHtml(safeReason)}\n\n` +
|
|
167
|
+
`<pre>${escapeHtml(diff)}</pre>`;
|
|
168
|
+
|
|
169
|
+
return truncateRawToFit({
|
|
170
|
+
raw: args.unifiedDiff,
|
|
171
|
+
render,
|
|
172
|
+
cap: RENDERED_BODY_CAP,
|
|
173
|
+
sentinel: DIFF_SENTINEL,
|
|
174
|
+
hardLimit: TELEGRAM_SENDMESSAGE_LIMIT,
|
|
175
|
+
});
|
|
75
176
|
}
|
|
76
177
|
|
|
77
178
|
/**
|
|
@@ -85,6 +186,7 @@ export async function handleRequestConfigApproval(
|
|
|
85
186
|
const reply = (
|
|
86
187
|
verdict: "approve" | "deny" | "timeout",
|
|
87
188
|
reason?: string,
|
|
189
|
+
denySource?: "operator" | "dispatch_failure",
|
|
88
190
|
) => {
|
|
89
191
|
try {
|
|
90
192
|
client.send({
|
|
@@ -92,6 +194,7 @@ export async function handleRequestConfigApproval(
|
|
|
92
194
|
requestId: msg.requestId,
|
|
93
195
|
verdict,
|
|
94
196
|
...(reason ? { reason } : {}),
|
|
197
|
+
...(denySource ? { denySource } : {}),
|
|
95
198
|
});
|
|
96
199
|
} catch (err) {
|
|
97
200
|
deps.log?.(
|
|
@@ -101,21 +204,44 @@ export async function handleRequestConfigApproval(
|
|
|
101
204
|
};
|
|
102
205
|
|
|
103
206
|
if (msg.agentName !== deps.agentName) {
|
|
104
|
-
reply(
|
|
207
|
+
reply(
|
|
208
|
+
"deny",
|
|
209
|
+
`gateway serves '${deps.agentName}', not '${msg.agentName}'`,
|
|
210
|
+
"dispatch_failure",
|
|
211
|
+
);
|
|
105
212
|
return;
|
|
106
213
|
}
|
|
107
214
|
|
|
108
215
|
const target = deps.loadTargetChat();
|
|
109
216
|
if (target === null) {
|
|
110
|
-
reply(
|
|
217
|
+
reply(
|
|
218
|
+
"deny",
|
|
219
|
+
"no target chat available — operator not paired?",
|
|
220
|
+
"dispatch_failure",
|
|
221
|
+
);
|
|
111
222
|
return;
|
|
112
223
|
}
|
|
113
224
|
|
|
114
|
-
|
|
225
|
+
// Pre-flight oversize handling (#1762). Telegram caps sendMessage
|
|
226
|
+
// at 4096 chars and we render with parse_mode=HTML, so the limit
|
|
227
|
+
// applies to the rendered (escaped) body — worst-case `&` → `&`
|
|
228
|
+
// inflates 5x. Fast-path with a cheap raw-input cap, then let
|
|
229
|
+
// buildConfigApprovalCardBody enforce the post-escape rendered cap
|
|
230
|
+
// (which re-truncates the diff if escaping blew past the limit). We
|
|
231
|
+
// ship the full diff as a `.patch` attachment whenever truncation
|
|
232
|
+
// happens in either layer.
|
|
233
|
+
const prelim = truncateDiffForCard(msg.unifiedDiff);
|
|
234
|
+
const built = buildConfigApprovalCardBody({
|
|
115
235
|
agentName: msg.agentName,
|
|
116
236
|
reason: msg.reason,
|
|
117
|
-
unifiedDiff:
|
|
237
|
+
unifiedDiff: prelim,
|
|
118
238
|
});
|
|
239
|
+
const body = built.body;
|
|
240
|
+
// Oversize iff EITHER the cheap raw fast-path trimmed lines OR the
|
|
241
|
+
// post-escape rendered cap had to re-truncate. Keyed off the
|
|
242
|
+
// builder's structured `truncated` flag instead of substring-
|
|
243
|
+
// matching the sentinel string (#1767 nit).
|
|
244
|
+
const oversize = prelim !== msg.unifiedDiff || built.truncated;
|
|
119
245
|
const replyMarkup = deps.buildKeyboard(msg.requestId);
|
|
120
246
|
|
|
121
247
|
const posted = await deps.postCard({
|
|
@@ -125,9 +251,12 @@ export async function handleRequestConfigApproval(
|
|
|
125
251
|
replyMarkup,
|
|
126
252
|
});
|
|
127
253
|
if (posted === null) {
|
|
128
|
-
reply("deny", "Telegram sendMessage failed");
|
|
254
|
+
reply("deny", "Telegram sendMessage failed", "dispatch_failure");
|
|
129
255
|
return;
|
|
130
256
|
}
|
|
257
|
+
if (oversize) {
|
|
258
|
+
await maybePostAttachment(deps, target, msg);
|
|
259
|
+
}
|
|
131
260
|
|
|
132
261
|
const entry: PendingConfigApproval = {
|
|
133
262
|
requestId: msg.requestId,
|
|
@@ -245,8 +374,34 @@ export async function handleRequestConfigFinalize(
|
|
|
245
374
|
}
|
|
246
375
|
}
|
|
247
376
|
|
|
248
|
-
|
|
249
|
-
|
|
377
|
+
/**
|
|
378
|
+
* Best-effort attachment of the full diff as a `.patch` document.
|
|
379
|
+
* Logged-and-swallowed on failure — the truncated card body remains
|
|
380
|
+
* actionable even without the attachment. (#1762)
|
|
381
|
+
*/
|
|
382
|
+
async function maybePostAttachment(
|
|
383
|
+
deps: ConfigApprovalHandlerDeps,
|
|
384
|
+
target: { chatId: number | string; threadId?: number },
|
|
385
|
+
msg: RequestConfigApprovalMessage,
|
|
386
|
+
): Promise<void> {
|
|
387
|
+
if (deps.postAttachment === undefined) {
|
|
388
|
+
deps.log?.(
|
|
389
|
+
`oversize config approval card but no postAttachment dep wired (requestId=${msg.requestId})`,
|
|
390
|
+
);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
await deps.postAttachment({
|
|
395
|
+
chatId: target.chatId,
|
|
396
|
+
...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
|
|
397
|
+
filename: `config-edit-${msg.requestId}.patch`,
|
|
398
|
+
content: msg.unifiedDiff,
|
|
399
|
+
});
|
|
400
|
+
} catch (err) {
|
|
401
|
+
deps.log?.(
|
|
402
|
+
`config approval attachment failed (requestId=${msg.requestId}): ${(err as Error).message}`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
250
405
|
}
|
|
251
406
|
|
|
252
407
|
// Test-only: clear the in-memory pending map between cases.
|
|
@@ -111,7 +111,7 @@ describe("buildDiffPreviewCard — input validation", () => {
|
|
|
111
111
|
const preview = buildDiffPreview(baseInput());
|
|
112
112
|
expect(() =>
|
|
113
113
|
buildDiffPreviewCard({ preview, suggestRequestId: "not-hex" }),
|
|
114
|
-
).toThrow(/
|
|
114
|
+
).toThrow(/32 hex chars/);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
it("throws on a malformed writeRequestId", () => {
|
|
@@ -122,7 +122,7 @@ describe("buildDiffPreviewCard — input validation", () => {
|
|
|
122
122
|
suggestRequestId: "aabbccddaabbccddaabbccddaabbccdd",
|
|
123
123
|
writeRequestId: "ABCDEF01", // wrong case
|
|
124
124
|
}),
|
|
125
|
-
).toThrow(/
|
|
125
|
+
).toThrow(/32 hex chars/);
|
|
126
126
|
});
|
|
127
127
|
});
|
|
128
128
|
|
|
@@ -73,12 +73,12 @@ export function buildDiffPreviewCard(
|
|
|
73
73
|
): BuiltDiffPreviewCard {
|
|
74
74
|
if (!REQUEST_ID_RE.test(input.suggestRequestId)) {
|
|
75
75
|
throw new Error(
|
|
76
|
-
`buildDiffPreviewCard: suggestRequestId must be
|
|
76
|
+
`buildDiffPreviewCard: suggestRequestId must be 32 hex chars (got '${input.suggestRequestId}')`,
|
|
77
77
|
);
|
|
78
78
|
}
|
|
79
79
|
if (input.writeRequestId !== undefined && !REQUEST_ID_RE.test(input.writeRequestId)) {
|
|
80
80
|
throw new Error(
|
|
81
|
-
`buildDiffPreviewCard: writeRequestId must be
|
|
81
|
+
`buildDiffPreviewCard: writeRequestId must be 32 hex chars (got '${input.writeRequestId}')`,
|
|
82
82
|
);
|
|
83
83
|
}
|
|
84
84
|
|
|
@@ -279,6 +279,76 @@ describe("handleRequestDriveApproval — TTL clamping", () => {
|
|
|
279
279
|
});
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
283
|
+
// Oversize-body fit (#1767)
|
|
284
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
describe("handleRequestDriveApproval — oversize card body fit (#1767)", () => {
|
|
287
|
+
it("truncates the rendered text under 4096 chars before posting", async () => {
|
|
288
|
+
const spy = makeSpy();
|
|
289
|
+
// buildCard returns a body well past Telegram's 4096-char limit
|
|
290
|
+
// (simulates a docTitle / anchor / summary whose HTML-escape
|
|
291
|
+
// inflated past the limit). Handler must shrink it before postCard.
|
|
292
|
+
const giantText =
|
|
293
|
+
"<b>Title</b>\n" +
|
|
294
|
+
Array.from({ length: 200 }, (_, i) => `line-${i}-${"x".repeat(40)}`).join(
|
|
295
|
+
"\n",
|
|
296
|
+
);
|
|
297
|
+
expect(giantText.length).toBeGreaterThan(4096);
|
|
298
|
+
await handleRequestDriveApproval(
|
|
299
|
+
clientFor(spy),
|
|
300
|
+
msgFor(),
|
|
301
|
+
deps({
|
|
302
|
+
spy,
|
|
303
|
+
buildCard: () => ({ text: giantText, reply_markup: { stub: true } }),
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
expect(spy.posted).toHaveLength(1);
|
|
307
|
+
expect(spy.posted[0]!.text.length).toBeLessThanOrEqual(4096);
|
|
308
|
+
expect(spy.posted[0]!.text).toContain("preview truncated");
|
|
309
|
+
// Card was successfully posted → ok:true response went back.
|
|
310
|
+
expect(spy.sent[0]?.ok).toBe(true);
|
|
311
|
+
// The log line surfaces the truncation event.
|
|
312
|
+
expect(
|
|
313
|
+
spy.logs.some((l) => l.includes("oversize-truncated")),
|
|
314
|
+
).toBe(true);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("does not touch the body when it already fits", async () => {
|
|
318
|
+
const spy = makeSpy();
|
|
319
|
+
const small = "<b>Small</b>\nline 1\nline 2";
|
|
320
|
+
await handleRequestDriveApproval(
|
|
321
|
+
clientFor(spy),
|
|
322
|
+
msgFor(),
|
|
323
|
+
deps({
|
|
324
|
+
spy,
|
|
325
|
+
buildCard: () => ({ text: small, reply_markup: { stub: true } }),
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
expect(spy.posted[0]!.text).toBe(small);
|
|
329
|
+
expect(
|
|
330
|
+
spy.logs.some((l) => l.includes("oversize-truncated")),
|
|
331
|
+
).toBe(false);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("post failure after truncation surfaces a structured reason", async () => {
|
|
335
|
+
const spy = makeSpy();
|
|
336
|
+
const giantText =
|
|
337
|
+
"<b>Title</b>\n" + "x".repeat(8000);
|
|
338
|
+
await handleRequestDriveApproval(
|
|
339
|
+
clientFor(spy),
|
|
340
|
+
msgFor(),
|
|
341
|
+
deps({
|
|
342
|
+
spy,
|
|
343
|
+
buildCard: () => ({ text: giantText, reply_markup: { stub: true } }),
|
|
344
|
+
postCard: async () => null,
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
expect(spy.sent[0]?.ok).toBe(false);
|
|
348
|
+
expect(spy.sent[0]?.reason).toMatch(/oversize-body truncation/);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
282
352
|
// ────────────────────────────────────────────────────────────────────────
|
|
283
353
|
// Always responds (no path drops the response)
|
|
284
354
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
DriveApprovalPostedEvent,
|
|
31
31
|
RequestDriveApprovalMessage,
|
|
32
32
|
} from "./ipc-protocol.js";
|
|
33
|
+
import { truncateRawToFit } from "./oversize-card-body.js";
|
|
33
34
|
|
|
34
35
|
// ────────────────────────────────────────────────────────────────────────
|
|
35
36
|
// Injected deps — caller (gateway.ts) wires these from the existing
|
|
@@ -100,6 +101,18 @@ const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
|
100
101
|
const MAX_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
101
102
|
const MIN_TTL_MS = 30 * 1000; // 30 seconds
|
|
102
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Telegram `sendMessage` hard limit. `buildDiffPreviewCard` HTML-
|
|
106
|
+
* escapes the docTitle + every body line with no upstream length
|
|
107
|
+
* cap, so an adversarial or just-unusually-long docTitle / anchor
|
|
108
|
+
* displayName / agent-supplied summary can render past 4096 once
|
|
109
|
+
* `&` / `<` / `>` inflate up to 5x. We rebuild a truncated body
|
|
110
|
+
* keyed off the wrapper-attested fields when that happens. (#1767)
|
|
111
|
+
*/
|
|
112
|
+
const TELEGRAM_SENDMESSAGE_LIMIT = 4096;
|
|
113
|
+
const RENDERED_BODY_CAP = 3900;
|
|
114
|
+
const OVERSIZE_SENTINEL = "\n[… preview truncated; open in Drive for full context]";
|
|
115
|
+
|
|
103
116
|
/**
|
|
104
117
|
* Top-level handler called by ipc-server's onRequestDriveApproval.
|
|
105
118
|
* Always sends a single `drive_approval_posted` reply (success or
|
|
@@ -204,20 +217,56 @@ export async function handleRequestDriveApproval(
|
|
|
204
217
|
});
|
|
205
218
|
return;
|
|
206
219
|
}
|
|
220
|
+
// Oversize guard (#1767). buildDiffPreviewCard renders title +
|
|
221
|
+
// every body line HTML-escaped with no length cap. Worst-case `&`
|
|
222
|
+
// / `<` / `>` inflate 5x — a long docTitle or anchor displayName
|
|
223
|
+
// pushes past Telegram's 4096-char sendMessage limit and the API
|
|
224
|
+
// returns a generic 400 that surfaces as a silent E_DENIED. Cap
|
|
225
|
+
// the rendered body BEFORE posting.
|
|
226
|
+
let cardText = card.text;
|
|
227
|
+
let truncatedForFit = false;
|
|
228
|
+
if (cardText.length > RENDERED_BODY_CAP) {
|
|
229
|
+
const fit = truncateRawToFit({
|
|
230
|
+
raw: card.text,
|
|
231
|
+
// The "raw" here is the already-escaped card text — we don't
|
|
232
|
+
// have access to pre-escape source at this layer because
|
|
233
|
+
// buildDiffPreviewCard owns escaping. Line-snap is the safe
|
|
234
|
+
// granularity: HTML entities like `&` never span `\n`, so
|
|
235
|
+
// truncating at a newline boundary can't bisect an entity.
|
|
236
|
+
// The defensive single-long-line fallback may char-cut into an
|
|
237
|
+
// entity, but the drive-preview lines are short (anchor name,
|
|
238
|
+
// metrics, capped 200-char summary) so this is unreachable
|
|
239
|
+
// unless a multi-KB docTitle slipped through Drive itself.
|
|
240
|
+
render: (slice) => slice,
|
|
241
|
+
cap: RENDERED_BODY_CAP,
|
|
242
|
+
sentinel: OVERSIZE_SENTINEL,
|
|
243
|
+
hardLimit: TELEGRAM_SENDMESSAGE_LIMIT,
|
|
244
|
+
});
|
|
245
|
+
cardText = fit.body;
|
|
246
|
+
truncatedForFit = fit.truncated;
|
|
247
|
+
}
|
|
248
|
+
|
|
207
249
|
const posted = await deps.postCard({
|
|
208
250
|
chatId: target.chatId,
|
|
209
251
|
...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
|
|
210
|
-
text:
|
|
252
|
+
text: cardText,
|
|
211
253
|
replyMarkup: card.reply_markup,
|
|
212
254
|
});
|
|
213
255
|
if (posted === null) {
|
|
214
256
|
reply({
|
|
215
257
|
correlationId: msg.correlationId,
|
|
216
258
|
ok: false,
|
|
217
|
-
reason:
|
|
259
|
+
reason: truncatedForFit
|
|
260
|
+
? "Telegram sendMessage failed even after oversize-body truncation"
|
|
261
|
+
: "Telegram sendMessage failed",
|
|
218
262
|
});
|
|
219
263
|
return;
|
|
220
264
|
}
|
|
265
|
+
if (truncatedForFit) {
|
|
266
|
+
deps.log?.(
|
|
267
|
+
`drive_approval_posted oversize-truncated correlation=${msg.correlationId} original_len=${card.text.length} rendered_len=${cardText.length}`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
221
270
|
|
|
222
271
|
deps.log?.(
|
|
223
272
|
`drive_approval_posted ok correlation=${msg.correlationId} request_id=${registered.request_id} file=${fileId}`,
|
|
@@ -3438,6 +3438,14 @@ pendingProgress.startTimer({
|
|
|
3438
3438
|
)
|
|
3439
3439
|
},
|
|
3440
3440
|
emitMetric: (event) => emitRuntimeMetric(event),
|
|
3441
|
+
// #1760 defense-in-depth: if a newer turn for this chat is active at
|
|
3442
|
+
// tick time, the prior turn's pending-progress is stale (the
|
|
3443
|
+
// canonical teardown was missed). Drop the ticker instead of editing
|
|
3444
|
+
// the old anchor — see pending-work-progress.ts's docblock.
|
|
3445
|
+
isActiveTurnNewerThan: (key, activatedAt) => {
|
|
3446
|
+
const turnStartedAt = activeTurnStartedAt.get(key)
|
|
3447
|
+
return turnStartedAt != null && turnStartedAt > activatedAt
|
|
3448
|
+
},
|
|
3441
3449
|
})
|
|
3442
3450
|
|
|
3443
3451
|
// Per-agent buffer for synthetic inbounds the gateway couldn't deliver
|
|
@@ -4113,6 +4121,24 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4113
4121
|
)
|
|
4114
4122
|
}
|
|
4115
4123
|
},
|
|
4124
|
+
// #1762: send the full diff as a `.patch` attachment when the
|
|
4125
|
+
// card body would exceed Telegram's 4096-char sendMessage limit.
|
|
4126
|
+
postAttachment: async (args) => {
|
|
4127
|
+
const input = new InputFile(Buffer.from(args.content, 'utf8'), args.filename)
|
|
4128
|
+
await robustApiCall(
|
|
4129
|
+
() =>
|
|
4130
|
+
bot.api.sendDocument(args.chatId, input, {
|
|
4131
|
+
...(args.threadId !== undefined
|
|
4132
|
+
? { message_thread_id: args.threadId }
|
|
4133
|
+
: {}),
|
|
4134
|
+
}),
|
|
4135
|
+
{
|
|
4136
|
+
chat_id: String(args.chatId),
|
|
4137
|
+
verb: 'config-approval-attachment',
|
|
4138
|
+
...(args.threadId !== undefined ? { threadId: args.threadId } : {}),
|
|
4139
|
+
},
|
|
4140
|
+
)
|
|
4141
|
+
},
|
|
4116
4142
|
log: (m) =>
|
|
4117
4143
|
process.stderr.write(`telegram gateway: config-approval — ${m}\n`),
|
|
4118
4144
|
})
|
|
@@ -4693,6 +4719,10 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4693
4719
|
// - #1664 silent-end re-prompt fires even when the
|
|
4694
4720
|
// accumulated silent content qualifies as substantive;
|
|
4695
4721
|
// - retries within the dedup window may double-send.
|
|
4722
|
+
// #1760 primary fix — clear any stale prior-turn ticker
|
|
4723
|
+
// before re-anchoring on this silent-reply edit. See the
|
|
4724
|
+
// matching comment at the executeReply finalize site below.
|
|
4725
|
+
pendingProgress.clearPending(statusKey(chat_id, threadId), 'reply_finalize')
|
|
4696
4726
|
pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
|
|
4697
4727
|
messageId: decision.messageId,
|
|
4698
4728
|
text: decision.mergedText,
|
|
@@ -4886,6 +4916,14 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4886
4916
|
if (sentIds.length === chunks.length && chunks.length > 0) {
|
|
4887
4917
|
const anchorMsgId = sentIds[chunks.length - 1]
|
|
4888
4918
|
if (typeof anchorMsgId === 'number') {
|
|
4919
|
+
// #1760 primary fix — clear any stale prior-turn ticker BEFORE
|
|
4920
|
+
// re-anchoring. The canonical teardown wires (turn_end,
|
|
4921
|
+
// subagent_handback, inbound) can be missed (e.g. SDK turn_end
|
|
4922
|
+
// event dropped, as in the #1760 live evidence). Tearing down on
|
|
4923
|
+
// every reply-finalize is idempotent and resilient: it's a no-op
|
|
4924
|
+
// when nothing is active, and drops a stale ambient before the
|
|
4925
|
+
// new turn captures its anchor.
|
|
4926
|
+
pendingProgress.clearPending(statusKey(chat_id, threadId), 'reply_finalize')
|
|
4889
4927
|
pendingProgress.noteOutbound(statusKey(chat_id, threadId), {
|
|
4890
4928
|
messageId: anchorMsgId,
|
|
4891
4929
|
text: chunks[chunks.length - 1],
|
|
@@ -5323,6 +5361,10 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
5323
5361
|
streamFormat === 'html' ? 'HTML'
|
|
5324
5362
|
: streamFormat === 'markdownv2' ? 'MarkdownV2'
|
|
5325
5363
|
: undefined
|
|
5364
|
+
// #1760 primary fix — clear any stale prior-turn ticker before
|
|
5365
|
+
// re-anchoring on stream_reply done. See the matching comment at
|
|
5366
|
+
// the executeReply finalize site.
|
|
5367
|
+
pendingProgress.clearPending(statusKey(sChatId, sThreadId), 'reply_finalize')
|
|
5326
5368
|
pendingProgress.noteOutbound(statusKey(sChatId, sThreadId), {
|
|
5327
5369
|
messageId: result.messageId,
|
|
5328
5370
|
text: args.text as string,
|
|
@@ -107,8 +107,17 @@ export interface ConfigApprovalResolvedEvent {
|
|
|
107
107
|
/** Echoes the requestId from the originating request_config_approval. */
|
|
108
108
|
requestId: string;
|
|
109
109
|
verdict: "approve" | "deny" | "timeout";
|
|
110
|
-
/** Diagnostic detail when present
|
|
110
|
+
/** Diagnostic detail when present. */
|
|
111
111
|
reason?: string;
|
|
112
|
+
/**
|
|
113
|
+
* Distinguishes an actual operator tap-deny (`"operator"`) from a
|
|
114
|
+
* gateway-side dispatch failure that auto-denied because the card
|
|
115
|
+
* never reached the operator (`"dispatch_failure"`). Only set on
|
|
116
|
+
* `verdict: "deny"` events. Caller (hostd) maps `dispatch_failure`
|
|
117
|
+
* to a distinct error code so the failure isn't misattributed to
|
|
118
|
+
* the operator. Issue #1762.
|
|
119
|
+
*/
|
|
120
|
+
denySource?: "operator" | "dispatch_failure";
|
|
112
121
|
}
|
|
113
122
|
|
|
114
123
|
export type GatewayToClient =
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared truncateRawToFit helper (#1767).
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - No-op when the rendered body already fits.
|
|
6
|
+
* - Binary-search shrinks the raw slice; result fits under `cap`.
|
|
7
|
+
* - Line-snap when newlines exist; sentinel appended.
|
|
8
|
+
* - Char-truncation fallback when a single unbroken line exceeds
|
|
9
|
+
* the budget (no `\n` to snap to).
|
|
10
|
+
* - Defensive hard-cut when even the framing-alone overflows.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from "vitest";
|
|
14
|
+
import { truncateRawToFit } from "./oversize-card-body.js";
|
|
15
|
+
|
|
16
|
+
const SENTINEL = "\n[… truncated]";
|
|
17
|
+
|
|
18
|
+
function frame(escapeMultiplier: number) {
|
|
19
|
+
// Render closure that mimics a `<pre>`-wrapped HTML-escaped body
|
|
20
|
+
// where every `&` inflates `escapeMultiplier`-fold.
|
|
21
|
+
return (raw: string) => {
|
|
22
|
+
const inflated = raw.replace(/&/g, "&".repeat(escapeMultiplier));
|
|
23
|
+
return `<b>Hdr</b>\n<pre>${inflated}</pre>`;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("truncateRawToFit", () => {
|
|
28
|
+
it("returns the full body unchanged when it fits under cap", () => {
|
|
29
|
+
const raw = "small content";
|
|
30
|
+
const { body, truncated } = truncateRawToFit({
|
|
31
|
+
raw,
|
|
32
|
+
render: (s) => `<pre>${s}</pre>`,
|
|
33
|
+
cap: 100,
|
|
34
|
+
sentinel: SENTINEL,
|
|
35
|
+
});
|
|
36
|
+
expect(truncated).toBe(false);
|
|
37
|
+
expect(body).toBe("<pre>small content</pre>");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("shrinks raw via binary-search until the rendered body fits the cap (5x escape inflation)", () => {
|
|
41
|
+
const raw = "&".repeat(2000); // 2000 chars raw, 10000 after 5x inflate
|
|
42
|
+
const { body, truncated } = truncateRawToFit({
|
|
43
|
+
raw,
|
|
44
|
+
render: frame(5),
|
|
45
|
+
cap: 1000,
|
|
46
|
+
sentinel: SENTINEL,
|
|
47
|
+
});
|
|
48
|
+
expect(truncated).toBe(true);
|
|
49
|
+
expect(body.length).toBeLessThanOrEqual(1000);
|
|
50
|
+
expect(body).toContain("truncated");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("snaps to the last newline within the chosen raw prefix (no mid-line cut)", () => {
|
|
54
|
+
const raw = ["line-a", "line-b", "line-c", "line-d", "line-e"]
|
|
55
|
+
.map((l) => l.repeat(50))
|
|
56
|
+
.join("\n");
|
|
57
|
+
const { body, truncated } = truncateRawToFit({
|
|
58
|
+
raw,
|
|
59
|
+
render: (s) => `<pre>${s}</pre>`,
|
|
60
|
+
cap: 600,
|
|
61
|
+
sentinel: SENTINEL,
|
|
62
|
+
});
|
|
63
|
+
expect(truncated).toBe(true);
|
|
64
|
+
// The portion before the sentinel must end at a complete line —
|
|
65
|
+
// no partial `line-?` suffix mid-word.
|
|
66
|
+
const beforeSentinel = body.slice(
|
|
67
|
+
0,
|
|
68
|
+
body.length - SENTINEL.length - "</pre>".length,
|
|
69
|
+
);
|
|
70
|
+
// Every line in the source repeated its label 50x — assert the
|
|
71
|
+
// tail of the kept content ends with a full repeated block, not
|
|
72
|
+
// a chopped one. Easiest check: the body must not end mid-word
|
|
73
|
+
// like `line-` (the dash followed by no letter).
|
|
74
|
+
expect(beforeSentinel).not.toMatch(/line-$/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("falls through to char-truncation when a single unbroken line exceeds cap", () => {
|
|
78
|
+
const raw = "x".repeat(5000); // no newlines at all
|
|
79
|
+
const { body, truncated } = truncateRawToFit({
|
|
80
|
+
raw,
|
|
81
|
+
render: (s) => `<pre>${s}</pre>`,
|
|
82
|
+
cap: 500,
|
|
83
|
+
sentinel: SENTINEL,
|
|
84
|
+
});
|
|
85
|
+
expect(truncated).toBe(true);
|
|
86
|
+
expect(body.length).toBeLessThanOrEqual(500);
|
|
87
|
+
// Some of the line content survives — the helper shouldn't
|
|
88
|
+
// collapse to "<pre>" + sentinel only.
|
|
89
|
+
expect(body).toMatch(/x{100,}/);
|
|
90
|
+
expect(body).toContain("truncated");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("hard-cuts at hardLimit when framing alone overflows (defensive)", () => {
|
|
94
|
+
// Framing-alone is 5000 chars (way past cap), and the renderer
|
|
95
|
+
// ignores its input — so no slice of raw can ever fit. The
|
|
96
|
+
// helper should hard-cut to hardLimit rather than loop forever.
|
|
97
|
+
const huge = "Z".repeat(5000);
|
|
98
|
+
const { body, truncated } = truncateRawToFit({
|
|
99
|
+
raw: "ignored",
|
|
100
|
+
render: () => huge,
|
|
101
|
+
cap: 1000,
|
|
102
|
+
sentinel: SENTINEL,
|
|
103
|
+
hardLimit: 1100,
|
|
104
|
+
});
|
|
105
|
+
expect(truncated).toBe(true);
|
|
106
|
+
expect(body.length).toBeLessThanOrEqual(1100);
|
|
107
|
+
});
|
|
108
|
+
});
|