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.
- package/README.md +54 -61
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +285 -45
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/drive-write-pretool.mjs +5418 -0
- package/dist/cli/switchroom.js +8890 -5560
- package/dist/host-control/main.js +582 -43
- package/dist/vault/approvals/kernel-server.js +276 -47
- package/dist/vault/broker/server.js +333 -69
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +3 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/default/CLAUDE.md +10 -0
- package/profiles/default/CLAUDE.md.hbs +16 -0
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- package/skills/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +67 -15
- package/skills/switchroom-status/SKILL.md +26 -1
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/dispatch.test.ts +1 -1
- package/telegram-plugin/admin-commands/index.ts +9 -5
- 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 +17453 -15100
- 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-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +905 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/auth-status-adapter.ts +101 -0
- package/telegram-plugin/gateway/boot-card.ts +23 -37
- package/telegram-plugin/gateway/boot-probes.ts +9 -12
- 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 +1156 -938
- package/telegram-plugin/gateway/hostd-dispatch.ts +244 -0
- 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/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/model-unavailable.ts +28 -12
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/silence-poke.ts +153 -1
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-format2.test.ts +156 -0
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -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 +27 -22
- 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/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- 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 +35 -1
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +7 -1
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +7 -1
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/auto-fallback-dispatcher.ts +0 -68
- package/telegram-plugin/dist/foreman/foreman.js +0 -31358
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/auto-fallback-dispatcher.e2e.test.ts +0 -183
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- 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, "&")
|
|
168
|
+
.replace(/</g, "<")
|
|
169
|
+
.replace(/>/g, ">");
|
|
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
|
+
});
|