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,348 @@
1
+ /**
2
+ * Folder-picker Telegram handlers — RFC E §4.1 wire-up.
3
+ *
4
+ * Two entry points the gateway dispatcher calls into:
5
+ *
6
+ * - `handleFoldersCommand(ctx, deps)` ← `/folders` slash command
7
+ * - `handleFolderPickerCallback(ctx, data, deps)` ← `drvpick:` callback_query
8
+ *
9
+ * Both are kernel-agnostic — Drive API + approval-kernel + access-token
10
+ * sources are injected via `FolderPickerHandlerDeps` so the handlers
11
+ * are unit-testable without docker / Google / SQLite / grammy in the
12
+ * loop. The gateway construct module wires concrete deps from
13
+ * `src/drive/folder-list.ts`, `src/vault/approvals/client.ts`, and the
14
+ * auth-broker.
15
+ *
16
+ * Operator UX:
17
+ *
18
+ * 1. User types `/folders` in the agent's DM.
19
+ * 2. Gateway fetches the user's top-level Drive folders and posts
20
+ * a picker card with one row per folder ([Allow] + [Browse]).
21
+ * 3. Tapping [Browse "Work"] drills in — the gateway re-renders the
22
+ * card in place with the sub-folder list + a [⬅ Back] button.
23
+ * 4. Tapping [✅ Allow "<folder>"] writes a kernel `allow_always`
24
+ * grant at `doc:gdrive:folder/<id>/**` and edits the card to a
25
+ * green confirmation.
26
+ *
27
+ * Authorisation: same gate as the rest of the slash commands —
28
+ * `isAuthorizedSender(ctx)` upstream. The handler itself trusts the
29
+ * caller did the gate.
30
+ */
31
+
32
+ import type { Context } from "grammy";
33
+ import { InlineKeyboard } from "grammy";
34
+
35
+ import type { FolderListCache, FolderPage } from "../../src/drive/folder-list.js";
36
+ import {
37
+ buildFolderPickerCard,
38
+ parseFolderPickerCallback,
39
+ type FolderPickerCallback,
40
+ type FolderPickerCardSpec,
41
+ } from "../../src/drive/folder-picker.js";
42
+
43
+ // ────────────────────────────────────────────────────────────────────────
44
+ // Deps — injected by the gateway construct module
45
+ // ────────────────────────────────────────────────────────────────────────
46
+
47
+ export interface FolderPickerHandlerDeps {
48
+ /** Agent slug this gateway instance serves. */
49
+ agentName: string;
50
+ /**
51
+ * Fetch a single folder page from Drive. Production wires this to
52
+ * `fetchFolderPage()` from src/drive/folder-list.ts with the
53
+ * agent's Drive access-token injected. Tests pass a fake.
54
+ */
55
+ fetchPage: (args: {
56
+ parent_id?: string;
57
+ page_token?: string;
58
+ }) => Promise<FolderPage>;
59
+ /**
60
+ * Per-gateway-process folder-list cache. The cache holds the
61
+ * 5-min folder-page payloads AND the short-handle ↔ Drive
62
+ * page-token map used by `[Next ▶]` callbacks.
63
+ */
64
+ cache: FolderListCache;
65
+ /**
66
+ * Build a fresh kernel `request_id` for an upcoming grant. The
67
+ * gateway mints a request right before recording the decision —
68
+ * the kernel's request/consume/record flow is the only path to
69
+ * write an `approval_decisions` row.
70
+ */
71
+ approvalRequest: (args: {
72
+ agent_unit: string;
73
+ scope: string;
74
+ action: string;
75
+ approver_set: string[];
76
+ why?: string | null;
77
+ ttl_ms?: number | null;
78
+ }) => Promise<{ request_id: string } | null>;
79
+ /** Consume the pending request_id so approvalRecord can land the row. */
80
+ approvalConsume: (request_id: string) => Promise<boolean>;
81
+ /** Write the granted decision. */
82
+ approvalRecord: (args: {
83
+ request_id: string;
84
+ decision: "allow_always";
85
+ approver_set: string[];
86
+ granted_by_user_id: number;
87
+ ttl_ms?: number | null;
88
+ }) => Promise<string | null>;
89
+ }
90
+
91
+ // ────────────────────────────────────────────────────────────────────────
92
+ // `/folders` entry point
93
+ // ────────────────────────────────────────────────────────────────────────
94
+
95
+ export async function handleFoldersCommand(
96
+ ctx: Context,
97
+ deps: FolderPickerHandlerDeps,
98
+ ): Promise<void> {
99
+ let page: FolderPage;
100
+ try {
101
+ // First-page hit goes through the cache so a re-issued /folders
102
+ // within 5 min doesn't slam Drive's quota.
103
+ const hit = deps.cache.get(deps.agentName);
104
+ if (hit !== null) {
105
+ page = hit;
106
+ } else {
107
+ page = await deps.fetchPage({});
108
+ deps.cache.set(deps.agentName, page);
109
+ }
110
+ } catch (err) {
111
+ await ctx.reply(
112
+ `Drive folder listing failed: ${describe(err)}. Run \`switchroom drive connect ${deps.agentName}\` if the agent isn't authenticated.`,
113
+ );
114
+ return;
115
+ }
116
+
117
+ const card = buildFolderPickerCard({
118
+ agent: deps.agentName,
119
+ page,
120
+ cache: deps.cache,
121
+ });
122
+ await ctx.reply(card.body, { reply_markup: toKeyboard(card) });
123
+ }
124
+
125
+ // ────────────────────────────────────────────────────────────────────────
126
+ // `drvpick:` callback entry point
127
+ // ────────────────────────────────────────────────────────────────────────
128
+
129
+ export async function handleFolderPickerCallback(
130
+ ctx: Context,
131
+ data: string,
132
+ deps: FolderPickerHandlerDeps,
133
+ ): Promise<void> {
134
+ const parsed = parseFolderPickerCallback(data);
135
+ if (parsed === null) {
136
+ await ctx.answerCallbackQuery({ text: "malformed picker callback" });
137
+ return;
138
+ }
139
+
140
+ // Path-scoped agent guard — the picker callback names an agent in
141
+ // the data string. A gateway running for agent `klanker` MUST
142
+ // refuse callbacks meant for `clerk` (defense in depth on top of
143
+ // the per-agent socket isolation; relevant if a card from one
144
+ // agent's topic somehow leaks into another).
145
+ if (parsed.agent !== deps.agentName) {
146
+ await ctx.answerCallbackQuery({
147
+ text: `card is for agent '${parsed.agent}', this gateway serves '${deps.agentName}'`,
148
+ });
149
+ return;
150
+ }
151
+
152
+ switch (parsed.kind) {
153
+ case "open":
154
+ await renderPage(ctx, deps, {
155
+ parent_id: parsed.parent_id,
156
+ page_token_handle: parsed.page_token_handle,
157
+ forceRefresh: false,
158
+ });
159
+ break;
160
+ case "enter":
161
+ await renderPage(ctx, deps, {
162
+ parent_id: parsed.folder_id,
163
+ forceRefresh: false,
164
+ });
165
+ break;
166
+ case "back":
167
+ // RFC §4.1: tapping Back returns to the parent level. The
168
+ // callback payload carries the parent we're returning TO
169
+ // (empty string = top of Drive). The current breadcrumb
170
+ // chain is rebuilt by the card builder — we just need the
171
+ // target parent id.
172
+ await renderPage(ctx, deps, {
173
+ parent_id: parsed.parent_id,
174
+ forceRefresh: false,
175
+ });
176
+ break;
177
+ case "refresh":
178
+ await renderPage(ctx, deps, {
179
+ parent_id: parsed.parent_id,
180
+ forceRefresh: true,
181
+ });
182
+ break;
183
+ case "grant":
184
+ await recordGrantAndConfirm(ctx, deps, parsed);
185
+ break;
186
+ }
187
+ }
188
+
189
+ // ────────────────────────────────────────────────────────────────────────
190
+ // Sub-handlers
191
+ // ────────────────────────────────────────────────────────────────────────
192
+
193
+ async function renderPage(
194
+ ctx: Context,
195
+ deps: FolderPickerHandlerDeps,
196
+ args: {
197
+ parent_id?: string;
198
+ page_token_handle?: string;
199
+ forceRefresh: boolean;
200
+ },
201
+ ): Promise<void> {
202
+ let page: FolderPage;
203
+ try {
204
+ // Page-token handles bypass cache by design — they target a
205
+ // specific subsequent page that's not held in the first-page
206
+ // cache slot. Resolve the handle BEFORE attempting cache.
207
+ const resolved =
208
+ args.page_token_handle !== undefined
209
+ ? deps.cache.getPageToken(deps.agentName, args.page_token_handle)
210
+ : null;
211
+
212
+ if (resolved !== null) {
213
+ page = await deps.fetchPage({
214
+ parent_id: args.parent_id,
215
+ page_token: resolved,
216
+ });
217
+ } else if (
218
+ !args.forceRefresh &&
219
+ args.page_token_handle === undefined
220
+ ) {
221
+ const hit = deps.cache.get(deps.agentName, args.parent_id);
222
+ if (hit !== null) {
223
+ page = hit;
224
+ } else {
225
+ page = await deps.fetchPage({ parent_id: args.parent_id });
226
+ deps.cache.set(deps.agentName, page, args.parent_id);
227
+ }
228
+ } else {
229
+ // forceRefresh OR a handle that's expired ([↻ Refresh] / stale).
230
+ page = await deps.fetchPage({ parent_id: args.parent_id });
231
+ deps.cache.set(deps.agentName, page, args.parent_id);
232
+ }
233
+ } catch (err) {
234
+ await ctx.answerCallbackQuery({ text: `Drive error: ${describe(err)}` });
235
+ return;
236
+ }
237
+
238
+ const card = buildFolderPickerCard({
239
+ agent: deps.agentName,
240
+ page,
241
+ ...(args.parent_id !== undefined && args.parent_id.length > 0
242
+ ? { parent: { id: args.parent_id, name: args.parent_id } }
243
+ : {}),
244
+ cache: deps.cache,
245
+ });
246
+ try {
247
+ await ctx.editMessageText(card.body, { reply_markup: toKeyboard(card) });
248
+ } catch {
249
+ // Card may have been edited/deleted under us — operator can
250
+ // re-issue /folders to start over.
251
+ }
252
+ await ctx.answerCallbackQuery({});
253
+ }
254
+
255
+ async function recordGrantAndConfirm(
256
+ ctx: Context,
257
+ deps: FolderPickerHandlerDeps,
258
+ parsed: Extract<FolderPickerCallback, { kind: "grant" }>,
259
+ ): Promise<void> {
260
+ const granted_by_user_id = ctx.from?.id ?? 0;
261
+ if (granted_by_user_id === 0) {
262
+ await ctx.answerCallbackQuery({ text: "missing user id" });
263
+ return;
264
+ }
265
+
266
+ const scope = `doc:gdrive:folder/${parsed.folder_id}/**`;
267
+ const action = "read";
268
+
269
+ // Three-step kernel call: request → consume → record. The kernel
270
+ // requires the request-id chain even for operator-driven grants
271
+ // (no direct-record op as of the v0.10 schema). All three round-
272
+ // trips are UDS to the local kernel — no user-visible latency.
273
+ const requested = await deps.approvalRequest({
274
+ agent_unit: deps.agentName,
275
+ scope,
276
+ action,
277
+ approver_set: [String(granted_by_user_id)],
278
+ why: `folder picker grant via /folders`,
279
+ });
280
+ if (requested === null) {
281
+ await ctx.answerCallbackQuery({ text: "kernel request failed" });
282
+ return;
283
+ }
284
+
285
+ const consumed = await deps.approvalConsume(requested.request_id);
286
+ if (!consumed) {
287
+ await ctx.answerCallbackQuery({ text: "kernel consume failed" });
288
+ return;
289
+ }
290
+
291
+ const decisionId = await deps.approvalRecord({
292
+ request_id: requested.request_id,
293
+ decision: "allow_always",
294
+ approver_set: [String(granted_by_user_id)],
295
+ granted_by_user_id,
296
+ ttl_ms: null,
297
+ });
298
+ if (decisionId === null) {
299
+ await ctx.answerCallbackQuery({ text: "kernel record failed" });
300
+ return;
301
+ }
302
+
303
+ const confirmText =
304
+ `✅ Granted ${deps.agentName} access to folder ${parsed.folder_id}\n` +
305
+ `Scope: <code>${scope}</code>\n` +
306
+ `Revoke with: <code>/approvals revoke ${decisionId}</code>`;
307
+
308
+ // Keep an [Open in Drive] URL button on the confirmation so the
309
+ // operator can verify which folder they granted.
310
+ const kb = new InlineKeyboard().url(
311
+ "📖 Open in Drive",
312
+ `https://drive.google.com/drive/folders/${parsed.folder_id}`,
313
+ );
314
+ try {
315
+ await ctx.editMessageText(confirmText, {
316
+ parse_mode: "HTML",
317
+ reply_markup: kb,
318
+ });
319
+ } catch {
320
+ // Best-effort.
321
+ }
322
+ await ctx.answerCallbackQuery({ text: "Allowed" });
323
+ }
324
+
325
+ // ────────────────────────────────────────────────────────────────────────
326
+ // Helpers
327
+ // ────────────────────────────────────────────────────────────────────────
328
+
329
+ function toKeyboard(spec: FolderPickerCardSpec): InlineKeyboard {
330
+ const kb = new InlineKeyboard();
331
+ for (let r = 0; r < spec.rows.length; r++) {
332
+ const row = spec.rows[r]!;
333
+ for (const btn of row) {
334
+ kb.text(btn.text, btn.callback_data);
335
+ }
336
+ if (r < spec.rows.length - 1) kb.row();
337
+ }
338
+ return kb;
339
+ }
340
+
341
+ function describe(err: unknown): string {
342
+ if (err instanceof Error) return err.message;
343
+ try {
344
+ return JSON.stringify(err);
345
+ } catch {
346
+ return String(err);
347
+ }
348
+ }