switchroom 0.13.33 → 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.
Files changed (34) hide show
  1. package/bin/timezone-hook.sh +1 -1
  2. package/dist/agent-scheduler/index.js +8 -1
  3. package/dist/auth-broker/index.js +8 -1
  4. package/dist/cli/switchroom.js +176 -26
  5. package/dist/host-control/main.js +5222 -203
  6. package/dist/vault/approvals/kernel-server.js +9 -2
  7. package/dist/vault/broker/server.js +9 -2
  8. package/package.json +1 -1
  9. package/profiles/default/CLAUDE.md.hbs +1 -1
  10. package/telegram-plugin/dist/gateway/gateway.js +234 -31
  11. package/telegram-plugin/docs/waiting-ux-spec.md +40 -0
  12. package/telegram-plugin/gateway/config-approval-handler.test.ts +188 -1
  13. package/telegram-plugin/gateway/config-approval-handler.ts +170 -15
  14. package/telegram-plugin/gateway/diff-preview-card.test.ts +2 -2
  15. package/telegram-plugin/gateway/diff-preview-card.ts +2 -2
  16. package/telegram-plugin/gateway/drive-write-approval.test.ts +70 -0
  17. package/telegram-plugin/gateway/drive-write-approval.ts +51 -2
  18. package/telegram-plugin/gateway/error-envelope-card.ts +64 -0
  19. package/telegram-plugin/gateway/gateway.ts +112 -15
  20. package/telegram-plugin/gateway/ipc-protocol.ts +10 -1
  21. package/telegram-plugin/gateway/oversize-card-body.test.ts +108 -0
  22. package/telegram-plugin/gateway/oversize-card-body.ts +114 -0
  23. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +46 -1
  24. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +118 -41
  25. package/telegram-plugin/hooks/silent-end-scan.mjs +190 -0
  26. package/telegram-plugin/pending-work-progress.ts +37 -1
  27. package/telegram-plugin/tests/boot-clears-clean-shutdown-marker.test.ts +75 -0
  28. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +79 -0
  29. package/telegram-plugin/tests/pending-work-progress.test.ts +134 -0
  30. package/telegram-plugin/tests/silent-end-integration.test.ts +268 -0
  31. package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +242 -0
  32. package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +314 -0
  33. package/telegram-plugin/tests/silent-end.test.ts +227 -38
  34. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +51 -6
@@ -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("&lt;script&gt;");
75
76
  expect(body).toContain("a &amp; b &lt;c&gt;");
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 `&amp;` 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("&lt;");
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({
@@ -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 (`&` → `&amp;`). 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 `&` → `&amp;` (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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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 esc = (s: string) =>
68
- s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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>${esc(args.agentName)}</code>\n` +
72
- `Reason: ${esc(args.reason)}\n\n` +
73
- `<pre>${esc(args.unifiedDiff)}</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("deny", `gateway serves '${deps.agentName}', not '${msg.agentName}'`);
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("deny", "no target chat available — operator not paired?");
217
+ reply(
218
+ "deny",
219
+ "no target chat available — operator not paired?",
220
+ "dispatch_failure",
221
+ );
111
222
  return;
112
223
  }
113
224
 
114
- const body = buildConfigApprovalCardBody({
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 `&` → `&amp;`
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: msg.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
- function escapeHtml(s: string): string {
249
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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(/8 hex chars/);
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(/8 hex chars/);
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 8 hex chars (got '${input.suggestRequestId}')`,
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 8 hex chars (got '${input.writeRequestId}')`,
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 `&amp;` 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: card.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: "Telegram sendMessage failed",
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}`,