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.
- package/bin/timezone-hook.sh +1 -1
- package/dist/agent-scheduler/index.js +8 -1
- package/dist/auth-broker/index.js +8 -1
- package/dist/cli/switchroom.js +176 -26
- package/dist/host-control/main.js +5222 -203
- package/dist/vault/approvals/kernel-server.js +9 -2
- package/dist/vault/broker/server.js +9 -2
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +234 -31
- package/telegram-plugin/docs/waiting-ux-spec.md +40 -0
- 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/error-envelope-card.ts +64 -0
- package/telegram-plugin/gateway/gateway.ts +112 -15
- 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/gateway/unhandled-rejection-policy.ts +46 -1
- 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/boot-clears-clean-shutdown-marker.test.ts +75 -0
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +79 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +134 -0
- package/telegram-plugin/tests/silent-end-integration.test.ts +268 -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 +227 -38
- 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("<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({
|
|
@@ -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}`,
|