switchroom 0.10.0 → 0.11.1

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 (52) hide show
  1. package/README.md +5 -4
  2. package/dist/agent-scheduler/index.js +2 -2
  3. package/dist/auth-broker/index.js +125 -3
  4. package/dist/cli/drive-write-pretool.mjs +5436 -0
  5. package/dist/cli/switchroom.js +231 -29
  6. package/dist/host-control/main.js +2 -2
  7. package/dist/vault/approvals/kernel-server.js +2 -2
  8. package/dist/vault/broker/server.js +2 -2
  9. package/package.json +1 -1
  10. package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
  11. package/telegram-plugin/admin-commands/index.ts +2 -0
  12. package/telegram-plugin/auth-snapshot-format.ts +612 -0
  13. package/telegram-plugin/auto-fallback-fleet.ts +215 -0
  14. package/telegram-plugin/auto-fallback.ts +28 -301
  15. package/telegram-plugin/dist/gateway/gateway.js +4314 -2143
  16. package/telegram-plugin/fleet-fallback-gate.ts +105 -0
  17. package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
  18. package/telegram-plugin/gateway/approval-callback.ts +31 -3
  19. package/telegram-plugin/gateway/auth-broker-client.ts +2 -0
  20. package/telegram-plugin/gateway/auth-command.ts +131 -10
  21. package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
  22. package/telegram-plugin/gateway/boot-card.ts +1 -1
  23. package/telegram-plugin/gateway/boot-probes.ts +6 -9
  24. package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
  25. package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
  26. package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
  27. package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
  28. package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
  29. package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
  30. package/telegram-plugin/gateway/gateway.ts +903 -173
  31. package/telegram-plugin/gateway/hostd-dispatch.ts +137 -2
  32. package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
  33. package/telegram-plugin/gateway/ipc-server.ts +69 -0
  34. package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
  35. package/telegram-plugin/model-unavailable.ts +28 -12
  36. package/telegram-plugin/silence-poke.ts +153 -1
  37. package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
  38. package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
  39. package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
  40. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
  41. package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
  42. package/telegram-plugin/tests/boot-probes.test.ts +16 -18
  43. package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
  44. package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
  45. package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
  46. package/telegram-plugin/tests/silence-poke.test.ts +237 -0
  47. package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
  48. package/telegram-plugin/turn-flush-safety.ts +55 -1
  49. package/telegram-plugin/uat/SETUP.md +16 -12
  50. package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
  51. package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
  52. 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("&lt;script&gt;");
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("&lt;b&gt;");
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, "&amp;")
168
+ .replace(/</g, "&lt;")
169
+ .replace(/>/g, "&gt;");
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
+ });