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,243 @@
1
+ /**
2
+ * Drive-write approval handler — RFC E §4.2 Cut 2.
3
+ *
4
+ * Called by the gateway's IPC dispatcher when the Drive-write
5
+ * PreToolUse hook sends a `request_drive_approval` message. The
6
+ * handler:
7
+ *
8
+ * 1. Validates the inbound preview payload via `buildDiffPreview`
9
+ * (which fails closed on malformed inputs).
10
+ * 2. Registers a kernel approval request at scope
11
+ * `doc:gdrive:write:<fileId>`, action `write`, approver_set =
12
+ * operator allowFrom.
13
+ * 3. Builds the Telegram card via `buildDiffPreviewCard` (#1299).
14
+ * 4. Posts the card to the operator chat via grammy.
15
+ * 5. Sends a `drive_approval_posted` event back over IPC with the
16
+ * kernel request_id + expires_at so the hook can poll
17
+ * `approval_lookup` for the verdict.
18
+ *
19
+ * On any failure the handler sends `drive_approval_posted { ok:
20
+ * false, reason: ... }` so the hook fails closed (blocks the tool).
21
+ *
22
+ * Kept in its own module so the unit tests for the orchestration
23
+ * (kernel call + card build + post + response) live separately
24
+ * from the gateway monolith.
25
+ */
26
+
27
+ import { buildDiffPreview, type DiffPreviewInput } from "../../src/drive/diff-preview.js";
28
+ import type { IpcClient } from "./ipc-server.js";
29
+ import type {
30
+ DriveApprovalPostedEvent,
31
+ RequestDriveApprovalMessage,
32
+ } from "./ipc-protocol.js";
33
+
34
+ // ────────────────────────────────────────────────────────────────────────
35
+ // Injected deps — caller (gateway.ts) wires these from the existing
36
+ // surface area. Kept abstract so the handler unit-tests don't need
37
+ // grammy / the kernel / the gateway in scope.
38
+ // ────────────────────────────────────────────────────────────────────────
39
+
40
+ export interface DriveApprovalHandlerDeps {
41
+ /** This gateway's agent name — cross-agent requests rejected. */
42
+ agentName: string;
43
+ /**
44
+ * Operator allowFrom list (Telegram user ids as strings) — used
45
+ * as the kernel's approver_set, and to pick the target chat for
46
+ * the card post.
47
+ */
48
+ loadAllowFrom: () => string[];
49
+ /**
50
+ * The chat (and optional topic) the picker card should land in.
51
+ * For the standard DM-based operator setup this is the operator's
52
+ * private chat with the bot; for group-based setups the operator
53
+ * group chat + the agent's topic id.
54
+ */
55
+ loadTargetChat: () => {
56
+ chatId: number | string;
57
+ threadId?: number;
58
+ } | null;
59
+ /**
60
+ * Register a kernel approval request. Returns the kernel's
61
+ * request_id + expires_at_ms on success, null on failure (rate
62
+ * limit, broker unreachable, etc.).
63
+ */
64
+ registerApproval: (args: {
65
+ agent_unit: string;
66
+ scope: string;
67
+ action: string;
68
+ approver_set: string[];
69
+ why: string;
70
+ ttl_ms: number;
71
+ }) => Promise<{ request_id: string; expires_at_ms: number } | null>;
72
+ /**
73
+ * Post the diff-preview card to Telegram. Returns a posted
74
+ * message id on success, null on failure.
75
+ */
76
+ postCard: (args: {
77
+ chatId: number | string;
78
+ threadId?: number;
79
+ text: string;
80
+ /** grammy's InlineKeyboard, passed straight through. */
81
+ replyMarkup: unknown;
82
+ }) => Promise<{ messageId: number } | null>;
83
+ /**
84
+ * Build the Telegram-shaped card from a DiffPreview. Pass-through
85
+ * to `buildDiffPreviewCard` (#1299); deferred via deps so the
86
+ * handler tests don't need grammy.
87
+ */
88
+ buildCard: (args: {
89
+ preview: ReturnType<typeof buildDiffPreview>;
90
+ suggestRequestId: string;
91
+ }) => { text: string; reply_markup: unknown };
92
+ log?: (msg: string) => void;
93
+ /** TTL clamping policy. */
94
+ defaultTtlMs?: number;
95
+ maxTtlMs?: number;
96
+ minTtlMs?: number;
97
+ }
98
+
99
+ const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
100
+ const MAX_TTL_MS = 30 * 60 * 1000; // 30 minutes
101
+ const MIN_TTL_MS = 30 * 1000; // 30 seconds
102
+
103
+ /**
104
+ * Top-level handler called by ipc-server's onRequestDriveApproval.
105
+ * Always sends a single `drive_approval_posted` reply (success or
106
+ * failure) before returning.
107
+ */
108
+ export async function handleRequestDriveApproval(
109
+ client: Pick<IpcClient, "send">,
110
+ msg: RequestDriveApprovalMessage,
111
+ deps: DriveApprovalHandlerDeps,
112
+ ): Promise<void> {
113
+ const reply = (event: Omit<DriveApprovalPostedEvent, "type">) => {
114
+ try {
115
+ client.send({ type: "drive_approval_posted", ...event });
116
+ } catch (err) {
117
+ deps.log?.(
118
+ `drive_approval_posted send failed (correlation=${msg.correlationId}): ${(err as Error).message}`,
119
+ );
120
+ }
121
+ };
122
+
123
+ // 1. Cross-agent guard.
124
+ if (msg.agentName !== deps.agentName) {
125
+ reply({
126
+ correlationId: msg.correlationId,
127
+ ok: false,
128
+ reason: `gateway serves '${deps.agentName}', not '${msg.agentName}'`,
129
+ });
130
+ return;
131
+ }
132
+
133
+ // 2. Validate preview shape — buildDiffPreview throws on
134
+ // malformed inputs (fileId missing, metrics invalid, etc).
135
+ let preview: ReturnType<typeof buildDiffPreview>;
136
+ try {
137
+ preview = buildDiffPreview(msg.preview as unknown as DiffPreviewInput);
138
+ } catch (err) {
139
+ reply({
140
+ correlationId: msg.correlationId,
141
+ ok: false,
142
+ reason: `invalid preview payload: ${(err as Error).message}`,
143
+ });
144
+ return;
145
+ }
146
+
147
+ // 3. Pull operator targeting + allowFrom.
148
+ const allowFrom = deps.loadAllowFrom();
149
+ if (allowFrom.length === 0) {
150
+ reply({
151
+ correlationId: msg.correlationId,
152
+ ok: false,
153
+ reason: "no operator allowFrom configured — cannot route approval",
154
+ });
155
+ return;
156
+ }
157
+ const target = deps.loadTargetChat();
158
+ if (target === null) {
159
+ reply({
160
+ correlationId: msg.correlationId,
161
+ ok: false,
162
+ reason: "no target chat available — operator not paired?",
163
+ });
164
+ return;
165
+ }
166
+
167
+ // 4. TTL clamp.
168
+ const ttlMs = clampTtl(
169
+ msg.ttlMs,
170
+ deps.defaultTtlMs ?? DEFAULT_TTL_MS,
171
+ deps.minTtlMs ?? MIN_TTL_MS,
172
+ deps.maxTtlMs ?? MAX_TTL_MS,
173
+ );
174
+
175
+ // 5. Kernel approval request.
176
+ const fileId = preview.audit.wrapperAttested.fileId;
177
+ const scope = `doc:gdrive:write:${fileId}`;
178
+ const registered = await deps.registerApproval({
179
+ agent_unit: deps.agentName,
180
+ scope,
181
+ action: "write",
182
+ approver_set: allowFrom,
183
+ why: `Drive write — ${preview.audit.wrapperAttested.docTitle}`,
184
+ ttl_ms: ttlMs,
185
+ });
186
+ if (registered === null) {
187
+ reply({
188
+ correlationId: msg.correlationId,
189
+ ok: false,
190
+ reason: "kernel approval_request failed (rate limit or broker unreachable)",
191
+ });
192
+ return;
193
+ }
194
+
195
+ // 6. Build + post the card.
196
+ let card: { text: string; reply_markup: unknown };
197
+ try {
198
+ card = deps.buildCard({ preview, suggestRequestId: registered.request_id });
199
+ } catch (err) {
200
+ reply({
201
+ correlationId: msg.correlationId,
202
+ ok: false,
203
+ reason: `card build failed: ${(err as Error).message}`,
204
+ });
205
+ return;
206
+ }
207
+ const posted = await deps.postCard({
208
+ chatId: target.chatId,
209
+ ...(target.threadId !== undefined ? { threadId: target.threadId } : {}),
210
+ text: card.text,
211
+ replyMarkup: card.reply_markup,
212
+ });
213
+ if (posted === null) {
214
+ reply({
215
+ correlationId: msg.correlationId,
216
+ ok: false,
217
+ reason: "Telegram sendMessage failed",
218
+ });
219
+ return;
220
+ }
221
+
222
+ deps.log?.(
223
+ `drive_approval_posted ok correlation=${msg.correlationId} request_id=${registered.request_id} file=${fileId}`,
224
+ );
225
+ reply({
226
+ correlationId: msg.correlationId,
227
+ ok: true,
228
+ requestId: registered.request_id,
229
+ expiresAtMs: registered.expires_at_ms,
230
+ });
231
+ }
232
+
233
+ function clampTtl(
234
+ requested: number | undefined,
235
+ fallback: number,
236
+ min: number,
237
+ max: number,
238
+ ): number {
239
+ const t = requested === undefined || !Number.isFinite(requested) ? fallback : requested;
240
+ if (t < min) return min;
241
+ if (t > max) return max;
242
+ return t;
243
+ }
@@ -0,0 +1,314 @@
1
+ /**
2
+ * Tests for folder-picker Telegram handlers — RFC E §4.1 wire-up.
3
+ *
4
+ * The handlers are kernel/Drive/grammy-agnostic via injected deps;
5
+ * tests use minimal stub contexts + fake deps.
6
+ */
7
+
8
+ import { describe, expect, it } from "vitest";
9
+ import type { Context } from "grammy";
10
+
11
+ import { FolderListCache } from "../../src/drive/folder-list.js";
12
+ import type { FolderPage } from "../../src/drive/folder-list.js";
13
+ import {
14
+ handleFolderPickerCallback,
15
+ handleFoldersCommand,
16
+ type FolderPickerHandlerDeps,
17
+ } from "./folder-picker-handler.js";
18
+
19
+ interface FakeCtx {
20
+ from: { id: number };
21
+ replies: Array<{ text: string; keyboardRows: string[][] }>;
22
+ edits: Array<{ text: string; keyboardRows: string[][] | undefined }>;
23
+ callbackAnswers: Array<{ text?: string }>;
24
+ }
25
+
26
+ function fakeCtx(userId = 12345): { ctx: Context; spy: FakeCtx } {
27
+ const spy: FakeCtx = {
28
+ from: { id: userId },
29
+ replies: [],
30
+ edits: [],
31
+ callbackAnswers: [],
32
+ };
33
+ const ctx = {
34
+ from: spy.from,
35
+ reply: async (text: string, opts?: { reply_markup?: { inline_keyboard?: unknown[][] } }) => {
36
+ const rows = (opts?.reply_markup?.inline_keyboard ?? []) as Array<
37
+ Array<{ text: string }>
38
+ >;
39
+ spy.replies.push({
40
+ text,
41
+ keyboardRows: rows.map((r) => r.map((b) => b.text)),
42
+ });
43
+ return { message_id: 1 };
44
+ },
45
+ editMessageText: async (text: string, opts?: { reply_markup?: { inline_keyboard?: unknown[][] } }) => {
46
+ const rows = (opts?.reply_markup?.inline_keyboard ?? []) as Array<
47
+ Array<{ text: string }>
48
+ >;
49
+ spy.edits.push({
50
+ text,
51
+ keyboardRows: opts?.reply_markup
52
+ ? rows.map((r) => r.map((b) => b.text))
53
+ : undefined,
54
+ });
55
+ return true;
56
+ },
57
+ answerCallbackQuery: async (arg?: { text?: string }) => {
58
+ spy.callbackAnswers.push(arg ?? {});
59
+ return true;
60
+ },
61
+ } as unknown as Context;
62
+ return { ctx, spy };
63
+ }
64
+
65
+ interface FakeKernel {
66
+ requests: Array<{ scope: string; action: string }>;
67
+ consumed: string[];
68
+ recorded: Array<{
69
+ request_id: string;
70
+ decision: string;
71
+ approver_set: string[];
72
+ }>;
73
+ nextRequestId: string;
74
+ failRequest?: boolean;
75
+ failConsume?: boolean;
76
+ failRecord?: boolean;
77
+ }
78
+
79
+ function depsFor(args: {
80
+ agentName?: string;
81
+ fetchPage?: FolderPickerHandlerDeps["fetchPage"];
82
+ cache?: FolderListCache;
83
+ kernel?: FakeKernel;
84
+ }): { deps: FolderPickerHandlerDeps; kernel: FakeKernel; cache: FolderListCache } {
85
+ const cache = args.cache ?? new FolderListCache({ now: () => 1000 });
86
+ const kernel: FakeKernel = args.kernel ?? {
87
+ requests: [],
88
+ consumed: [],
89
+ recorded: [],
90
+ nextRequestId: "abcdef01",
91
+ };
92
+ const deps: FolderPickerHandlerDeps = {
93
+ agentName: args.agentName ?? "klanker",
94
+ cache,
95
+ fetchPage:
96
+ args.fetchPage ??
97
+ (async () => ({ folders: [{ id: "F1", name: "Work" }] })),
98
+ approvalRequest: async (a) => {
99
+ if (kernel.failRequest) return null;
100
+ kernel.requests.push({ scope: a.scope, action: a.action });
101
+ return { request_id: kernel.nextRequestId };
102
+ },
103
+ approvalConsume: async (id) => {
104
+ if (kernel.failConsume) return false;
105
+ kernel.consumed.push(id);
106
+ return true;
107
+ },
108
+ approvalRecord: async (a) => {
109
+ if (kernel.failRecord) return null;
110
+ kernel.recorded.push({
111
+ request_id: a.request_id,
112
+ decision: a.decision,
113
+ approver_set: a.approver_set,
114
+ });
115
+ return "dec-1";
116
+ },
117
+ };
118
+ return { deps, kernel, cache };
119
+ }
120
+
121
+ describe("handleFoldersCommand", () => {
122
+ it("posts a picker card with the top-level folders", async () => {
123
+ const { ctx, spy } = fakeCtx();
124
+ const { deps } = depsFor({});
125
+ await handleFoldersCommand(ctx, deps);
126
+ expect(spy.replies).toHaveLength(1);
127
+ expect(spy.replies[0]?.text).toContain("📁");
128
+ expect(spy.replies[0]?.text).toContain("1 folder");
129
+ // Two folder rows ([Allow] + [Browse]) plus nav row.
130
+ expect(spy.replies[0]?.keyboardRows.length).toBeGreaterThanOrEqual(3);
131
+ });
132
+
133
+ it("hits the cache on a re-issued /folders within TTL", async () => {
134
+ const { ctx, spy } = fakeCtx();
135
+ let fetches = 0;
136
+ const { deps } = depsFor({
137
+ fetchPage: async () => {
138
+ fetches += 1;
139
+ return { folders: [{ id: "F1", name: "Work" }] };
140
+ },
141
+ });
142
+ await handleFoldersCommand(ctx, deps);
143
+ await handleFoldersCommand(ctx, deps);
144
+ expect(fetches).toBe(1);
145
+ expect(spy.replies).toHaveLength(2);
146
+ });
147
+
148
+ it("surfaces a Drive failure as a friendly error message (no crash)", async () => {
149
+ const { ctx, spy } = fakeCtx();
150
+ const { deps } = depsFor({
151
+ fetchPage: async () => {
152
+ throw new Error("HTTP 401");
153
+ },
154
+ });
155
+ await handleFoldersCommand(ctx, deps);
156
+ expect(spy.replies[0]?.text).toContain("Drive folder listing failed");
157
+ expect(spy.replies[0]?.text).toContain("HTTP 401");
158
+ });
159
+ });
160
+
161
+ describe("handleFolderPickerCallback — refusal cases", () => {
162
+ it("rejects a malformed callback", async () => {
163
+ const { ctx, spy } = fakeCtx();
164
+ const { deps } = depsFor({});
165
+ await handleFolderPickerCallback(ctx, "not-a-drvpick-callback", deps);
166
+ expect(spy.callbackAnswers[0]?.text).toMatch(/malformed/);
167
+ expect(spy.edits).toEqual([]);
168
+ });
169
+
170
+ it("refuses callbacks for a different agent (path-scoped guard)", async () => {
171
+ const { ctx, spy } = fakeCtx();
172
+ const { deps } = depsFor({ agentName: "klanker" });
173
+ await handleFolderPickerCallback(ctx, "drvpick:grant:clerk:F1", deps);
174
+ expect(spy.callbackAnswers[0]?.text).toMatch(/this gateway serves 'klanker'/);
175
+ expect(spy.edits).toEqual([]);
176
+ });
177
+ });
178
+
179
+ describe("handleFolderPickerCallback — navigation", () => {
180
+ it("enter drills into a sub-folder (cache miss → fetchPage with parent_id)", async () => {
181
+ const { ctx, spy } = fakeCtx();
182
+ const fetched: string[] = [];
183
+ const { deps } = depsFor({
184
+ fetchPage: async ({ parent_id }) => {
185
+ fetched.push(parent_id ?? "<top>");
186
+ return { folders: [{ id: "SUB1", name: "Q3" }] };
187
+ },
188
+ });
189
+ await handleFolderPickerCallback(ctx, "drvpick:enter:klanker:F1", deps);
190
+ expect(fetched).toEqual(["F1"]);
191
+ expect(spy.edits[0]?.text).toContain("/F1");
192
+ expect(spy.callbackAnswers[0]).toEqual({});
193
+ });
194
+
195
+ it("back returns to the named parent level", async () => {
196
+ const { ctx, spy } = fakeCtx();
197
+ const fetched: string[] = [];
198
+ const { deps } = depsFor({
199
+ fetchPage: async ({ parent_id }) => {
200
+ fetched.push(parent_id ?? "<top>");
201
+ return { folders: [] };
202
+ },
203
+ });
204
+ await handleFolderPickerCallback(ctx, "drvpick:back:klanker:PARENT1", deps);
205
+ expect(fetched).toEqual(["PARENT1"]);
206
+ });
207
+
208
+ it("back to top-of-Drive (empty parent_id) fetches the top page", async () => {
209
+ const { ctx, spy } = fakeCtx();
210
+ const fetched: Array<string | undefined> = [];
211
+ const { deps } = depsFor({
212
+ fetchPage: async ({ parent_id }) => {
213
+ fetched.push(parent_id);
214
+ return { folders: [] };
215
+ },
216
+ });
217
+ await handleFolderPickerCallback(ctx, "drvpick:back:klanker:", deps);
218
+ expect(fetched).toEqual([undefined]);
219
+ });
220
+
221
+ it("refresh bypasses cache (forceRefresh)", async () => {
222
+ const cache = new FolderListCache({ now: () => 1000 });
223
+ cache.set("klanker", { folders: [{ id: "STALE", name: "S" }] });
224
+ let fetches = 0;
225
+ const { ctx, spy } = fakeCtx();
226
+ const { deps } = depsFor({
227
+ cache,
228
+ fetchPage: async () => {
229
+ fetches += 1;
230
+ return { folders: [{ id: "FRESH", name: "F" }] };
231
+ },
232
+ });
233
+ await handleFolderPickerCallback(ctx, "drvpick:refresh:klanker:", deps);
234
+ expect(fetches).toBe(1);
235
+ expect(spy.edits[0]?.text).toContain("📁");
236
+ });
237
+
238
+ it("open resolves a page-token handle back to the real Drive token", async () => {
239
+ const cache = new FolderListCache({ now: () => 1000 });
240
+ const handle = cache.registerPageToken("klanker", "REAL_DRIVE_TOKEN_120_CHARS_XXXXXX");
241
+ const { ctx, spy } = fakeCtx();
242
+ let observedToken: string | undefined;
243
+ const { deps } = depsFor({
244
+ cache,
245
+ fetchPage: async ({ page_token }) => {
246
+ observedToken = page_token;
247
+ return { folders: [{ id: "F2", name: "Page 2" }] };
248
+ },
249
+ });
250
+ await handleFolderPickerCallback(
251
+ ctx,
252
+ `drvpick:open:klanker::${handle}`,
253
+ deps,
254
+ );
255
+ expect(observedToken).toBe("REAL_DRIVE_TOKEN_120_CHARS_XXXXXX");
256
+ // The folder name lands in the [Allow / Browse] keyboard rows,
257
+ // not the body line.
258
+ const flat = spy.edits[0]?.keyboardRows.flat() ?? [];
259
+ expect(flat.some((b) => b.includes("Page 2"))).toBe(true);
260
+ });
261
+ });
262
+
263
+ describe("handleFolderPickerCallback — grant", () => {
264
+ it("records the grant via the three-step kernel call and confirms in-place", async () => {
265
+ const { ctx, spy } = fakeCtx(99999);
266
+ const { deps, kernel } = depsFor({});
267
+ await handleFolderPickerCallback(ctx, "drvpick:grant:klanker:F1", deps);
268
+ expect(kernel.requests).toEqual([
269
+ { scope: "doc:gdrive:folder/F1/**", action: "read" },
270
+ ]);
271
+ expect(kernel.consumed).toEqual(["abcdef01"]);
272
+ expect(kernel.recorded).toEqual([
273
+ {
274
+ request_id: "abcdef01",
275
+ decision: "allow_always",
276
+ approver_set: ["99999"],
277
+ },
278
+ ]);
279
+ expect(spy.edits[0]?.text).toContain("✅ Granted klanker");
280
+ expect(spy.edits[0]?.text).toContain("doc:gdrive:folder/F1/**");
281
+ expect(spy.edits[0]?.text).toContain("/approvals revoke dec-1");
282
+ // Confirmation keyboard has Open-in-Drive URL.
283
+ expect(spy.edits[0]?.keyboardRows[0]).toEqual(["📖 Open in Drive"]);
284
+ expect(spy.callbackAnswers[0]?.text).toBe("Allowed");
285
+ });
286
+
287
+ it("surfaces each kernel failure step with a clear toast", async () => {
288
+ for (const failure of [
289
+ { failRequest: true, msg: /request failed/ },
290
+ { failConsume: true, msg: /consume failed/ },
291
+ { failRecord: true, msg: /record failed/ },
292
+ ] as const) {
293
+ const { ctx, spy } = fakeCtx();
294
+ const kernel: FakeKernel = {
295
+ requests: [],
296
+ consumed: [],
297
+ recorded: [],
298
+ nextRequestId: "abcdef01",
299
+ ...failure,
300
+ };
301
+ const { deps } = depsFor({ kernel });
302
+ await handleFolderPickerCallback(ctx, "drvpick:grant:klanker:F1", deps);
303
+ expect(spy.callbackAnswers[0]?.text).toMatch(failure.msg);
304
+ expect(spy.edits).toEqual([]); // no confirmation edit on failure
305
+ }
306
+ });
307
+
308
+ it("refuses when ctx.from is missing (no user id)", async () => {
309
+ const { ctx, spy } = fakeCtx(0);
310
+ const { deps } = depsFor({});
311
+ await handleFolderPickerCallback(ctx, "drvpick:grant:klanker:F1", deps);
312
+ expect(spy.callbackAnswers[0]?.text).toMatch(/user id/);
313
+ });
314
+ });