switchroom 0.10.0 → 0.11.0
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/README.md +5 -4
- package/dist/cli/drive-write-pretool.mjs +5418 -0
- package/dist/cli/switchroom.js +201 -24
- package/package.json +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
- package/telegram-plugin/admin-commands/index.ts +2 -0
- package/telegram-plugin/auth-snapshot-format.ts +612 -0
- package/telegram-plugin/auto-fallback-fleet.ts +215 -0
- package/telegram-plugin/auto-fallback.ts +28 -301
- package/telegram-plugin/dist/gateway/gateway.js +4407 -2252
- package/telegram-plugin/fleet-fallback-gate.ts +105 -0
- package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
- package/telegram-plugin/gateway/approval-callback.ts +31 -3
- package/telegram-plugin/gateway/auth-command.ts +121 -10
- package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
- package/telegram-plugin/gateway/boot-card.ts +1 -1
- package/telegram-plugin/gateway/boot-probes.ts +6 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
- package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
- package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
- package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
- package/telegram-plugin/gateway/gateway.ts +876 -173
- package/telegram-plugin/gateway/hostd-dispatch.ts +127 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
- package/telegram-plugin/gateway/ipc-server.ts +69 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
- package/telegram-plugin/model-unavailable.ts +28 -12
- package/telegram-plugin/silence-poke.ts +153 -1
- package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
- package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
- package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
- package/telegram-plugin/tests/boot-probes.test.ts +16 -18
- package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
- package/telegram-plugin/tests/silence-poke.test.ts +237 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
- package/telegram-plugin/turn-flush-safety.ts +55 -1
- package/telegram-plugin/uat/SETUP.md +16 -12
- package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
- package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
- package/telegram-plugin/tests/hostd-dispatch.test.ts +0 -129
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the Telegram diff-preview card renderer — RFC E §4.2.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { InlineKeyboard } from "grammy";
|
|
7
|
+
|
|
8
|
+
import { buildDiffPreview } from "../../src/drive/diff-preview.js";
|
|
9
|
+
import type { DiffPreviewInput } from "../../src/drive/diff-preview.js";
|
|
10
|
+
import { buildDiffPreviewCard } from "./diff-preview-card.js";
|
|
11
|
+
|
|
12
|
+
/** Pull row-major button shape out of grammy's InlineKeyboard. */
|
|
13
|
+
function rows(kb: InlineKeyboard): Array<Array<{ text: string; callback_data?: string; url?: string }>> {
|
|
14
|
+
return kb.inline_keyboard.map((row) =>
|
|
15
|
+
row.map((b) => ({
|
|
16
|
+
text: b.text,
|
|
17
|
+
...("callback_data" in b ? { callback_data: b.callback_data } : {}),
|
|
18
|
+
...("url" in b ? { url: b.url } : {}),
|
|
19
|
+
})),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function baseInput(overrides: Partial<DiffPreviewInput> = {}): DiffPreviewInput {
|
|
24
|
+
return {
|
|
25
|
+
agentName: "klanker",
|
|
26
|
+
docTitle: "Q3 Strategy Notes",
|
|
27
|
+
fileId: "DOC1",
|
|
28
|
+
mimeType: "application/vnd.google-apps.document",
|
|
29
|
+
resolvedAnchor: {
|
|
30
|
+
op: { kind: "insert_after", paragraphIndex: 4 },
|
|
31
|
+
displayName: "after heading 'Goals' (level 2)",
|
|
32
|
+
},
|
|
33
|
+
metrics: { linesAdded: 47, linesRemoved: 0 },
|
|
34
|
+
mode: "suggest",
|
|
35
|
+
...overrides,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("buildDiffPreviewCard — suggest mode (default)", () => {
|
|
40
|
+
it("emits the wrapper-attested body + all four buttons in the RFC layout", () => {
|
|
41
|
+
const preview = buildDiffPreview(baseInput({ agentSummary: "Added Hiring section" }));
|
|
42
|
+
const card = buildDiffPreviewCard({
|
|
43
|
+
preview,
|
|
44
|
+
suggestRequestId: "aabbccdd",
|
|
45
|
+
writeRequestId: "11223344",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Body: title bold + all preview lines.
|
|
49
|
+
expect(card.text).toContain("<b>");
|
|
50
|
+
expect(card.text).toContain("klanker");
|
|
51
|
+
expect(card.text).toContain("Q3 Strategy Notes");
|
|
52
|
+
expect(card.text).toContain("📍 after heading 'Goals' (level 2)");
|
|
53
|
+
expect(card.text).toContain("+47");
|
|
54
|
+
expect(card.text).toContain("💬");
|
|
55
|
+
expect(card.text).toContain("Added Hiring section");
|
|
56
|
+
|
|
57
|
+
const r = rows(card.reply_markup);
|
|
58
|
+
// Row 1: [Open in Drive] [Apply as suggestion]
|
|
59
|
+
expect(r[0]?.[0]?.text).toBe("📖 Open in Drive");
|
|
60
|
+
expect(r[0]?.[0]?.url).toBe("https://docs.google.com/document/d/DOC1/edit");
|
|
61
|
+
expect(r[0]?.[1]?.text).toBe("✅ Apply as suggestion");
|
|
62
|
+
expect(r[0]?.[1]?.callback_data).toBe("apv:aabbccdd:once");
|
|
63
|
+
// Row 2: [Apply directly] [Cancel]
|
|
64
|
+
expect(r[1]?.[0]?.text).toBe("⚠ Apply directly");
|
|
65
|
+
expect(r[1]?.[0]?.callback_data).toBe("apv:11223344:once");
|
|
66
|
+
expect(r[1]?.[1]?.text).toBe("🚫 Cancel");
|
|
67
|
+
expect(r[1]?.[1]?.callback_data).toBe("apv:aabbccdd:deny");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("hides 'Apply directly' when writeRequestId is undefined", () => {
|
|
71
|
+
const preview = buildDiffPreview(baseInput());
|
|
72
|
+
const card = buildDiffPreviewCard({
|
|
73
|
+
preview,
|
|
74
|
+
suggestRequestId: "aabbccdd",
|
|
75
|
+
});
|
|
76
|
+
const flat = rows(card.reply_markup).flat();
|
|
77
|
+
expect(flat.find((b) => b.text === "⚠ Apply directly")).toBeUndefined();
|
|
78
|
+
// The other three buttons still present.
|
|
79
|
+
expect(flat.find((b) => b.text === "📖 Open in Drive")).toBeDefined();
|
|
80
|
+
expect(flat.find((b) => b.text === "✅ Apply as suggestion")).toBeDefined();
|
|
81
|
+
expect(flat.find((b) => b.text === "🚫 Cancel")).toBeDefined();
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("buildDiffPreviewCard — write mode (opt-in via expand)", () => {
|
|
86
|
+
it("only emits Apply-directly + Open-in-Drive + Cancel (no suggest button)", () => {
|
|
87
|
+
const preview = buildDiffPreview(baseInput({ mode: "write" }));
|
|
88
|
+
const card = buildDiffPreviewCard({
|
|
89
|
+
preview,
|
|
90
|
+
// In write-mode the suggest id is still needed for the Cancel
|
|
91
|
+
// callback's deny channel — semantically Cancel is "don't grant
|
|
92
|
+
// either scope" but reusing the suggest id keeps the existing
|
|
93
|
+
// approval-callback handler stateless.
|
|
94
|
+
suggestRequestId: "aabbccdd",
|
|
95
|
+
writeRequestId: "11223344",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const r = rows(card.reply_markup);
|
|
99
|
+
const flat = r.flat();
|
|
100
|
+
expect(flat.find((b) => b.text === "✅ Apply as suggestion")).toBeUndefined();
|
|
101
|
+
const directly = flat.find((b) => b.text === "⚠ Apply directly");
|
|
102
|
+
expect(directly).toBeDefined();
|
|
103
|
+
expect(directly?.callback_data).toBe("apv:11223344:once");
|
|
104
|
+
// Title icon swaps to ⚠.
|
|
105
|
+
expect(card.text).toContain("⚠");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("buildDiffPreviewCard — input validation", () => {
|
|
110
|
+
it("throws on a malformed suggestRequestId", () => {
|
|
111
|
+
const preview = buildDiffPreview(baseInput());
|
|
112
|
+
expect(() =>
|
|
113
|
+
buildDiffPreviewCard({ preview, suggestRequestId: "not-hex" }),
|
|
114
|
+
).toThrow(/8 hex chars/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("throws on a malformed writeRequestId", () => {
|
|
118
|
+
const preview = buildDiffPreview(baseInput());
|
|
119
|
+
expect(() =>
|
|
120
|
+
buildDiffPreviewCard({
|
|
121
|
+
preview,
|
|
122
|
+
suggestRequestId: "aabbccdd",
|
|
123
|
+
writeRequestId: "ABCDEF01", // wrong case
|
|
124
|
+
}),
|
|
125
|
+
).toThrow(/8 hex chars/);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("buildDiffPreviewCard — fragility guards", () => {
|
|
130
|
+
it("drops the Open-in-Drive button when fileId is the 'pending-create' sentinel", () => {
|
|
131
|
+
// create_doc prep emits "pending-create" as a placeholder fileId
|
|
132
|
+
// (the doc doesn't exist yet). The renderer must NOT emit a Drive
|
|
133
|
+
// URL pointing at a nonexistent doc.
|
|
134
|
+
const preview = buildDiffPreview(
|
|
135
|
+
baseInput({ fileId: "pending-create" }),
|
|
136
|
+
);
|
|
137
|
+
const card = buildDiffPreviewCard({
|
|
138
|
+
preview,
|
|
139
|
+
suggestRequestId: "aabbccdd",
|
|
140
|
+
});
|
|
141
|
+
const flat = rows(card.reply_markup).flat();
|
|
142
|
+
expect(flat.find((b) => b.text === "📖 Open in Drive")).toBeUndefined();
|
|
143
|
+
// Apply buttons still present — the doc creation flow is still actionable.
|
|
144
|
+
expect(flat.find((b) => b.text === "✅ Apply as suggestion")).toBeDefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("HTML-escapes title + lines (no markup injection from doc names)", () => {
|
|
148
|
+
const preview = buildDiffPreview(
|
|
149
|
+
baseInput({ docTitle: "<script>alert(1)</script>" }),
|
|
150
|
+
);
|
|
151
|
+
const card = buildDiffPreviewCard({
|
|
152
|
+
preview,
|
|
153
|
+
suggestRequestId: "aabbccdd",
|
|
154
|
+
});
|
|
155
|
+
expect(card.text).not.toContain("<script>");
|
|
156
|
+
expect(card.text).toContain("<script>");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("HTML-escapes the agent-supplied summary", () => {
|
|
160
|
+
const preview = buildDiffPreview(
|
|
161
|
+
baseInput({ agentSummary: "Hi <b>bold</b> & <i>tags</i>" }),
|
|
162
|
+
);
|
|
163
|
+
const card = buildDiffPreviewCard({
|
|
164
|
+
preview,
|
|
165
|
+
suggestRequestId: "aabbccdd",
|
|
166
|
+
});
|
|
167
|
+
expect(card.text).not.toMatch(/💬.*<b>/);
|
|
168
|
+
expect(card.text).toContain("<b>");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("buildDiffPreviewCard — audit fidelity", () => {
|
|
173
|
+
it("preview audit row matches what the user sees on the card", () => {
|
|
174
|
+
const input = baseInput({ agentSummary: "Added the Hiring section" });
|
|
175
|
+
const preview = buildDiffPreview(input);
|
|
176
|
+
const card = buildDiffPreviewCard({
|
|
177
|
+
preview,
|
|
178
|
+
suggestRequestId: "aabbccdd",
|
|
179
|
+
writeRequestId: "11223344",
|
|
180
|
+
});
|
|
181
|
+
// The audit row captures both wrapper truth + agent framing,
|
|
182
|
+
// exactly as surfaced on the card.
|
|
183
|
+
expect(preview.audit.wrapperAttested.anchorDisplayName).toBe(
|
|
184
|
+
"after heading 'Goals' (level 2)",
|
|
185
|
+
);
|
|
186
|
+
expect(preview.audit.wrapperAttested.linesAdded).toBe(47);
|
|
187
|
+
expect(preview.audit.agentSupplied.summary).toBe("Added the Hiring section");
|
|
188
|
+
// Card body contains both.
|
|
189
|
+
expect(card.text).toContain("after heading 'Goals' (level 2)");
|
|
190
|
+
expect(card.text).toContain("Added the Hiring section");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram renderer for the diff-preview approval card — RFC E §4.2.
|
|
3
|
+
*
|
|
4
|
+
* Takes a `DiffPreview` (output of `src/drive/diff-preview.ts`) plus the
|
|
5
|
+
* two pre-registered approval-kernel request ids — one for the suggest
|
|
6
|
+
* scope, one for the write scope — and emits a `BuiltApprovalCard`
|
|
7
|
+
* (HTML body + grammy InlineKeyboard).
|
|
8
|
+
*
|
|
9
|
+
* Why two request ids? The card surfaces both "Apply as suggestion"
|
|
10
|
+
* and "Apply directly" buttons; each one grants a different kernel
|
|
11
|
+
* scope (`doc:gdrive:suggest:<id>` vs `doc:gdrive:write:<id>`). The
|
|
12
|
+
* upstream caller (the MCP-tool wrapper, or whoever's posting the
|
|
13
|
+
* card) registers BOTH up front with `approval_request`, then passes
|
|
14
|
+
* both ids into this renderer. The user taps one; the other expires
|
|
15
|
+
* naturally on the kernel side.
|
|
16
|
+
*
|
|
17
|
+
* Each action button reuses the existing `apv:<request_id>:once`
|
|
18
|
+
* callback shape so the generic kernel handler at
|
|
19
|
+
* `approval-callback.ts` records the grant without surface-specific
|
|
20
|
+
* routing. The "✅ once" semantics line up with the diff-preview's
|
|
21
|
+
* single-shot "do this edit now" intent.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { InlineKeyboard } from "grammy";
|
|
25
|
+
import type { DiffPreview } from "../../src/drive/diff-preview.js";
|
|
26
|
+
|
|
27
|
+
export interface BuiltDiffPreviewCard {
|
|
28
|
+
text: string;
|
|
29
|
+
reply_markup: InlineKeyboard;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DiffPreviewCardInput {
|
|
33
|
+
preview: DiffPreview;
|
|
34
|
+
/**
|
|
35
|
+
* Kernel request id pre-registered for `doc:gdrive:suggest:<doc_id>`.
|
|
36
|
+
* Required — the suggest path is the RFC's default. When undefined
|
|
37
|
+
* the renderer throws (an "approval card with no Apply button"
|
|
38
|
+
* isn't a coherent UX).
|
|
39
|
+
*/
|
|
40
|
+
suggestRequestId: string;
|
|
41
|
+
/**
|
|
42
|
+
* Kernel request id pre-registered for `doc:gdrive:write:<doc_id>`.
|
|
43
|
+
* Optional — when omitted, the "⚠ Apply directly" button is hidden
|
|
44
|
+
* (used for `gdrive_suggest_edit` callers that don't want to offer
|
|
45
|
+
* the direct-write escalation at all). When `preview.buttons` has
|
|
46
|
+
* an `apply_directly` entry but this is omitted, the button is
|
|
47
|
+
* dropped silently.
|
|
48
|
+
*/
|
|
49
|
+
writeRequestId?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 8-hex request id shape — same regex the kernel uses (RFC B §6.1).
|
|
54
|
+
* Defense in depth — a malformed request id would render an invalid
|
|
55
|
+
* callback_data that the dispatcher rejects, but we'd rather fail
|
|
56
|
+
* loudly at build time.
|
|
57
|
+
*/
|
|
58
|
+
const REQUEST_ID_RE = /^[0-9a-f]{8}$/;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fragility-guard from B2 review: the `create_doc` prep helper
|
|
62
|
+
* synthesises a "pending-create" placeholder fileId because the
|
|
63
|
+
* doc doesn't exist yet. `validateDriveId` happily accepts the
|
|
64
|
+
* literal "pending-create" string (it's alnum + `-`), so a naive
|
|
65
|
+
* Open-in-Drive button would emit a broken link. The renderer
|
|
66
|
+
* detects the sentinel and drops the open-in-drive row instead of
|
|
67
|
+
* rendering a dead link.
|
|
68
|
+
*/
|
|
69
|
+
const PENDING_FILE_ID_SENTINEL = "pending-create";
|
|
70
|
+
|
|
71
|
+
export function buildDiffPreviewCard(
|
|
72
|
+
input: DiffPreviewCardInput,
|
|
73
|
+
): BuiltDiffPreviewCard {
|
|
74
|
+
if (!REQUEST_ID_RE.test(input.suggestRequestId)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`buildDiffPreviewCard: suggestRequestId must be 8 hex chars (got '${input.suggestRequestId}')`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (input.writeRequestId !== undefined && !REQUEST_ID_RE.test(input.writeRequestId)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`buildDiffPreviewCard: writeRequestId must be 8 hex chars (got '${input.writeRequestId}')`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const preview = input.preview;
|
|
86
|
+
|
|
87
|
+
// Body: title + every diff-preview line in order, HTML-escaped.
|
|
88
|
+
// The 📍 + line-count rows are surfaced verbatim — they're
|
|
89
|
+
// wrapper-attested and the agent has no input into their content.
|
|
90
|
+
const bodyLines: string[] = [];
|
|
91
|
+
bodyLines.push(`<b>${escapeHtml(preview.title)}</b>`);
|
|
92
|
+
for (const line of preview.lines) {
|
|
93
|
+
bodyLines.push(escapeHtml(line.text));
|
|
94
|
+
}
|
|
95
|
+
const text = bodyLines.join("\n");
|
|
96
|
+
|
|
97
|
+
const kb = new InlineKeyboard();
|
|
98
|
+
|
|
99
|
+
// Layout per RFC E §4.2 mockup:
|
|
100
|
+
// row 1: [ 📖 Open in Drive ] [ ✅ Apply as suggestion ]
|
|
101
|
+
// row 2: [ ⚠ Apply directly ] [ 🚫 Cancel ]
|
|
102
|
+
//
|
|
103
|
+
// Buttons whose `action` doesn't match a known shape are dropped
|
|
104
|
+
// silently — the diff-preview builder is the source of truth for
|
|
105
|
+
// which buttons exist; the renderer just maps them to callbacks.
|
|
106
|
+
const ROW_BREAK_AFTER: Array<DiffPreview["buttons"][number]["action"]> = [
|
|
107
|
+
"apply_suggestion",
|
|
108
|
+
];
|
|
109
|
+
// `DiffPreview` doesn't carry the original mode, so infer from the
|
|
110
|
+
// button set: in suggest mode the builder always emits both
|
|
111
|
+
// `apply_suggestion` and `apply_directly`; in write mode it emits
|
|
112
|
+
// only `apply_directly`. The renderer drops `apply_directly` when
|
|
113
|
+
// a writeRequestId wasn't provided AND a suggestion path exists
|
|
114
|
+
// — caller chose to offer only Suggesting.
|
|
115
|
+
const offeringSuggestion = preview.buttons.some(
|
|
116
|
+
(b) => b.action === "apply_suggestion",
|
|
117
|
+
);
|
|
118
|
+
const droppedDirectly =
|
|
119
|
+
offeringSuggestion && input.writeRequestId === undefined;
|
|
120
|
+
|
|
121
|
+
const isPendingFileId =
|
|
122
|
+
preview.audit.wrapperAttested.fileId === PENDING_FILE_ID_SENTINEL;
|
|
123
|
+
|
|
124
|
+
let rowStarted = false;
|
|
125
|
+
const breakRow = () => {
|
|
126
|
+
if (rowStarted) {
|
|
127
|
+
kb.row();
|
|
128
|
+
rowStarted = false;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
for (const btn of preview.buttons) {
|
|
133
|
+
switch (btn.action) {
|
|
134
|
+
case "open_in_drive": {
|
|
135
|
+
if (isPendingFileId) break; // drop sentinel-URL buttons
|
|
136
|
+
if (typeof btn.url !== "string" || btn.url.length === 0) break;
|
|
137
|
+
kb.url(btn.text, btn.url);
|
|
138
|
+
rowStarted = true;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case "apply_suggestion": {
|
|
142
|
+
kb.text(btn.text, `apv:${input.suggestRequestId}:once`);
|
|
143
|
+
rowStarted = true;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
case "apply_directly": {
|
|
147
|
+
if (droppedDirectly) break;
|
|
148
|
+
const id = input.writeRequestId ?? input.suggestRequestId;
|
|
149
|
+
kb.text(btn.text, `apv:${id}:once`);
|
|
150
|
+
rowStarted = true;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case "cancel": {
|
|
154
|
+
kb.text(btn.text, `apv:${input.suggestRequestId}:deny`);
|
|
155
|
+
rowStarted = true;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (ROW_BREAK_AFTER.includes(btn.action)) breakRow();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { text, reply_markup: kb };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function escapeHtml(s: string): string {
|
|
166
|
+
return s
|
|
167
|
+
.replace(/&/g, "&")
|
|
168
|
+
.replace(/</g, "<")
|
|
169
|
+
.replace(/>/g, ">");
|
|
170
|
+
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the Drive-write IPC handler — RFC E §4.2 Cut 2.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
DriveApprovalPostedEvent,
|
|
9
|
+
RequestDriveApprovalMessage,
|
|
10
|
+
} from "./ipc-protocol.js";
|
|
11
|
+
import {
|
|
12
|
+
type DriveApprovalHandlerDeps,
|
|
13
|
+
handleRequestDriveApproval,
|
|
14
|
+
} from "./drive-write-approval.js";
|
|
15
|
+
|
|
16
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
17
|
+
// Fixtures
|
|
18
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Valid DiffPreviewInput shape — matches src/drive/diff-preview.ts. */
|
|
21
|
+
function validPreview(): Record<string, unknown> {
|
|
22
|
+
return {
|
|
23
|
+
agentName: "klanker",
|
|
24
|
+
docTitle: "Q3 Strategy Notes",
|
|
25
|
+
fileId: "DOC1",
|
|
26
|
+
mimeType: "application/vnd.google-apps.document",
|
|
27
|
+
resolvedAnchor: {
|
|
28
|
+
op: { kind: "insert_after", paragraphIndex: 4 },
|
|
29
|
+
displayName: "inside section 'Goals' (level 2)",
|
|
30
|
+
},
|
|
31
|
+
metrics: { linesAdded: 5, linesRemoved: 0 },
|
|
32
|
+
mode: "write",
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Spy {
|
|
37
|
+
sent: DriveApprovalPostedEvent[];
|
|
38
|
+
registered: Array<{
|
|
39
|
+
scope: string;
|
|
40
|
+
action: string;
|
|
41
|
+
ttl_ms: number;
|
|
42
|
+
approver_set: string[];
|
|
43
|
+
}>;
|
|
44
|
+
posted: Array<{
|
|
45
|
+
chatId: number | string;
|
|
46
|
+
threadId?: number;
|
|
47
|
+
text: string;
|
|
48
|
+
}>;
|
|
49
|
+
logs: string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeSpy(): Spy {
|
|
53
|
+
return { sent: [], registered: [], posted: [], logs: [] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function deps(overrides: Partial<DriveApprovalHandlerDeps> & { spy: Spy }): DriveApprovalHandlerDeps {
|
|
57
|
+
const spy = overrides.spy;
|
|
58
|
+
return {
|
|
59
|
+
agentName: "klanker",
|
|
60
|
+
loadAllowFrom: () => ["12345"],
|
|
61
|
+
loadTargetChat: () => ({ chatId: 999 }),
|
|
62
|
+
registerApproval: async (args) => {
|
|
63
|
+
spy.registered.push({
|
|
64
|
+
scope: args.scope,
|
|
65
|
+
action: args.action,
|
|
66
|
+
ttl_ms: args.ttl_ms,
|
|
67
|
+
approver_set: args.approver_set,
|
|
68
|
+
});
|
|
69
|
+
return { request_id: "aabbccdd", expires_at_ms: Date.now() + args.ttl_ms };
|
|
70
|
+
},
|
|
71
|
+
postCard: async (args) => {
|
|
72
|
+
spy.posted.push({
|
|
73
|
+
chatId: args.chatId,
|
|
74
|
+
...(args.threadId !== undefined ? { threadId: args.threadId } : {}),
|
|
75
|
+
text: args.text,
|
|
76
|
+
});
|
|
77
|
+
return { messageId: 42 };
|
|
78
|
+
},
|
|
79
|
+
buildCard: () => ({ text: "diff-preview card body", reply_markup: { stub: true } }),
|
|
80
|
+
log: (m) => spy.logs.push(m),
|
|
81
|
+
...overrides,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function clientFor(spy: Spy): { send: (msg: unknown) => void } {
|
|
86
|
+
return {
|
|
87
|
+
send: (msg) => {
|
|
88
|
+
const m = msg as DriveApprovalPostedEvent;
|
|
89
|
+
if (m.type === "drive_approval_posted") spy.sent.push(m);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function msgFor(overrides: Partial<RequestDriveApprovalMessage> = {}): RequestDriveApprovalMessage {
|
|
95
|
+
return {
|
|
96
|
+
type: "request_drive_approval",
|
|
97
|
+
correlationId: "corr-1",
|
|
98
|
+
agentName: "klanker",
|
|
99
|
+
preview: validPreview(),
|
|
100
|
+
...overrides,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
105
|
+
// Happy path
|
|
106
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
describe("handleRequestDriveApproval — happy path", () => {
|
|
109
|
+
it("registers the kernel request + posts the card + replies success", async () => {
|
|
110
|
+
const spy = makeSpy();
|
|
111
|
+
await handleRequestDriveApproval(clientFor(spy), msgFor(), deps({ spy }));
|
|
112
|
+
expect(spy.registered).toHaveLength(1);
|
|
113
|
+
expect(spy.registered[0]).toEqual({
|
|
114
|
+
scope: "doc:gdrive:write:DOC1",
|
|
115
|
+
action: "write",
|
|
116
|
+
ttl_ms: 5 * 60 * 1000,
|
|
117
|
+
approver_set: ["12345"],
|
|
118
|
+
});
|
|
119
|
+
expect(spy.posted).toHaveLength(1);
|
|
120
|
+
expect(spy.posted[0]?.chatId).toBe(999);
|
|
121
|
+
expect(spy.sent).toHaveLength(1);
|
|
122
|
+
expect(spy.sent[0]).toMatchObject({
|
|
123
|
+
type: "drive_approval_posted",
|
|
124
|
+
correlationId: "corr-1",
|
|
125
|
+
ok: true,
|
|
126
|
+
requestId: "aabbccdd",
|
|
127
|
+
});
|
|
128
|
+
expect(spy.sent[0]?.expiresAtMs).toBeGreaterThan(Date.now());
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("threads threadId through to postCard when targetChat has one", async () => {
|
|
132
|
+
const spy = makeSpy();
|
|
133
|
+
await handleRequestDriveApproval(
|
|
134
|
+
clientFor(spy),
|
|
135
|
+
msgFor(),
|
|
136
|
+
deps({ spy, loadTargetChat: () => ({ chatId: 999, threadId: 7 }) }),
|
|
137
|
+
);
|
|
138
|
+
expect(spy.posted[0]?.threadId).toBe(7);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("respects a caller-supplied ttlMs (within clamp)", async () => {
|
|
142
|
+
const spy = makeSpy();
|
|
143
|
+
await handleRequestDriveApproval(
|
|
144
|
+
clientFor(spy),
|
|
145
|
+
msgFor({ ttlMs: 90_000 }),
|
|
146
|
+
deps({ spy }),
|
|
147
|
+
);
|
|
148
|
+
expect(spy.registered[0]?.ttl_ms).toBe(90_000);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
153
|
+
// Refusals
|
|
154
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
describe("handleRequestDriveApproval — refusals", () => {
|
|
157
|
+
it("refuses cross-agent requests", async () => {
|
|
158
|
+
const spy = makeSpy();
|
|
159
|
+
await handleRequestDriveApproval(
|
|
160
|
+
clientFor(spy),
|
|
161
|
+
msgFor({ agentName: "clerk" }),
|
|
162
|
+
deps({ spy }),
|
|
163
|
+
);
|
|
164
|
+
expect(spy.registered).toEqual([]);
|
|
165
|
+
expect(spy.sent[0]?.ok).toBe(false);
|
|
166
|
+
expect(spy.sent[0]?.reason).toMatch(/serves 'klanker'/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("refuses malformed preview payloads", async () => {
|
|
170
|
+
const spy = makeSpy();
|
|
171
|
+
await handleRequestDriveApproval(
|
|
172
|
+
clientFor(spy),
|
|
173
|
+
msgFor({ preview: { junk: true } }),
|
|
174
|
+
deps({ spy }),
|
|
175
|
+
);
|
|
176
|
+
expect(spy.registered).toEqual([]);
|
|
177
|
+
expect(spy.sent[0]?.ok).toBe(false);
|
|
178
|
+
expect(spy.sent[0]?.reason).toMatch(/invalid preview/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("refuses when no operator allowFrom is configured", async () => {
|
|
182
|
+
const spy = makeSpy();
|
|
183
|
+
await handleRequestDriveApproval(
|
|
184
|
+
clientFor(spy),
|
|
185
|
+
msgFor(),
|
|
186
|
+
deps({ spy, loadAllowFrom: () => [] }),
|
|
187
|
+
);
|
|
188
|
+
expect(spy.sent[0]?.ok).toBe(false);
|
|
189
|
+
expect(spy.sent[0]?.reason).toMatch(/allowFrom/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("refuses when no target chat is available", async () => {
|
|
193
|
+
const spy = makeSpy();
|
|
194
|
+
await handleRequestDriveApproval(
|
|
195
|
+
clientFor(spy),
|
|
196
|
+
msgFor(),
|
|
197
|
+
deps({ spy, loadTargetChat: () => null }),
|
|
198
|
+
);
|
|
199
|
+
expect(spy.sent[0]?.ok).toBe(false);
|
|
200
|
+
expect(spy.sent[0]?.reason).toMatch(/target chat/);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
205
|
+
// Failure modes
|
|
206
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
describe("handleRequestDriveApproval — downstream failures", () => {
|
|
209
|
+
it("kernel approval_request failure → ok:false with diagnostic reason", async () => {
|
|
210
|
+
const spy = makeSpy();
|
|
211
|
+
await handleRequestDriveApproval(
|
|
212
|
+
clientFor(spy),
|
|
213
|
+
msgFor(),
|
|
214
|
+
deps({ spy, registerApproval: async () => null }),
|
|
215
|
+
);
|
|
216
|
+
expect(spy.posted).toEqual([]); // card not posted
|
|
217
|
+
expect(spy.sent[0]?.ok).toBe(false);
|
|
218
|
+
expect(spy.sent[0]?.reason).toMatch(/kernel approval_request/);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("card build throw → ok:false (caught + reported)", async () => {
|
|
222
|
+
const spy = makeSpy();
|
|
223
|
+
await handleRequestDriveApproval(
|
|
224
|
+
clientFor(spy),
|
|
225
|
+
msgFor(),
|
|
226
|
+
deps({
|
|
227
|
+
spy,
|
|
228
|
+
buildCard: () => {
|
|
229
|
+
throw new Error("invalid request id");
|
|
230
|
+
},
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
expect(spy.posted).toEqual([]);
|
|
234
|
+
expect(spy.sent[0]?.ok).toBe(false);
|
|
235
|
+
expect(spy.sent[0]?.reason).toMatch(/card build failed/);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("Telegram sendMessage failure → ok:false", async () => {
|
|
239
|
+
const spy = makeSpy();
|
|
240
|
+
await handleRequestDriveApproval(
|
|
241
|
+
clientFor(spy),
|
|
242
|
+
msgFor(),
|
|
243
|
+
deps({ spy, postCard: async () => null }),
|
|
244
|
+
);
|
|
245
|
+
expect(spy.sent[0]?.ok).toBe(false);
|
|
246
|
+
expect(spy.sent[0]?.reason).toMatch(/sendMessage failed/);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
251
|
+
// TTL clamping
|
|
252
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
describe("handleRequestDriveApproval — TTL clamping", () => {
|
|
255
|
+
it("clamps below-min TTL up to the minimum", async () => {
|
|
256
|
+
const spy = makeSpy();
|
|
257
|
+
await handleRequestDriveApproval(
|
|
258
|
+
clientFor(spy),
|
|
259
|
+
msgFor({ ttlMs: 1000 }),
|
|
260
|
+
deps({ spy }),
|
|
261
|
+
);
|
|
262
|
+
expect(spy.registered[0]?.ttl_ms).toBe(30_000); // min default
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("clamps above-max TTL down to the maximum", async () => {
|
|
266
|
+
const spy = makeSpy();
|
|
267
|
+
await handleRequestDriveApproval(
|
|
268
|
+
clientFor(spy),
|
|
269
|
+
msgFor({ ttlMs: 999_999_999 }),
|
|
270
|
+
deps({ spy }),
|
|
271
|
+
);
|
|
272
|
+
expect(spy.registered[0]?.ttl_ms).toBe(30 * 60 * 1000); // max default
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("uses the configured default when ttlMs is undefined", async () => {
|
|
276
|
+
const spy = makeSpy();
|
|
277
|
+
await handleRequestDriveApproval(clientFor(spy), msgFor(), deps({ spy }));
|
|
278
|
+
expect(spy.registered[0]?.ttl_ms).toBe(5 * 60 * 1000);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
283
|
+
// Always responds (no path drops the response)
|
|
284
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
describe("handleRequestDriveApproval — invariant: always sends a reply", () => {
|
|
287
|
+
it("every refusal path emits exactly one drive_approval_posted event", async () => {
|
|
288
|
+
const cases: Array<Partial<DriveApprovalHandlerDeps> | "cross-agent" | "bad-preview"> = [
|
|
289
|
+
"cross-agent",
|
|
290
|
+
"bad-preview",
|
|
291
|
+
{ loadAllowFrom: () => [] },
|
|
292
|
+
{ loadTargetChat: () => null },
|
|
293
|
+
{ registerApproval: async () => null },
|
|
294
|
+
{ postCard: async () => null },
|
|
295
|
+
];
|
|
296
|
+
for (const c of cases) {
|
|
297
|
+
const spy = makeSpy();
|
|
298
|
+
const msg =
|
|
299
|
+
c === "cross-agent"
|
|
300
|
+
? msgFor({ agentName: "clerk" })
|
|
301
|
+
: c === "bad-preview"
|
|
302
|
+
? msgFor({ preview: { bad: true } })
|
|
303
|
+
: msgFor();
|
|
304
|
+
const dep =
|
|
305
|
+
c === "cross-agent" || c === "bad-preview"
|
|
306
|
+
? deps({ spy })
|
|
307
|
+
: deps({ spy, ...c });
|
|
308
|
+
await handleRequestDriveApproval(clientFor(spy), msg, dep);
|
|
309
|
+
expect(spy.sent).toHaveLength(1);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
});
|