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.
- package/README.md +5 -4
- package/dist/agent-scheduler/index.js +2 -2
- package/dist/auth-broker/index.js +125 -3
- package/dist/cli/drive-write-pretool.mjs +5436 -0
- package/dist/cli/switchroom.js +231 -29
- package/dist/host-control/main.js +2 -2
- package/dist/vault/approvals/kernel-server.js +2 -2
- package/dist/vault/broker/server.js +2 -2
- 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 +4314 -2143
- 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-broker-client.ts +2 -0
- package/telegram-plugin/gateway/auth-command.ts +131 -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 +903 -173
- package/telegram-plugin/gateway/hostd-dispatch.ts +137 -2
- 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,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
|
+
}
|