switchroom 0.8.1 → 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.
Files changed (137) hide show
  1. package/README.md +54 -61
  2. package/bin/timezone-hook.sh +9 -7
  3. package/dist/agent-scheduler/index.js +285 -45
  4. package/dist/auth-broker/index.js +13932 -0
  5. package/dist/cli/drive-write-pretool.mjs +5418 -0
  6. package/dist/cli/switchroom.js +8890 -5560
  7. package/dist/host-control/main.js +582 -43
  8. package/dist/vault/approvals/kernel-server.js +276 -47
  9. package/dist/vault/broker/server.js +333 -69
  10. package/examples/minimal.yaml +63 -0
  11. package/examples/personal-google-workspace-mcp/.env.example +34 -0
  12. package/examples/personal-google-workspace-mcp/README.md +194 -0
  13. package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
  14. package/examples/switchroom.yaml +220 -0
  15. package/package.json +6 -4
  16. package/profiles/_base/start.sh.hbs +3 -3
  17. package/profiles/_shared/agent-self-service.md.hbs +126 -0
  18. package/profiles/default/CLAUDE.md +10 -0
  19. package/profiles/default/CLAUDE.md.hbs +16 -0
  20. package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
  21. package/skills/buildkite-agent-runtime/SKILL.md +44 -11
  22. package/skills/buildkite-api/SKILL.md +31 -8
  23. package/skills/buildkite-cli/SKILL.md +27 -9
  24. package/skills/buildkite-migration/SKILL.md +22 -9
  25. package/skills/buildkite-pipelines/SKILL.md +26 -9
  26. package/skills/buildkite-secure-delivery/SKILL.md +23 -9
  27. package/skills/buildkite-test-engine/SKILL.md +25 -8
  28. package/skills/docx/SKILL.md +1 -1
  29. package/skills/file-bug/SKILL.md +34 -6
  30. package/skills/humanizer/SKILL.md +15 -0
  31. package/skills/humanizer-calibrate/SKILL.md +7 -1
  32. package/skills/mcp-builder/SKILL.md +1 -1
  33. package/skills/pdf/SKILL.md +1 -1
  34. package/skills/pptx/SKILL.md +1 -1
  35. package/skills/skill-creator/SKILL.md +21 -1
  36. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
  37. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
  38. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
  39. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
  40. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
  41. package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
  42. package/skills/switchroom-cli/SKILL.md +63 -64
  43. package/skills/switchroom-health/SKILL.md +23 -10
  44. package/skills/switchroom-install/SKILL.md +3 -3
  45. package/skills/switchroom-manage/SKILL.md +26 -19
  46. package/skills/switchroom-runtime/SKILL.md +67 -15
  47. package/skills/switchroom-status/SKILL.md +26 -1
  48. package/skills/telegram-test-harness/SKILL.md +3 -0
  49. package/skills/webapp-testing/SKILL.md +31 -1
  50. package/skills/xlsx/SKILL.md +1 -1
  51. package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
  52. package/telegram-plugin/admin-commands/index.ts +9 -5
  53. package/telegram-plugin/auth-snapshot-format.ts +612 -0
  54. package/telegram-plugin/auto-fallback-fleet.ts +215 -0
  55. package/telegram-plugin/auto-fallback.ts +28 -301
  56. package/telegram-plugin/dist/gateway/gateway.js +17453 -15100
  57. package/telegram-plugin/fleet-fallback-gate.ts +105 -0
  58. package/telegram-plugin/gateway/approval-callback.test.ts +104 -0
  59. package/telegram-plugin/gateway/approval-callback.ts +31 -3
  60. package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
  61. package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
  62. package/telegram-plugin/gateway/auth-command.ts +905 -0
  63. package/telegram-plugin/gateway/auth-line.ts +123 -0
  64. package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
  65. package/telegram-plugin/gateway/boot-card.ts +23 -37
  66. package/telegram-plugin/gateway/boot-probes.ts +9 -12
  67. package/telegram-plugin/gateway/diff-preview-card.test.ts +192 -0
  68. package/telegram-plugin/gateway/diff-preview-card.ts +170 -0
  69. package/telegram-plugin/gateway/drive-write-approval.test.ts +312 -0
  70. package/telegram-plugin/gateway/drive-write-approval.ts +243 -0
  71. package/telegram-plugin/gateway/folder-picker-handler.test.ts +314 -0
  72. package/telegram-plugin/gateway/folder-picker-handler.ts +348 -0
  73. package/telegram-plugin/gateway/gateway.ts +1156 -938
  74. package/telegram-plugin/gateway/hostd-dispatch.ts +244 -0
  75. package/telegram-plugin/gateway/ipc-protocol.ts +83 -2
  76. package/telegram-plugin/gateway/ipc-server.ts +69 -0
  77. package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +103 -12
  78. package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
  79. package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
  80. package/telegram-plugin/model-unavailable.ts +28 -12
  81. package/telegram-plugin/permission-title.ts +56 -0
  82. package/telegram-plugin/quota-check.ts +19 -41
  83. package/telegram-plugin/scripts/build.mjs +0 -1
  84. package/telegram-plugin/shared/bot-runtime.ts +5 -4
  85. package/telegram-plugin/silence-poke.ts +153 -1
  86. package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
  87. package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
  88. package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
  89. package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
  90. package/telegram-plugin/tests/auth-snapshot-format.test.ts +429 -0
  91. package/telegram-plugin/tests/auth-status-adapter.test.ts +129 -0
  92. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +211 -0
  93. package/telegram-plugin/tests/auto-fallback.test.ts +60 -358
  94. package/telegram-plugin/tests/boot-probes.test.ts +27 -22
  95. package/telegram-plugin/tests/fleet-fallback-gate.test.ts +197 -0
  96. package/telegram-plugin/tests/model-unavailable.test.ts +30 -5
  97. package/telegram-plugin/tests/permission-title.test.ts +31 -0
  98. package/telegram-plugin/tests/quota-check.test.ts +5 -35
  99. package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +212 -2
  100. package/telegram-plugin/tests/silence-poke.test.ts +237 -0
  101. package/telegram-plugin/tests/turn-flush-safety.test.ts +112 -0
  102. package/telegram-plugin/turn-flush-safety.ts +55 -1
  103. package/telegram-plugin/uat/SETUP.md +35 -1
  104. package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
  105. package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
  106. package/telegram-plugin/uat/runners/report.ts +150 -0
  107. package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
  108. package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
  109. package/telegram-plugin/uat/runners/scorer.ts +106 -0
  110. package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
  111. package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
  112. package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
  113. package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
  114. package/telegram-plugin/auth-dashboard.ts +0 -1104
  115. package/telegram-plugin/auth-slot-parser.ts +0 -497
  116. package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
  117. package/telegram-plugin/dist/foreman/foreman.js +0 -31358
  118. package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
  119. package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
  120. package/telegram-plugin/foreman/foreman.ts +0 -1165
  121. package/telegram-plugin/foreman/setup-flow.ts +0 -345
  122. package/telegram-plugin/foreman/setup-state.ts +0 -239
  123. package/telegram-plugin/foreman/state.ts +0 -203
  124. package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
  125. package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
  126. package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
  127. package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
  128. package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
  129. package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
  130. package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
  131. package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
  132. package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
  133. package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
  134. package/telegram-plugin/tests/foreman-state.test.ts +0 -164
  135. package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
  136. package/telegram-plugin/tests/setup-flow.test.ts +0 -510
  137. package/telegram-plugin/tests/setup-state.test.ts +0 -146
@@ -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
+ });